luckiest-co 1.0.0 → 1.0.3
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 +16 -0
- package/.claude-plugin/plugin.json +1 -1
- package/LICENSE +24 -0
- package/bin/install.js +7 -5
- package/commands/{luckiest/finish.md → finish.md} +2 -0
- package/commands/go.md +53 -0
- package/commands/plan.md +66 -0
- package/package.json +16 -5
- package/skills/brainstorm/ATTRIBUTION.md +27 -0
- package/skills/brainstorm/SKILL.md +139 -0
- package/skills/luckiest-ab-testing/ATTRIBUTION.md +35 -0
- package/skills/luckiest-ab-testing/CHANGELOG.md +27 -0
- package/skills/luckiest-ab-testing/SKILL.md +379 -0
- package/skills/luckiest-ab-testing/evals/evals.json +118 -0
- package/skills/luckiest-ab-testing/references/sample-size-guide.md +290 -0
- package/skills/luckiest-ab-testing/references/test-templates.md +284 -0
- package/skills/luckiest-ad-creative/ATTRIBUTION.md +35 -0
- package/skills/luckiest-ad-creative/CHANGELOG.md +23 -0
- package/skills/luckiest-ad-creative/SKILL.md +393 -0
- package/skills/luckiest-ad-creative/evals/evals.json +90 -0
- package/skills/luckiest-ad-creative/references/generative-tools.md +637 -0
- package/skills/luckiest-ad-creative/references/platform-specs.md +213 -0
- package/skills/luckiest-ads/ATTRIBUTION.md +36 -0
- package/skills/luckiest-ads/CHANGELOG.md +24 -0
- package/skills/luckiest-ads/LICENSE +21 -0
- package/skills/luckiest-ads/SKILL.md +434 -0
- package/skills/luckiest-ads/evals/evals.json +90 -0
- package/skills/luckiest-ads/references/ad-copy-templates.md +207 -0
- package/skills/luckiest-ads/references/audience-targeting.md +243 -0
- package/skills/luckiest-ads/references/conversion-tracking.md +361 -0
- package/skills/luckiest-ads/references/platform-setup-checklists.md +277 -0
- package/skills/luckiest-advisors/ATTRIBUTION.md +16 -0
- package/skills/luckiest-advisors/CHANGELOG.md +99 -0
- package/skills/luckiest-advisors/SKILL.md +151 -0
- package/skills/luckiest-aeo-grader/ATTRIBUTION.md +19 -0
- package/skills/luckiest-aeo-grader/CHANGELOG.md +52 -0
- package/skills/luckiest-aeo-grader/SKILL.md +200 -0
- package/skills/luckiest-aeo-grader/evals/evals.json +19 -0
- package/skills/luckiest-aeo-grader/references/rubric.md +72 -0
- package/skills/luckiest-aeo-grader/scripts/scan.py +265 -0
- package/skills/luckiest-ai-seo/ATTRIBUTION.md +38 -0
- package/skills/luckiest-ai-seo/CHANGELOG.md +23 -0
- package/skills/luckiest-ai-seo/SKILL.md +510 -0
- package/skills/luckiest-ai-seo/evals/evals.json +90 -0
- package/skills/luckiest-ai-seo/references/content-patterns.md +285 -0
- package/skills/luckiest-ai-seo/references/content-types.md +71 -0
- package/skills/luckiest-ai-seo/references/okf.md +104 -0
- package/skills/luckiest-ai-seo/references/platform-ranking-factors.md +154 -0
- package/skills/luckiest-analytics/ATTRIBUTION.md +42 -0
- package/skills/luckiest-analytics/CHANGELOG.md +36 -0
- package/skills/luckiest-analytics/SKILL.md +380 -0
- package/skills/luckiest-analytics/evals/evals.json +90 -0
- package/skills/luckiest-analytics/references/event-library.md +260 -0
- package/skills/luckiest-analytics/references/ga4-implementation.md +300 -0
- package/skills/luckiest-analytics/references/gtm-implementation.md +390 -0
- package/skills/luckiest-aso/ATTRIBUTION.md +40 -0
- package/skills/luckiest-aso/CHANGELOG.md +33 -0
- package/skills/luckiest-aso/SKILL.md +327 -0
- package/skills/luckiest-aso/evals/evals.json +91 -0
- package/skills/luckiest-aso/references/apple-specs.md +107 -0
- package/skills/luckiest-aso/references/benchmarks.md +129 -0
- package/skills/luckiest-aso/references/google-play-specs.md +131 -0
- package/skills/luckiest-aso/references/report-template.md +213 -0
- package/skills/luckiest-aso/references/scoring-criteria.md +213 -0
- package/skills/luckiest-churn-prevention/ATTRIBUTION.md +41 -0
- package/skills/luckiest-churn-prevention/CHANGELOG.md +24 -0
- package/skills/luckiest-churn-prevention/SKILL.md +445 -0
- package/skills/luckiest-churn-prevention/evals/evals.json +93 -0
- package/skills/luckiest-churn-prevention/references/cancel-flow-patterns.md +316 -0
- package/skills/luckiest-churn-prevention/references/dunning-playbook.md +408 -0
- package/skills/luckiest-co-marketing/ATTRIBUTION.md +38 -0
- package/skills/luckiest-co-marketing/CHANGELOG.md +25 -0
- package/skills/luckiest-co-marketing/SKILL.md +331 -0
- package/skills/luckiest-co-marketing/evals/evals.json +84 -0
- package/skills/luckiest-coder/ATTRIBUTION.md +26 -0
- package/skills/luckiest-coder/CHANGELOG.md +30 -0
- package/skills/luckiest-coder/SKILL.md +77 -0
- package/skills/luckiest-coder/references/antigravity-tools.md +23 -0
- package/skills/luckiest-coder/references/codex-tools.md +39 -0
- package/skills/luckiest-coder/references/pi-tools.md +16 -0
- package/skills/luckiest-coder-brainstorming/ATTRIBUTION.md +26 -0
- package/skills/luckiest-coder-brainstorming/CHANGELOG.md +31 -0
- package/skills/luckiest-coder-brainstorming/SKILL.md +173 -0
- package/skills/luckiest-coder-brainstorming/scripts/frame-template.html +213 -0
- package/skills/luckiest-coder-brainstorming/scripts/helper.js +168 -0
- package/skills/luckiest-coder-brainstorming/scripts/server.cjs +721 -0
- package/skills/luckiest-coder-brainstorming/scripts/start-server.sh +209 -0
- package/skills/luckiest-coder-brainstorming/scripts/stop-server.sh +120 -0
- package/skills/luckiest-coder-brainstorming/spec-document-reviewer-prompt.md +49 -0
- package/skills/luckiest-coder-brainstorming/visual-companion.md +291 -0
- package/skills/luckiest-coder-consistency/ATTRIBUTION.md +30 -0
- package/skills/luckiest-coder-consistency/CHANGELOG.md +26 -0
- package/skills/luckiest-coder-consistency/SKILL.md +98 -0
- package/skills/luckiest-coder-constitution/ATTRIBUTION.md +28 -0
- package/skills/luckiest-coder-constitution/CHANGELOG.md +25 -0
- package/skills/luckiest-coder-constitution/SKILL.md +115 -0
- package/skills/luckiest-coder-debugging/ATTRIBUTION.md +26 -0
- package/skills/luckiest-coder-debugging/CHANGELOG.md +22 -0
- package/skills/luckiest-coder-debugging/CREATION-LOG.md +119 -0
- package/skills/luckiest-coder-debugging/SKILL.md +309 -0
- package/skills/luckiest-coder-debugging/condition-based-waiting-example.ts +158 -0
- package/skills/luckiest-coder-debugging/condition-based-waiting.md +115 -0
- package/skills/luckiest-coder-debugging/defense-in-depth.md +122 -0
- package/skills/luckiest-coder-debugging/find-polluter.sh +63 -0
- package/skills/luckiest-coder-debugging/root-cause-tracing.md +169 -0
- package/skills/luckiest-coder-debugging/test-academic.md +14 -0
- package/skills/luckiest-coder-debugging/test-pressure-1.md +58 -0
- package/skills/luckiest-coder-debugging/test-pressure-2.md +68 -0
- package/skills/luckiest-coder-debugging/test-pressure-3.md +69 -0
- package/skills/luckiest-coder-dispatching-parallel-agents/ATTRIBUTION.md +26 -0
- package/skills/luckiest-coder-dispatching-parallel-agents/CHANGELOG.md +23 -0
- package/skills/luckiest-coder-dispatching-parallel-agents/SKILL.md +198 -0
- package/skills/luckiest-coder-executing-plans/ATTRIBUTION.md +26 -0
- package/skills/luckiest-coder-executing-plans/CHANGELOG.md +22 -0
- package/skills/luckiest-coder-executing-plans/SKILL.md +83 -0
- package/skills/luckiest-coder-finishing-a-branch/ATTRIBUTION.md +26 -0
- package/skills/luckiest-coder-finishing-a-branch/CHANGELOG.md +22 -0
- package/skills/luckiest-coder-finishing-a-branch/SKILL.md +258 -0
- package/skills/luckiest-coder-git-worktrees/ATTRIBUTION.md +26 -0
- package/skills/luckiest-coder-git-worktrees/CHANGELOG.md +22 -0
- package/skills/luckiest-coder-git-worktrees/SKILL.md +215 -0
- package/skills/luckiest-coder-guard/ATTRIBUTION.md +29 -0
- package/skills/luckiest-coder-guard/CHANGELOG.md +48 -0
- package/skills/luckiest-coder-guard/LICENSE +21 -0
- package/skills/luckiest-coder-guard/SKILL.md +126 -0
- package/skills/luckiest-coder-guard/bin/check-careful.sh +65 -0
- package/skills/luckiest-coder-guard/bin/check-freeze.sh +48 -0
- package/skills/luckiest-coder-qa/ATTRIBUTION.md +28 -0
- package/skills/luckiest-coder-qa/CHANGELOG.md +49 -0
- package/skills/luckiest-coder-qa/LICENSE +21 -0
- package/skills/luckiest-coder-qa/SKILL.md +160 -0
- package/skills/luckiest-coder-qa/references/issue-taxonomy.md +85 -0
- package/skills/luckiest-coder-qa/references/qa-report-template.md +126 -0
- package/skills/luckiest-coder-receiving-code-review/ATTRIBUTION.md +26 -0
- package/skills/luckiest-coder-receiving-code-review/CHANGELOG.md +28 -0
- package/skills/luckiest-coder-receiving-code-review/SKILL.md +226 -0
- package/skills/luckiest-coder-requesting-code-review/ATTRIBUTION.md +26 -0
- package/skills/luckiest-coder-requesting-code-review/CHANGELOG.md +31 -0
- package/skills/luckiest-coder-requesting-code-review/SKILL.md +118 -0
- package/skills/luckiest-coder-requesting-code-review/code-reviewer.md +172 -0
- package/skills/luckiest-coder-shipping/ATTRIBUTION.md +26 -0
- package/skills/luckiest-coder-shipping/CHANGELOG.md +48 -0
- package/skills/luckiest-coder-shipping/LICENSE +21 -0
- package/skills/luckiest-coder-shipping/SKILL.md +148 -0
- package/skills/luckiest-coder-shipping/references/canary.md +44 -0
- package/skills/luckiest-coder-shipping/references/test-and-coverage.md +91 -0
- package/skills/luckiest-coder-subagent-driven-development/ATTRIBUTION.md +26 -0
- package/skills/luckiest-coder-subagent-driven-development/CHANGELOG.md +28 -0
- package/skills/luckiest-coder-subagent-driven-development/SKILL.md +439 -0
- package/skills/luckiest-coder-subagent-driven-development/implementer-prompt.md +139 -0
- package/skills/luckiest-coder-subagent-driven-development/scripts/review-package +44 -0
- package/skills/luckiest-coder-subagent-driven-development/scripts/sdd-workspace +22 -0
- package/skills/luckiest-coder-subagent-driven-development/scripts/task-brief +40 -0
- package/skills/luckiest-coder-subagent-driven-development/task-reviewer-prompt.md +188 -0
- package/skills/luckiest-coder-tdd/ATTRIBUTION.md +26 -0
- package/skills/luckiest-coder-tdd/CHANGELOG.md +26 -0
- package/skills/luckiest-coder-tdd/SKILL.md +384 -0
- package/skills/luckiest-coder-tdd/testing-anti-patterns.md +299 -0
- package/skills/luckiest-coder-verification/ATTRIBUTION.md +26 -0
- package/skills/luckiest-coder-verification/CHANGELOG.md +22 -0
- package/skills/luckiest-coder-verification/SKILL.md +152 -0
- package/skills/luckiest-coder-writing-plans/ATTRIBUTION.md +26 -0
- package/skills/luckiest-coder-writing-plans/CHANGELOG.md +22 -0
- package/skills/luckiest-coder-writing-plans/SKILL.md +191 -0
- package/skills/luckiest-coder-writing-plans/plan-document-reviewer-prompt.md +49 -0
- package/skills/luckiest-coder-writing-skills/ATTRIBUTION.md +26 -0
- package/skills/luckiest-coder-writing-skills/CHANGELOG.md +31 -0
- package/skills/luckiest-coder-writing-skills/SKILL.md +702 -0
- package/skills/luckiest-coder-writing-skills/anthropic-best-practices.md +1150 -0
- package/skills/luckiest-coder-writing-skills/examples/CLAUDE_MD_TESTING.md +189 -0
- package/skills/luckiest-coder-writing-skills/graphviz-conventions.dot +172 -0
- package/skills/luckiest-coder-writing-skills/persuasion-principles.md +187 -0
- package/skills/luckiest-coder-writing-skills/render-graphs.js +168 -0
- package/skills/luckiest-coder-writing-skills/testing-skills-with-subagents.md +384 -0
- package/skills/luckiest-cold-email/ATTRIBUTION.md +36 -0
- package/skills/luckiest-cold-email/CHANGELOG.md +24 -0
- package/skills/luckiest-cold-email/SKILL.md +213 -0
- package/skills/luckiest-cold-email/evals/evals.json +94 -0
- package/skills/luckiest-cold-email/references/benchmarks.md +83 -0
- package/skills/luckiest-cold-email/references/follow-up-sequences.md +81 -0
- package/skills/luckiest-cold-email/references/frameworks.md +90 -0
- package/skills/luckiest-cold-email/references/personalization.md +79 -0
- package/skills/luckiest-cold-email/references/subject-lines.md +53 -0
- package/skills/luckiest-community-marketing/ATTRIBUTION.md +41 -0
- package/skills/luckiest-community-marketing/CHANGELOG.md +35 -0
- package/skills/luckiest-community-marketing/SKILL.md +200 -0
- package/skills/luckiest-community-marketing/evals/evals.json +89 -0
- package/skills/luckiest-competitor-profiling/ATTRIBUTION.md +36 -0
- package/skills/luckiest-competitor-profiling/CHANGELOG.md +24 -0
- package/skills/luckiest-competitor-profiling/SKILL.md +443 -0
- package/skills/luckiest-competitor-profiling/evals/evals.json +85 -0
- package/skills/luckiest-competitor-profiling/references/templates.md +167 -0
- package/skills/luckiest-competitor-profiling/references/tool-reference.md +179 -0
- package/skills/luckiest-competitors/ATTRIBUTION.md +36 -0
- package/skills/luckiest-competitors/CHANGELOG.md +25 -0
- package/skills/luckiest-competitors/SKILL.md +171 -0
- package/skills/luckiest-competitors/evals/evals.json +93 -0
- package/skills/luckiest-competitors/references/content-architecture.md +271 -0
- package/skills/luckiest-competitors/references/templates.md +223 -0
- package/skills/luckiest-content-strategy/ATTRIBUTION.md +39 -0
- package/skills/luckiest-content-strategy/CHANGELOG.md +23 -0
- package/skills/luckiest-content-strategy/SKILL.md +395 -0
- package/skills/luckiest-content-strategy/evals/evals.json +90 -0
- package/skills/luckiest-content-strategy/references/headless-cms.md +194 -0
- package/skills/luckiest-copy-editing/ATTRIBUTION.md +41 -0
- package/skills/luckiest-copy-editing/CHANGELOG.md +31 -0
- package/skills/luckiest-copy-editing/SKILL.md +491 -0
- package/skills/luckiest-copy-editing/evals/evals.json +89 -0
- package/skills/luckiest-copy-editing/references/checklist.md +66 -0
- package/skills/luckiest-copy-editing/references/content-refresh.md +38 -0
- package/skills/luckiest-copy-editing/references/plain-english-alternatives.md +394 -0
- package/skills/luckiest-copywriting/ATTRIBUTION.md +35 -0
- package/skills/luckiest-copywriting/CHANGELOG.md +23 -0
- package/skills/luckiest-copywriting/SKILL.md +296 -0
- package/skills/luckiest-copywriting/evals/evals.json +111 -0
- package/skills/luckiest-copywriting/references/copy-frameworks.md +344 -0
- package/skills/luckiest-copywriting/references/natural-transitions.md +272 -0
- package/skills/luckiest-cro/ATTRIBUTION.md +34 -0
- package/skills/luckiest-cro/CHANGELOG.md +23 -0
- package/skills/luckiest-cro/SKILL.md +215 -0
- package/skills/luckiest-cro/evals/evals.json +111 -0
- package/skills/luckiest-cro/references/experiments.md +248 -0
- package/skills/luckiest-cro/references/form.md +422 -0
- package/skills/luckiest-customer-research/ATTRIBUTION.md +40 -0
- package/skills/luckiest-customer-research/CHANGELOG.md +26 -0
- package/skills/luckiest-customer-research/SKILL.md +293 -0
- package/skills/luckiest-customer-research/evals/evals.json +162 -0
- package/skills/luckiest-customer-research/references/source-guides.md +401 -0
- package/skills/luckiest-directory-submissions/ATTRIBUTION.md +40 -0
- package/skills/luckiest-directory-submissions/CHANGELOG.md +33 -0
- package/skills/luckiest-directory-submissions/SKILL.md +419 -0
- package/skills/luckiest-directory-submissions/evals/evals.json +94 -0
- package/skills/luckiest-directory-submissions/references/directory-list.md +477 -0
- package/skills/luckiest-directory-submissions/references/positioning-variations.md +232 -0
- package/skills/luckiest-directory-submissions/references/submission-tracker-template.csv +266 -0
- package/skills/luckiest-emails/ATTRIBUTION.md +42 -0
- package/skills/luckiest-emails/CHANGELOG.md +33 -0
- package/skills/luckiest-emails/SKILL.md +220 -0
- package/skills/luckiest-emails/evals/evals.json +93 -0
- package/skills/luckiest-emails/references/copy-guidelines.md +120 -0
- package/skills/luckiest-emails/references/email-types.md +515 -0
- package/skills/luckiest-emails/references/sequence-templates.md +168 -0
- package/skills/luckiest-extract-design-system/ATTRIBUTION.md +8 -0
- package/skills/luckiest-extract-design-system/CHANGELOG.md +37 -0
- package/skills/luckiest-extract-design-system/SKILL.md +83 -0
- package/skills/luckiest-free-tools/ATTRIBUTION.md +43 -0
- package/skills/luckiest-free-tools/CHANGELOG.md +25 -0
- package/skills/luckiest-free-tools/SKILL.md +220 -0
- package/skills/luckiest-free-tools/evals/evals.json +90 -0
- package/skills/luckiest-free-tools/references/tool-types.md +217 -0
- package/skills/luckiest-image/ATTRIBUTION.md +43 -0
- package/skills/luckiest-image/CHANGELOG.md +23 -0
- package/skills/luckiest-image/SKILL.md +376 -0
- package/skills/luckiest-image/evals/evals.json +89 -0
- package/skills/luckiest-image/references/ai-image-prompting.md +229 -0
- package/skills/luckiest-launch/ATTRIBUTION.md +40 -0
- package/skills/luckiest-launch/CHANGELOG.md +43 -0
- package/skills/luckiest-launch/SKILL.md +385 -0
- package/skills/luckiest-launch/evals/evals.json +91 -0
- package/skills/luckiest-lead-magnets/ATTRIBUTION.md +35 -0
- package/skills/luckiest-lead-magnets/CHANGELOG.md +23 -0
- package/skills/luckiest-lead-magnets/SKILL.md +331 -0
- package/skills/luckiest-lead-magnets/evals/evals.json +87 -0
- package/skills/luckiest-lead-magnets/references/benchmarks.md +129 -0
- package/skills/luckiest-lead-magnets/references/format-guide.md +196 -0
- package/skills/luckiest-marketing-ideas/ATTRIBUTION.md +39 -0
- package/skills/luckiest-marketing-ideas/CHANGELOG.md +33 -0
- package/skills/luckiest-marketing-ideas/SKILL.md +191 -0
- package/skills/luckiest-marketing-ideas/evals/evals.json +90 -0
- package/skills/luckiest-marketing-ideas/references/ideas-by-category.md +366 -0
- package/skills/luckiest-marketing-plan/ATTRIBUTION.md +40 -0
- package/skills/luckiest-marketing-plan/CHANGELOG.md +23 -0
- package/skills/luckiest-marketing-plan/SKILL.md +285 -0
- package/skills/luckiest-marketing-plan/evals/evals.json +96 -0
- package/skills/luckiest-marketing-plan/references/aarrr-framework.md +180 -0
- package/skills/luckiest-marketing-plan/references/budget-planning.md +168 -0
- package/skills/luckiest-marketing-plan/references/client-types.md +373 -0
- package/skills/luckiest-marketing-plan/references/current-state-rubric.md +255 -0
- package/skills/luckiest-marketing-plan/references/example-quietude.md +972 -0
- package/skills/luckiest-marketing-plan/references/funding-stage-unlocks.md +230 -0
- package/skills/luckiest-marketing-plan/references/growth-patterns.md +148 -0
- package/skills/luckiest-marketing-plan/references/idea-cross-reference.md +265 -0
- package/skills/luckiest-marketing-plan/references/measurement-framework.md +213 -0
- package/skills/luckiest-marketing-plan/references/methodology.md +363 -0
- package/skills/luckiest-marketing-plan/references/ops-stack-mapping.md +197 -0
- package/skills/luckiest-marketing-plan/references/plan-template.md +494 -0
- package/skills/luckiest-marketing-plan/references/team-and-agency-model.md +278 -0
- package/skills/luckiest-marketing-psychology/ATTRIBUTION.md +31 -0
- package/skills/luckiest-marketing-psychology/CHANGELOG.md +32 -0
- package/skills/luckiest-marketing-psychology/SKILL.md +202 -0
- package/skills/luckiest-marketing-psychology/evals.json +88 -0
- package/skills/luckiest-model-router/ATTRIBUTION.md +11 -0
- package/skills/luckiest-model-router/CHANGELOG.md +45 -0
- package/skills/luckiest-model-router/SKILL.md +74 -0
- package/skills/luckiest-model-router/scripts/route.py +145 -0
- package/skills/luckiest-offers/ATTRIBUTION.md +39 -0
- package/skills/luckiest-offers/CHANGELOG.md +23 -0
- package/skills/luckiest-offers/SKILL.md +167 -0
- package/skills/luckiest-offers/references/bonus-stacking.md +150 -0
- package/skills/luckiest-offers/references/examples.md +215 -0
- package/skills/luckiest-offers/references/guarantee-design.md +172 -0
- package/skills/luckiest-offers/references/offer-anatomy.md +203 -0
- package/skills/luckiest-offers/references/offer-formats.md +240 -0
- package/skills/luckiest-offers/references/scarcity-urgency.md +175 -0
- package/skills/luckiest-offers/references/value-equation.md +134 -0
- package/skills/luckiest-onboarding/ATTRIBUTION.md +43 -0
- package/skills/luckiest-onboarding/CHANGELOG.md +31 -0
- package/skills/luckiest-onboarding/SKILL.md +243 -0
- package/skills/luckiest-onboarding/references/experiments.md +258 -0
- package/skills/luckiest-paywalls/ATTRIBUTION.md +39 -0
- package/skills/luckiest-paywalls/CHANGELOG.md +25 -0
- package/skills/luckiest-paywalls/SKILL.md +256 -0
- package/skills/luckiest-paywalls/evals/evals.json +93 -0
- package/skills/luckiest-paywalls/references/experiments.md +164 -0
- package/skills/luckiest-popups/ATTRIBUTION.md +35 -0
- package/skills/luckiest-popups/CHANGELOG.md +27 -0
- package/skills/luckiest-popups/SKILL.md +477 -0
- package/skills/luckiest-popups/evals/evals.json +107 -0
- package/skills/luckiest-pricing/ATTRIBUTION.md +35 -0
- package/skills/luckiest-pricing/CHANGELOG.md +31 -0
- package/skills/luckiest-pricing/SKILL.md +283 -0
- package/skills/luckiest-pricing/evals/evals.json +90 -0
- package/skills/luckiest-pricing/references/research-methods.md +152 -0
- package/skills/luckiest-pricing/references/tier-structure.md +232 -0
- package/skills/luckiest-product-marketing/ATTRIBUTION.md +38 -0
- package/skills/luckiest-product-marketing/CHANGELOG.md +23 -0
- package/skills/luckiest-product-marketing/SKILL.md +256 -0
- package/skills/luckiest-product-marketing/evals/evals.json +85 -0
- package/skills/luckiest-programmatic-seo/ATTRIBUTION.md +35 -0
- package/skills/luckiest-programmatic-seo/CHANGELOG.md +24 -0
- package/skills/luckiest-programmatic-seo/SKILL.md +286 -0
- package/skills/luckiest-programmatic-seo/evals/evals.json +95 -0
- package/skills/luckiest-programmatic-seo/references/playbooks.md +308 -0
- package/skills/luckiest-prompt-rewrite/ATTRIBUTION.md +39 -0
- package/skills/luckiest-prompt-rewrite/CHANGELOG.md +70 -0
- package/skills/luckiest-prompt-rewrite/LICENSE +21 -0
- package/skills/luckiest-prompt-rewrite/SKILL.md +494 -0
- package/skills/luckiest-prompt-rewrite/references/patterns.md +82 -0
- package/skills/luckiest-prompt-rewrite/references/templates.md +454 -0
- package/skills/luckiest-prospecting/ATTRIBUTION.md +29 -0
- package/skills/luckiest-prospecting/CHANGELOG.md +24 -0
- package/skills/luckiest-prospecting/SKILL.md +275 -0
- package/skills/luckiest-prospecting/evals/evals.json +107 -0
- package/skills/luckiest-prospecting/references/b2b-prospecting.md +106 -0
- package/skills/luckiest-prospecting/references/compliance.md +123 -0
- package/skills/luckiest-prospecting/references/data-sources.md +287 -0
- package/skills/luckiest-prospecting/references/local-prospecting.md +165 -0
- package/skills/luckiest-prospecting/references/saas-prospecting.md +123 -0
- package/skills/luckiest-public-relations/ATTRIBUTION.md +34 -0
- package/skills/luckiest-public-relations/CHANGELOG.md +23 -0
- package/skills/luckiest-public-relations/SKILL.md +154 -0
- package/skills/luckiest-public-relations/references/journalist-pitching.md +368 -0
- package/skills/luckiest-public-relations/references/media-outlets.md +207 -0
- package/skills/luckiest-public-relations/references/newsjacking.md +218 -0
- package/skills/luckiest-public-relations/references/press-platforms.md +188 -0
- package/skills/luckiest-referrals/ATTRIBUTION.md +41 -0
- package/skills/luckiest-referrals/CHANGELOG.md +23 -0
- package/skills/luckiest-referrals/SKILL.md +292 -0
- package/skills/luckiest-referrals/evals/evals.json +90 -0
- package/skills/luckiest-referrals/references/affiliate-programs.md +167 -0
- package/skills/luckiest-referrals/references/program-examples.md +143 -0
- package/skills/luckiest-revops/ATTRIBUTION.md +40 -0
- package/skills/luckiest-revops/CHANGELOG.md +24 -0
- package/skills/luckiest-revops/SKILL.md +362 -0
- package/skills/luckiest-revops/evals/evals.json +91 -0
- package/skills/luckiest-revops/references/automation-playbooks.md +290 -0
- package/skills/luckiest-revops/references/lifecycle-definitions.md +278 -0
- package/skills/luckiest-revops/references/routing-rules.md +203 -0
- package/skills/luckiest-revops/references/scoring-models.md +247 -0
- package/skills/luckiest-sales-enablement/ATTRIBUTION.md +39 -0
- package/skills/luckiest-sales-enablement/CHANGELOG.md +23 -0
- package/skills/luckiest-sales-enablement/SKILL.md +379 -0
- package/skills/luckiest-sales-enablement/evals/evals.json +91 -0
- package/skills/luckiest-sales-enablement/references/deck-frameworks.md +263 -0
- package/skills/luckiest-sales-enablement/references/demo-scripts.md +355 -0
- package/skills/luckiest-sales-enablement/references/objection-library.md +270 -0
- package/skills/luckiest-sales-enablement/references/one-pager-templates.md +208 -0
- package/skills/luckiest-schema/ATTRIBUTION.md +39 -0
- package/skills/luckiest-schema/CHANGELOG.md +25 -0
- package/skills/luckiest-schema/SKILL.md +209 -0
- package/skills/luckiest-schema/evals/evals.json +88 -0
- package/skills/luckiest-schema/references/schema-examples.md +398 -0
- package/skills/luckiest-seo-audit/ATTRIBUTION.md +39 -0
- package/skills/luckiest-seo-audit/CHANGELOG.md +23 -0
- package/skills/luckiest-seo-audit/SKILL.md +529 -0
- package/skills/luckiest-seo-audit/evals/evals.json +136 -0
- package/skills/luckiest-seo-audit/references/ai-writing-detection.md +200 -0
- package/skills/luckiest-seo-audit/references/international-seo.md +230 -0
- package/skills/luckiest-session-handoff/ATTRIBUTION.md +14 -0
- package/skills/luckiest-session-handoff/CHANGELOG.md +30 -0
- package/skills/luckiest-session-handoff/SKILL.md +96 -0
- package/skills/luckiest-signup/ATTRIBUTION.md +48 -0
- package/skills/luckiest-signup/CHANGELOG.md +24 -0
- package/skills/luckiest-signup/SKILL.md +382 -0
- package/skills/luckiest-signup/evals/evals.json +88 -0
- package/skills/luckiest-site-architecture/ATTRIBUTION.md +38 -0
- package/skills/luckiest-site-architecture/CHANGELOG.md +23 -0
- package/skills/luckiest-site-architecture/SKILL.md +383 -0
- package/skills/luckiest-site-architecture/evals/evals.json +88 -0
- package/skills/luckiest-site-architecture/references/mermaid-templates.md +216 -0
- package/skills/luckiest-site-architecture/references/navigation-patterns.md +305 -0
- package/skills/luckiest-site-architecture/references/site-type-templates.md +293 -0
- package/skills/luckiest-sms/ATTRIBUTION.md +27 -0
- package/skills/luckiest-sms/CHANGELOG.md +24 -0
- package/skills/luckiest-sms/SKILL.md +400 -0
- package/skills/luckiest-sms/evals/evals.json +100 -0
- package/skills/luckiest-sms/references/compliance.md +202 -0
- package/skills/luckiest-sms/references/platforms.md +318 -0
- package/skills/luckiest-sms/references/sequence-templates.md +282 -0
- package/skills/luckiest-social/ATTRIBUTION.md +27 -0
- package/skills/luckiest-social/CHANGELOG.md +24 -0
- package/skills/luckiest-social/SKILL.md +430 -0
- package/skills/luckiest-social/evals/evals.json +92 -0
- package/skills/luckiest-social/references/listening-sources-template.md +123 -0
- package/skills/luckiest-social/references/listening.md +283 -0
- package/skills/luckiest-social/references/platform-limits.md +110 -0
- package/skills/luckiest-social/references/platforms.md +170 -0
- package/skills/luckiest-social/references/post-templates.md +177 -0
- package/skills/luckiest-social/references/reverse-engineering.md +195 -0
- package/skills/luckiest-social/references/short-form-video.md +237 -0
- package/skills/luckiest-thinker/ATTRIBUTION.md +16 -0
- package/skills/luckiest-thinker/CHANGELOG.md +20 -0
- package/skills/luckiest-thinker/LICENSE +21 -0
- package/skills/luckiest-thinker/SKILL.md +92 -0
- package/skills/luckiest-thinker/references/distribution-calibration.md +106 -0
- package/skills/luckiest-thinker/references/research-foundations.md +214 -0
- package/skills/luckiest-thinker/references/thinking-patterns.md +138 -0
- package/skills/luckiest-trends/.skillignore +10 -0
- package/skills/luckiest-trends/ATTRIBUTION.md +43 -0
- package/skills/luckiest-trends/CHANGELOG.md +86 -0
- package/skills/luckiest-trends/LICENSE +21 -0
- package/skills/luckiest-trends/SKILL.md +194 -0
- package/skills/luckiest-trends/agents/openai.yaml +8 -0
- package/skills/luckiest-trends/references/engine-contract.md +2041 -0
- package/skills/luckiest-trends/references/save-html-brief.md +206 -0
- package/skills/luckiest-trends/scripts/briefing.py +272 -0
- package/skills/luckiest-trends/scripts/build-skill.sh +39 -0
- package/skills/luckiest-trends/scripts/compare.sh +61 -0
- package/skills/luckiest-trends/scripts/evaluate_search_quality.py +573 -0
- package/skills/luckiest-trends/scripts/last30days.py +1467 -0
- package/skills/luckiest-trends/scripts/lib/__init__.py +1 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/__init__.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/arxiv.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/bird_x.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/bluesky.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/categories.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/cjk.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/cluster.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/dates.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/dedupe.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/digg.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/entity_extract.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/env.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/fusion.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/github.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/grounding.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/hackernews.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/hiring_signals.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/html_render.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/http.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/instagram.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/jobs.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/linkedin.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/log.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/normalize.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/permission_preflight.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/perplexity.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/pinterest.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/pipeline.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/planner.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/polymarket.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/preflight.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/providers.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/quality_nudge.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/query.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/reddit.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/reddit_arctic.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/reddit_enrich.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/reddit_keyless.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/reddit_listing.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/reddit_public.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/reddit_rss.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/reddit_shreddit.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/relevance.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/render.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/rerank.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/resolve.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/schema.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/signals.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/skill_meta.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/snippet.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/subproc.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/techmeme.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/threads.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/tiktok.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/trustpilot.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/truthsocial.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/ui.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/web_search_keyless.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/xai_x.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/xiaohongshu_api.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/xquik.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/xurl_x.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/__pycache__/youtube_yt.cpython-313.pyc +0 -0
- package/skills/luckiest-trends/scripts/lib/arxiv.py +290 -0
- package/skills/luckiest-trends/scripts/lib/bird_x.py +639 -0
- package/skills/luckiest-trends/scripts/lib/bluesky.py +323 -0
- package/skills/luckiest-trends/scripts/lib/categories.py +283 -0
- package/skills/luckiest-trends/scripts/lib/chrome_cookies.py +414 -0
- package/skills/luckiest-trends/scripts/lib/cjk.py +107 -0
- package/skills/luckiest-trends/scripts/lib/cluster.py +271 -0
- package/skills/luckiest-trends/scripts/lib/competitors.py +198 -0
- package/skills/luckiest-trends/scripts/lib/cookie_extract.py +508 -0
- package/skills/luckiest-trends/scripts/lib/dates.py +169 -0
- package/skills/luckiest-trends/scripts/lib/dedupe.py +145 -0
- package/skills/luckiest-trends/scripts/lib/digg.py +440 -0
- package/skills/luckiest-trends/scripts/lib/entity_extract.py +127 -0
- package/skills/luckiest-trends/scripts/lib/env.py +1073 -0
- package/skills/luckiest-trends/scripts/lib/fanout.py +84 -0
- package/skills/luckiest-trends/scripts/lib/fusion.py +207 -0
- package/skills/luckiest-trends/scripts/lib/github.py +996 -0
- package/skills/luckiest-trends/scripts/lib/grounding.py +331 -0
- package/skills/luckiest-trends/scripts/lib/hackernews.py +357 -0
- package/skills/luckiest-trends/scripts/lib/health.py +92 -0
- package/skills/luckiest-trends/scripts/lib/hiring_signals.py +326 -0
- package/skills/luckiest-trends/scripts/lib/html_publish.py +73 -0
- package/skills/luckiest-trends/scripts/lib/html_render.py +738 -0
- package/skills/luckiest-trends/scripts/lib/http.py +372 -0
- package/skills/luckiest-trends/scripts/lib/instagram.py +521 -0
- package/skills/luckiest-trends/scripts/lib/jobs.py +708 -0
- package/skills/luckiest-trends/scripts/lib/linkedin.py +363 -0
- package/skills/luckiest-trends/scripts/lib/log.py +35 -0
- package/skills/luckiest-trends/scripts/lib/normalize.py +759 -0
- package/skills/luckiest-trends/scripts/lib/permission_preflight.py +242 -0
- package/skills/luckiest-trends/scripts/lib/perplexity.py +745 -0
- package/skills/luckiest-trends/scripts/lib/pinterest.py +154 -0
- package/skills/luckiest-trends/scripts/lib/pipeline.py +1720 -0
- package/skills/luckiest-trends/scripts/lib/planner.py +763 -0
- package/skills/luckiest-trends/scripts/lib/polymarket.py +794 -0
- package/skills/luckiest-trends/scripts/lib/preflight.py +119 -0
- package/skills/luckiest-trends/scripts/lib/providers.py +380 -0
- package/skills/luckiest-trends/scripts/lib/quality_nudge.py +383 -0
- package/skills/luckiest-trends/scripts/lib/query.py +202 -0
- package/skills/luckiest-trends/scripts/lib/reddit.py +731 -0
- package/skills/luckiest-trends/scripts/lib/reddit_arctic.py +92 -0
- package/skills/luckiest-trends/scripts/lib/reddit_enrich.py +322 -0
- package/skills/luckiest-trends/scripts/lib/reddit_keyless.py +329 -0
- package/skills/luckiest-trends/scripts/lib/reddit_listing.py +188 -0
- package/skills/luckiest-trends/scripts/lib/reddit_public.py +275 -0
- package/skills/luckiest-trends/scripts/lib/reddit_rss.py +224 -0
- package/skills/luckiest-trends/scripts/lib/reddit_shreddit.py +184 -0
- package/skills/luckiest-trends/scripts/lib/relevance.py +200 -0
- package/skills/luckiest-trends/scripts/lib/render.py +2130 -0
- package/skills/luckiest-trends/scripts/lib/rerank.py +708 -0
- package/skills/luckiest-trends/scripts/lib/resolve.py +345 -0
- package/skills/luckiest-trends/scripts/lib/safari_cookies.py +193 -0
- package/skills/luckiest-trends/scripts/lib/schema.py +319 -0
- package/skills/luckiest-trends/scripts/lib/setup_wizard.py +843 -0
- package/skills/luckiest-trends/scripts/lib/signals.py +334 -0
- package/skills/luckiest-trends/scripts/lib/skill_meta.py +33 -0
- package/skills/luckiest-trends/scripts/lib/snippet.py +51 -0
- package/skills/luckiest-trends/scripts/lib/subproc.py +116 -0
- package/skills/luckiest-trends/scripts/lib/techmeme.py +239 -0
- package/skills/luckiest-trends/scripts/lib/threads.py +207 -0
- package/skills/luckiest-trends/scripts/lib/tiktok.py +596 -0
- package/skills/luckiest-trends/scripts/lib/transcribe.py +235 -0
- package/skills/luckiest-trends/scripts/lib/trustpilot.py +277 -0
- package/skills/luckiest-trends/scripts/lib/truthsocial.py +162 -0
- package/skills/luckiest-trends/scripts/lib/ui.py +626 -0
- package/skills/luckiest-trends/scripts/lib/vendor/bird-search/LICENSE +21 -0
- package/skills/luckiest-trends/scripts/lib/vendor/bird-search/bird-search.mjs +147 -0
- package/skills/luckiest-trends/scripts/lib/vendor/bird-search/lib/cookies.js +220 -0
- package/skills/luckiest-trends/scripts/lib/vendor/bird-search/lib/features.json +17 -0
- package/skills/luckiest-trends/scripts/lib/vendor/bird-search/lib/paginate-cursor.js +37 -0
- package/skills/luckiest-trends/scripts/lib/vendor/bird-search/lib/query-ids.json +20 -0
- package/skills/luckiest-trends/scripts/lib/vendor/bird-search/lib/runtime-features.js +151 -0
- package/skills/luckiest-trends/scripts/lib/vendor/bird-search/lib/runtime-query-ids.js +264 -0
- package/skills/luckiest-trends/scripts/lib/vendor/bird-search/lib/twitter-client-base.js +129 -0
- package/skills/luckiest-trends/scripts/lib/vendor/bird-search/lib/twitter-client-constants.js +50 -0
- package/skills/luckiest-trends/scripts/lib/vendor/bird-search/lib/twitter-client-features.js +347 -0
- package/skills/luckiest-trends/scripts/lib/vendor/bird-search/lib/twitter-client-search.js +157 -0
- package/skills/luckiest-trends/scripts/lib/vendor/bird-search/lib/twitter-client-types.js +2 -0
- package/skills/luckiest-trends/scripts/lib/vendor/bird-search/lib/twitter-client-utils.js +511 -0
- package/skills/luckiest-trends/scripts/lib/vendor/bird-search/package.json +13 -0
- package/skills/luckiest-trends/scripts/lib/web_fetch_keyless.py +105 -0
- package/skills/luckiest-trends/scripts/lib/web_search_keyless.py +158 -0
- package/skills/luckiest-trends/scripts/lib/xai_x.py +236 -0
- package/skills/luckiest-trends/scripts/lib/xiaohongshu_api.py +162 -0
- package/skills/luckiest-trends/scripts/lib/xquik.py +404 -0
- package/skills/luckiest-trends/scripts/lib/xurl_x.py +171 -0
- package/skills/luckiest-trends/scripts/lib/youtube_yt.py +1365 -0
- package/skills/luckiest-trends/scripts/setup-keychain.sh +123 -0
- package/skills/luckiest-trends/scripts/setup-pass.sh +132 -0
- package/skills/luckiest-trends/scripts/store.py +1020 -0
- package/skills/luckiest-trends/scripts/test-v1-vs-v2.sh +220 -0
- package/skills/luckiest-trends/scripts/test_device_auth.py +128 -0
- package/skills/luckiest-trends/scripts/verify_v3.py +195 -0
- package/skills/luckiest-trends/scripts/watchlist.py +318 -0
- package/skills/luckiest-video/ATTRIBUTION.md +27 -0
- package/skills/luckiest-video/CHANGELOG.md +23 -0
- package/skills/luckiest-video/SKILL.md +371 -0
- package/skills/luckiest-video/evals/evals.json +88 -0
- package/skills/luckiest-video/references/ai-video-prompting.md +175 -0
- package/skills/remix/SKILL.md +32 -0
- package/commands/luckiest/go.md +0 -45
- package/commands/luckiest/plan.md +0 -55
- /package/commands/{luckiest/charms.md → charms.md} +0 -0
- /package/commands/{luckiest/helpers.md → helpers.md} +0 -0
- /package/commands/{luckiest/home.md → home.md} +0 -0
- /package/commands/{luckiest/leaderboard.md → leaderboard.md} +0 -0
- /package/commands/{luckiest/merge.md → merge.md} +0 -0
- /package/commands/{luckiest/overlaps.md → overlaps.md} +0 -0
- /package/commands/{luckiest/skills.md → skills.md} +0 -0
- /package/commands/{luckiest/start.md → start.md} +0 -0
- /package/commands/{luckiest/status.md → status.md} +0 -0
- /package/commands/{luckiest/updates.md → updates.md} +0 -0
- /package/commands/{luckiest/vouch.md → vouch.md} +0 -0
- /package/commands/{luckiest/wishes.md → wishes.md} +0 -0
|
@@ -0,0 +1,1467 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# ruff: noqa: E402
|
|
3
|
+
"""last30days CLI."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import atexit
|
|
9
|
+
import datetime
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import signal
|
|
14
|
+
import sys
|
|
15
|
+
import threading
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
MIN_PYTHON = (3, 12)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def ensure_supported_python(version_info: tuple[int, int, int] | object | None = None) -> None:
|
|
22
|
+
if version_info is None:
|
|
23
|
+
version_info = sys.version_info
|
|
24
|
+
major, minor, micro = tuple(version_info[:3])
|
|
25
|
+
if (major, minor) >= MIN_PYTHON:
|
|
26
|
+
return
|
|
27
|
+
req = f"{MIN_PYTHON[0]}.{MIN_PYTHON[1]}"
|
|
28
|
+
sys.stderr.write(
|
|
29
|
+
f"last30days v3 requires Python {req}+.\n"
|
|
30
|
+
f"Detected Python {major}.{minor}.{micro}.\n"
|
|
31
|
+
f"Install with:\n"
|
|
32
|
+
f" Mac: brew install python@{req}\n"
|
|
33
|
+
f" Windows: winget install Python.Python.{req}\n"
|
|
34
|
+
f" Linux: sudo apt install python{req} (or pyenv install {req})\n"
|
|
35
|
+
f"Then rerun: python{req} <path-to-script> setup\n"
|
|
36
|
+
)
|
|
37
|
+
raise SystemExit(1)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
ensure_supported_python()
|
|
41
|
+
|
|
42
|
+
if os.name == "nt":
|
|
43
|
+
for stream in (sys.stdout, sys.stderr):
|
|
44
|
+
if hasattr(stream, "reconfigure"):
|
|
45
|
+
stream.reconfigure(encoding="utf-8", errors="replace")
|
|
46
|
+
|
|
47
|
+
SCRIPT_DIR = Path(__file__).parent.resolve()
|
|
48
|
+
sys.path.insert(0, str(SCRIPT_DIR))
|
|
49
|
+
|
|
50
|
+
from lib import dates, env, html_render, permission_preflight, pipeline, render, schema, ui
|
|
51
|
+
|
|
52
|
+
_child_pids: set[int] = set()
|
|
53
|
+
_child_pids_lock = threading.Lock()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def register_child_pid(pid: int) -> None:
|
|
57
|
+
with _child_pids_lock:
|
|
58
|
+
_child_pids.add(pid)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def unregister_child_pid(pid: int) -> None:
|
|
62
|
+
with _child_pids_lock:
|
|
63
|
+
_child_pids.discard(pid)
|
|
64
|
+
|
|
65
|
+
|
|
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
|
+
|
|
78
|
+
|
|
79
|
+
atexit.register(_cleanup_children)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def parse_search_flag(raw: str, flag_name: str = "--search") -> list[str]:
|
|
83
|
+
sources = []
|
|
84
|
+
for source in raw.split(","):
|
|
85
|
+
source = source.strip().lower()
|
|
86
|
+
if not source:
|
|
87
|
+
continue
|
|
88
|
+
normalized = pipeline.SEARCH_ALIAS.get(source, source)
|
|
89
|
+
if normalized not in pipeline.MOCK_AVAILABLE_SOURCES:
|
|
90
|
+
raise SystemExit(f"Unknown search source in {flag_name}: {source}")
|
|
91
|
+
if normalized not in sources:
|
|
92
|
+
sources.append(normalized)
|
|
93
|
+
if not sources:
|
|
94
|
+
raise SystemExit(f"{flag_name} requires at least one source.")
|
|
95
|
+
return sources
|
|
96
|
+
|
|
97
|
+
def parse_as_of_date_arg(value: str) -> str:
|
|
98
|
+
try:
|
|
99
|
+
parsed = dates.parse_as_of_date(value)
|
|
100
|
+
except ValueError as exc:
|
|
101
|
+
raise argparse.ArgumentTypeError(str(exc)) from exc
|
|
102
|
+
return parsed
|
|
103
|
+
|
|
104
|
+
def resolve_requested_sources(args_search: str | None, config: dict) -> list[str] | None:
|
|
105
|
+
"""Resolve the requested source set: explicit --search wins, then the
|
|
106
|
+
LAST30DAYS_DEFAULT_SEARCH config key (env var or .env file), then None
|
|
107
|
+
(per-query default behavior). The config fallback lets users pin a fixed
|
|
108
|
+
source set that survives upgrades without patching SKILL.md (#442).
|
|
109
|
+
"""
|
|
110
|
+
if args_search:
|
|
111
|
+
return parse_search_flag(args_search)
|
|
112
|
+
default_search = (config.get("LAST30DAYS_DEFAULT_SEARCH") or "").strip()
|
|
113
|
+
if default_search:
|
|
114
|
+
return parse_search_flag(default_search, flag_name="LAST30DAYS_DEFAULT_SEARCH")
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def slugify(value: str) -> str:
|
|
119
|
+
slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
|
|
120
|
+
return slug or "last30days"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def save_output(
|
|
124
|
+
report: schema.Report,
|
|
125
|
+
emit: str,
|
|
126
|
+
save_dir: str,
|
|
127
|
+
suffix: str = "",
|
|
128
|
+
synthesis_md: str | None = None,
|
|
129
|
+
topic_override: str | None = None,
|
|
130
|
+
rendered_content: str | None = None,
|
|
131
|
+
) -> Path:
|
|
132
|
+
from datetime import datetime
|
|
133
|
+
path = Path(save_dir).expanduser().resolve()
|
|
134
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
135
|
+
slug = slugify(topic_override or report.topic)
|
|
136
|
+
extension = "json" if emit == "json" else "html" if emit == "html" else "md"
|
|
137
|
+
raw_label = "raw-html" if emit == "html" else "raw"
|
|
138
|
+
suffix_part = f"-{suffix}" if suffix else ""
|
|
139
|
+
out_path = path / f"{slug}-{raw_label}{suffix_part}.{extension}"
|
|
140
|
+
if out_path.exists():
|
|
141
|
+
out_path = path / f"{slug}-{raw_label}{suffix_part}-{datetime.now().strftime('%Y-%m-%d')}.{extension}"
|
|
142
|
+
# Markdown saves keep the complete debug artifact. JSON and HTML preserve
|
|
143
|
+
# their requested wire format so file extensions match their content.
|
|
144
|
+
if rendered_content is not None:
|
|
145
|
+
content = rendered_content
|
|
146
|
+
elif emit in {"json", "html"}:
|
|
147
|
+
content = emit_output(report, emit, synthesis_md=synthesis_md)
|
|
148
|
+
else:
|
|
149
|
+
content = render.render_full(report)
|
|
150
|
+
out_path.write_text(content, encoding="utf-8")
|
|
151
|
+
return out_path
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def save_rendered_output(rendered_content: str, output_file: str) -> Path:
|
|
155
|
+
out_path = Path(output_file).expanduser().resolve()
|
|
156
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
157
|
+
out_path.write_text(rendered_content, encoding="utf-8")
|
|
158
|
+
return out_path
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _publish_metadata_path(html_path: Path) -> Path:
|
|
162
|
+
return html_path.with_name(f"{html_path.name}.publish.json")
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _write_publish_metadata(html_path: Path, publish_result: dict[str, object]) -> None:
|
|
166
|
+
payload = {
|
|
167
|
+
"url": publish_result.get("url"),
|
|
168
|
+
"site_id": publish_result.get("site_id"),
|
|
169
|
+
"status": publish_result.get("status"),
|
|
170
|
+
"published_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
|
171
|
+
}
|
|
172
|
+
_publish_metadata_path(html_path).write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def publish_rendered_html(
|
|
176
|
+
rendered: str,
|
|
177
|
+
*,
|
|
178
|
+
password: str | None = None,
|
|
179
|
+
companion_paths: list[Path] | None = None,
|
|
180
|
+
) -> dict[str, object]:
|
|
181
|
+
from lib import html_publish
|
|
182
|
+
|
|
183
|
+
result = html_publish.publish_html(rendered, password=password)
|
|
184
|
+
metadata_errors: list[str] = []
|
|
185
|
+
for path in companion_paths or []:
|
|
186
|
+
try:
|
|
187
|
+
_write_publish_metadata(path, result)
|
|
188
|
+
except OSError as exc:
|
|
189
|
+
metadata_errors.append(f"{path}: {exc}")
|
|
190
|
+
if metadata_errors:
|
|
191
|
+
result = dict(result)
|
|
192
|
+
result["_metadata_errors"] = metadata_errors
|
|
193
|
+
return result
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _publish_password_for_args(args: argparse.Namespace) -> str | None:
|
|
197
|
+
return (args.publish_password or os.environ.get("LAST30DAYS_PUBLISH_PASSWORD") or None)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def emit_output(
|
|
201
|
+
report: schema.Report,
|
|
202
|
+
emit: str,
|
|
203
|
+
fun_level: str = "medium",
|
|
204
|
+
save_path: str | None = None,
|
|
205
|
+
synthesis_md: str | None = None,
|
|
206
|
+
) -> str:
|
|
207
|
+
if emit == "json":
|
|
208
|
+
return json.dumps(schema.to_dict(report), indent=2, sort_keys=True)
|
|
209
|
+
if emit == "html":
|
|
210
|
+
return html_render.render_html(
|
|
211
|
+
report, fun_level=fun_level, save_path=save_path, synthesis_md=synthesis_md,
|
|
212
|
+
)
|
|
213
|
+
if emit in {"compact", "md"}:
|
|
214
|
+
return render.render_compact(report, fun_level=fun_level, save_path=save_path)
|
|
215
|
+
if emit == "context":
|
|
216
|
+
return render.render_context(report)
|
|
217
|
+
if emit == "brief":
|
|
218
|
+
return render.render_brief(report)
|
|
219
|
+
raise SystemExit(f"Unsupported emit mode: {emit}")
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def emit_comparison_output(
|
|
223
|
+
entity_reports: list[tuple[str, schema.Report]],
|
|
224
|
+
emit: str,
|
|
225
|
+
fun_level: str = "medium",
|
|
226
|
+
save_path: str | None = None,
|
|
227
|
+
synthesis_md: str | None = None,
|
|
228
|
+
) -> str:
|
|
229
|
+
if emit == "json":
|
|
230
|
+
payload = {
|
|
231
|
+
"comparison": True,
|
|
232
|
+
"entities": [label for label, _ in entity_reports],
|
|
233
|
+
"reports": [
|
|
234
|
+
{"entity": label, "report": schema.to_dict(report)}
|
|
235
|
+
for label, report in entity_reports
|
|
236
|
+
],
|
|
237
|
+
}
|
|
238
|
+
return json.dumps(payload, indent=2, sort_keys=True)
|
|
239
|
+
if emit == "html":
|
|
240
|
+
return html_render.render_html_comparison(
|
|
241
|
+
entity_reports,
|
|
242
|
+
fun_level=fun_level,
|
|
243
|
+
save_path=save_path,
|
|
244
|
+
synthesis_md=synthesis_md,
|
|
245
|
+
)
|
|
246
|
+
if emit in {"compact", "md"}:
|
|
247
|
+
return render.render_comparison_multi(
|
|
248
|
+
entity_reports, fun_level=fun_level, save_path=save_path,
|
|
249
|
+
)
|
|
250
|
+
if emit == "context":
|
|
251
|
+
return render.render_comparison_multi_context(entity_reports)
|
|
252
|
+
raise SystemExit(f"Unsupported emit mode: {emit}")
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def comparison_topic(entity_reports: list[tuple[str, schema.Report]]) -> str:
|
|
256
|
+
return " vs ".join(label for label, _ in entity_reports)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def compute_save_path_display(save_dir: str, topic: str, suffix: str, emit: str) -> str:
|
|
260
|
+
"""Compute the user-friendly save path string that will be shown in the footer.
|
|
261
|
+
|
|
262
|
+
Uses ~ when the saved file is under the user's home directory; otherwise
|
|
263
|
+
returns the absolute path.
|
|
264
|
+
"""
|
|
265
|
+
from pathlib import Path as _Path
|
|
266
|
+
path = _Path(save_dir).expanduser().resolve()
|
|
267
|
+
slug = slugify(topic)
|
|
268
|
+
extension = "json" if emit == "json" else "html" if emit == "html" else "md"
|
|
269
|
+
raw_label = "raw-html" if emit == "html" else "raw"
|
|
270
|
+
suffix_part = f"-{suffix}" if suffix else ""
|
|
271
|
+
raw = path / f"{slug}-{raw_label}{suffix_part}.{extension}"
|
|
272
|
+
try:
|
|
273
|
+
home = _Path.home().resolve()
|
|
274
|
+
relative = raw.relative_to(home)
|
|
275
|
+
return f"~/{relative.as_posix()}"
|
|
276
|
+
except ValueError:
|
|
277
|
+
return raw.as_posix()
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def compute_output_path_display(output_file: str) -> str:
|
|
281
|
+
"""Compute the user-friendly explicit output path shown in render footers."""
|
|
282
|
+
raw = Path(output_file).expanduser().resolve()
|
|
283
|
+
try:
|
|
284
|
+
home = Path.home().resolve()
|
|
285
|
+
relative = raw.relative_to(home)
|
|
286
|
+
return f"~/{relative.as_posix()}"
|
|
287
|
+
except ValueError:
|
|
288
|
+
return raw.as_posix()
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def read_synthesis_file(path: str) -> str:
|
|
292
|
+
try:
|
|
293
|
+
return Path(path).expanduser().read_text(encoding="utf-8")
|
|
294
|
+
except OSError as exc:
|
|
295
|
+
sys.stderr.write(f"[last30days] Cannot read --synthesis-file: {exc}\n")
|
|
296
|
+
raise SystemExit(2)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def persist_report(report: schema.Report) -> dict[str, int]:
|
|
300
|
+
import store
|
|
301
|
+
|
|
302
|
+
store.init_db()
|
|
303
|
+
topic_row = store.add_topic(report.topic)
|
|
304
|
+
topic_id = topic_row["id"]
|
|
305
|
+
source_mode = ",".join(sorted(report.items_by_source)) or "v3"
|
|
306
|
+
run_id = store.record_run(topic_id, source_mode=source_mode, status="running")
|
|
307
|
+
try:
|
|
308
|
+
findings = store.findings_from_report(report)
|
|
309
|
+
counts = store.store_findings(run_id, topic_id, findings)
|
|
310
|
+
store.update_run(
|
|
311
|
+
run_id,
|
|
312
|
+
status="completed",
|
|
313
|
+
findings_new=counts["new"],
|
|
314
|
+
findings_updated=counts["updated"],
|
|
315
|
+
)
|
|
316
|
+
return counts
|
|
317
|
+
except Exception as exc:
|
|
318
|
+
store.update_run(run_id, status="failed", error_message=str(exc)[:500])
|
|
319
|
+
raise
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
323
|
+
parser = argparse.ArgumentParser(
|
|
324
|
+
description="Research a topic across live social, market, and grounded web sources.",
|
|
325
|
+
allow_abbrev=False,
|
|
326
|
+
)
|
|
327
|
+
parser.add_argument("topic", nargs="*", help="Research topic")
|
|
328
|
+
parser.add_argument("--emit", default="compact", choices=["compact", "json", "context", "md", "html", "brief"])
|
|
329
|
+
parser.add_argument("--search", help="Comma-separated source list")
|
|
330
|
+
parser.add_argument("--quick", action="store_true", help="Lower-latency retrieval profile")
|
|
331
|
+
parser.add_argument("--deep", action="store_true", help="Higher-recall retrieval profile")
|
|
332
|
+
parser.add_argument("--debug", action="store_true", help="Enable HTTP debug logging")
|
|
333
|
+
parser.add_argument("--mock", action="store_true", help="Use mock retrieval fixtures")
|
|
334
|
+
parser.add_argument("--diagnose", action="store_true", help="Print provider and source availability")
|
|
335
|
+
parser.add_argument("--preflight", action="store_true",
|
|
336
|
+
help="Print a safe human-readable permission preflight")
|
|
337
|
+
parser.add_argument("--preflight-report-on-save-dir", help=argparse.SUPPRESS)
|
|
338
|
+
parser.add_argument("--no-browser-cookies", action="store_true",
|
|
339
|
+
help="Disable browser-cookie extraction even when FROM_BROWSER is configured")
|
|
340
|
+
parser.add_argument("--save-dir", help="Optional directory for saving the rendered output")
|
|
341
|
+
parser.add_argument("--output", help="Optional exact file path for saving the rendered output")
|
|
342
|
+
parser.add_argument("--synthesis-file", help="Markdown synthesis to embed in --emit=html output")
|
|
343
|
+
parser.add_argument("--publish-html", action="store_true",
|
|
344
|
+
help="Publish --emit=html output to ht-ml.app (explicit opt-in; public by default)")
|
|
345
|
+
parser.add_argument("--publish-password",
|
|
346
|
+
help="Optional shared password for --publish-html; prefer LAST30DAYS_PUBLISH_PASSWORD to avoid exposing secrets in process lists")
|
|
347
|
+
parser.add_argument("--store", action="store_true", help="Persist ranked findings to the SQLite research store")
|
|
348
|
+
parser.add_argument("--x-handle", help="X handle for targeted supplemental search")
|
|
349
|
+
parser.add_argument("--x-related", help="Comma-separated related X handles (searched with lower weight)")
|
|
350
|
+
parser.add_argument("--web-backend", default="auto",
|
|
351
|
+
choices=["auto", "brave", "exa", "serper", "parallel", "none"],
|
|
352
|
+
help="Web search backend (default: auto, tries Brave then Exa then Serper then Parallel)")
|
|
353
|
+
parser.add_argument("--deep-research", action="store_true",
|
|
354
|
+
help="Use Perplexity Deep Research (~$0.90/query) for in-depth analysis. Requires PERPLEXITY_API_KEY or OPENROUTER_API_KEY.")
|
|
355
|
+
parser.add_argument("--hiring-signals", action="store_true",
|
|
356
|
+
help="Analyze public jobs/careers postings as evidence-backed company focus signals.")
|
|
357
|
+
parser.add_argument("--plan", help="JSON query plan (skips internal LLM planner). Can be a JSON string or a file path.")
|
|
358
|
+
parser.add_argument("--save-suffix", help="Suffix for saved output filename (e.g., 'gemini' → kanye-west-raw-gemini.md)")
|
|
359
|
+
parser.add_argument("--subreddits", help="Comma-separated broad/category subreddit names to search (e.g., SaaS,Entrepreneur)")
|
|
360
|
+
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.")
|
|
361
|
+
parser.add_argument("--tiktok-hashtags", help="Comma-separated TikTok hashtags without # (e.g., tella,screenrecording)")
|
|
362
|
+
parser.add_argument("--tiktok-creators", help="Comma-separated TikTok creator handles (e.g., TellaHQ,taborplace)")
|
|
363
|
+
parser.add_argument("--ig-creators", help="Comma-separated Instagram creator handles (e.g., tella.tv,laborstories)")
|
|
364
|
+
parser.add_argument(
|
|
365
|
+
"--days",
|
|
366
|
+
"--lookback-days",
|
|
367
|
+
dest="lookback_days",
|
|
368
|
+
type=int,
|
|
369
|
+
default=30,
|
|
370
|
+
help="Number of days to look back for research (default: 30, watchlist uses 90)",
|
|
371
|
+
)
|
|
372
|
+
parser.add_argument(
|
|
373
|
+
"--as-of",
|
|
374
|
+
dest="as_of_date",
|
|
375
|
+
type=parse_as_of_date_arg,
|
|
376
|
+
help=(
|
|
377
|
+
"End date for the lookback window in YYYY-MM-DD format. "
|
|
378
|
+
"When set, --days looks back from this date instead of today."
|
|
379
|
+
),
|
|
380
|
+
)
|
|
381
|
+
parser.add_argument("--auto-resolve", action="store_true",
|
|
382
|
+
help="Use web search to discover subreddits/handles before planning (for platforms without WebSearch)")
|
|
383
|
+
parser.add_argument("--github-user", help="GitHub username for person-mode search (e.g., steipete)")
|
|
384
|
+
parser.add_argument("--github-repo", help="Comma-separated owner/repo for project-mode search (e.g., openclaw/openclaw,paperclipai/paperclip)")
|
|
385
|
+
parser.add_argument(
|
|
386
|
+
"--competitors",
|
|
387
|
+
nargs="?",
|
|
388
|
+
const=2,
|
|
389
|
+
type=int,
|
|
390
|
+
default=None,
|
|
391
|
+
metavar="N",
|
|
392
|
+
help="Auto-discover N competitor entities and fan out last30days across all of them as a comparison (default N=2 → 3-way: original + 2 peers; range 1..6). Use --competitors-list to override discovery.",
|
|
393
|
+
)
|
|
394
|
+
parser.add_argument(
|
|
395
|
+
"--competitors-list",
|
|
396
|
+
dest="competitors_list",
|
|
397
|
+
help="Comma-separated competitor entities to skip discovery (e.g., 'Anthropic,xAI,Google Gemini'). Implies --competitors.",
|
|
398
|
+
)
|
|
399
|
+
parser.add_argument(
|
|
400
|
+
"--polymarket-keywords",
|
|
401
|
+
dest="polymarket_keywords",
|
|
402
|
+
help=(
|
|
403
|
+
"Comma-separated keywords that Polymarket market titles must match "
|
|
404
|
+
"to be included. Use for ambiguous single-token topics like 'Warriors' "
|
|
405
|
+
"(nba,gsw,golden-state) to filter out Glasgow Warriors rugby, Honor "
|
|
406
|
+
"of Kings Rogue Warriors, etc. When omitted, Polymarket returns all "
|
|
407
|
+
"matching markets — so expect cross-entity noise on generic topics."
|
|
408
|
+
),
|
|
409
|
+
)
|
|
410
|
+
parser.add_argument(
|
|
411
|
+
"--competitors-plan",
|
|
412
|
+
dest="competitors_plan",
|
|
413
|
+
help=(
|
|
414
|
+
"JSON mapping of per-entity Step 0.55 targeting for competitor / vs-mode "
|
|
415
|
+
"sub-runs. Schema: {entity_name: {x_handle?, x_related?, subreddits?, "
|
|
416
|
+
"github_user?, github_repos?, context?}}. Accepts inline JSON or a file "
|
|
417
|
+
"path. Implies --competitors. Preferred over --competitors-list when the "
|
|
418
|
+
"hosting model has already resolved per-entity handles and subs."
|
|
419
|
+
),
|
|
420
|
+
)
|
|
421
|
+
return parser
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def parse_competitors_plan(raw: str | None) -> dict[str, dict]:
|
|
425
|
+
"""Parse a --competitors-plan argument into a {entity_name_lower: plan_entry} dict.
|
|
426
|
+
|
|
427
|
+
Accepts inline JSON or a file path (matches --plan). Returns {} on None/empty.
|
|
428
|
+
Validation: top-level must be a dict; each value must be a dict. Unknown fields
|
|
429
|
+
in entry values log a warning but do not abort. Invalid JSON or non-dict shape
|
|
430
|
+
raises SystemExit(2) with a clear stderr message.
|
|
431
|
+
"""
|
|
432
|
+
if not raw:
|
|
433
|
+
return {}
|
|
434
|
+
plan_str = raw
|
|
435
|
+
if os.path.isfile(plan_str):
|
|
436
|
+
try:
|
|
437
|
+
plan_str = open(plan_str, encoding="utf-8").read()
|
|
438
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
439
|
+
sys.stderr.write(f"[CompetitorsPlan] Cannot read plan file: {exc}\n")
|
|
440
|
+
raise SystemExit(2)
|
|
441
|
+
try:
|
|
442
|
+
parsed = json.loads(plan_str)
|
|
443
|
+
except json.JSONDecodeError as exc:
|
|
444
|
+
sys.stderr.write(f"[CompetitorsPlan] Invalid JSON: {exc}\n")
|
|
445
|
+
raise SystemExit(2)
|
|
446
|
+
if not isinstance(parsed, dict):
|
|
447
|
+
sys.stderr.write(
|
|
448
|
+
f"[CompetitorsPlan] Top-level must be a dict of "
|
|
449
|
+
f"{{entity: {{targeting}}}}, got {type(parsed).__name__}\n"
|
|
450
|
+
)
|
|
451
|
+
raise SystemExit(2)
|
|
452
|
+
known_fields = {
|
|
453
|
+
"x_handle", "x_related", "subreddits",
|
|
454
|
+
"github_user", "github_repos", "context",
|
|
455
|
+
}
|
|
456
|
+
normalized: dict[str, dict] = {}
|
|
457
|
+
for entity, entry in parsed.items():
|
|
458
|
+
if not isinstance(entry, dict):
|
|
459
|
+
sys.stderr.write(
|
|
460
|
+
f"[CompetitorsPlan] Entry for {entity!r} must be a dict, "
|
|
461
|
+
f"got {type(entry).__name__}; skipping.\n"
|
|
462
|
+
)
|
|
463
|
+
continue
|
|
464
|
+
unknown = set(entry.keys()) - known_fields
|
|
465
|
+
if unknown:
|
|
466
|
+
sys.stderr.write(
|
|
467
|
+
f"[CompetitorsPlan] Unknown fields in {entity!r}: "
|
|
468
|
+
f"{sorted(unknown)}; ignoring.\n"
|
|
469
|
+
)
|
|
470
|
+
normalized[entity.strip().lower()] = {
|
|
471
|
+
k: v for k, v in entry.items() if k in known_fields
|
|
472
|
+
}
|
|
473
|
+
return normalized
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def subrun_kwargs_for(
|
|
477
|
+
entity: str,
|
|
478
|
+
plan_entry: dict,
|
|
479
|
+
*,
|
|
480
|
+
resolved: dict,
|
|
481
|
+
) -> dict:
|
|
482
|
+
"""Build an explicit per-entity kwargs dict for pipeline.run().
|
|
483
|
+
|
|
484
|
+
Plan values win over auto_resolve values. Returns keys for all per-entity
|
|
485
|
+
targeting flags so callers never fall through to closure defaults.
|
|
486
|
+
|
|
487
|
+
This helper is the single source of truth for sub-run kwargs — main-topic
|
|
488
|
+
flags can only leak if a caller bypasses it.
|
|
489
|
+
"""
|
|
490
|
+
def _choose(plan_key: str, resolved_key: str | None = None):
|
|
491
|
+
if plan_key in plan_entry and plan_entry[plan_key]:
|
|
492
|
+
return plan_entry[plan_key]
|
|
493
|
+
if resolved_key is not None and resolved.get(resolved_key):
|
|
494
|
+
return resolved[resolved_key]
|
|
495
|
+
return None
|
|
496
|
+
|
|
497
|
+
x_handle = _choose("x_handle", "x_handle")
|
|
498
|
+
if isinstance(x_handle, str):
|
|
499
|
+
x_handle = x_handle.lstrip("@") or None
|
|
500
|
+
|
|
501
|
+
subreddits = _choose("subreddits", "subreddits")
|
|
502
|
+
if isinstance(subreddits, list):
|
|
503
|
+
subreddits = [s.strip().removeprefix("r/") for s in subreddits if s.strip()] or None
|
|
504
|
+
|
|
505
|
+
x_related = plan_entry.get("x_related")
|
|
506
|
+
if isinstance(x_related, list):
|
|
507
|
+
x_related = [h.strip().lstrip("@") for h in x_related if h.strip()] or None
|
|
508
|
+
else:
|
|
509
|
+
x_related = None
|
|
510
|
+
|
|
511
|
+
github_user = _choose("github_user", "github_user")
|
|
512
|
+
if isinstance(github_user, str):
|
|
513
|
+
github_user = github_user.lstrip("@").lower() or None
|
|
514
|
+
|
|
515
|
+
github_repos = _choose("github_repos", "github_repos")
|
|
516
|
+
if isinstance(github_repos, list):
|
|
517
|
+
github_repos = [r.strip() for r in github_repos if r.strip() and "/" in r.strip()] or None
|
|
518
|
+
|
|
519
|
+
context = plan_entry.get("context") or resolved.get("context") or ""
|
|
520
|
+
|
|
521
|
+
return {
|
|
522
|
+
"x_handle": x_handle,
|
|
523
|
+
"x_related": x_related,
|
|
524
|
+
"subreddits": subreddits,
|
|
525
|
+
"github_user": github_user,
|
|
526
|
+
"github_repos": github_repos,
|
|
527
|
+
"_context": context,
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
COMPETITORS_MIN = 1
|
|
532
|
+
COMPETITORS_MAX = 6
|
|
533
|
+
COMPETITORS_DEFAULT = 2
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def resolve_competitors_args(args: argparse.Namespace) -> tuple[bool, int, list[str]]:
|
|
537
|
+
"""Normalize --competitors / --competitors-list into (enabled, count, explicit_list).
|
|
538
|
+
|
|
539
|
+
- (False, 0, []) when neither flag is set.
|
|
540
|
+
- An explicit list always wins; count is derived from list length.
|
|
541
|
+
- A numeric count outside [1, 6] is clamped with a stderr warning.
|
|
542
|
+
- count <= 0 (explicit) raises SystemExit(2).
|
|
543
|
+
"""
|
|
544
|
+
explicit_list: list[str] = []
|
|
545
|
+
list_flag_provided = args.competitors_list is not None
|
|
546
|
+
if list_flag_provided:
|
|
547
|
+
explicit_list = [
|
|
548
|
+
entity.strip()
|
|
549
|
+
for entity in args.competitors_list.split(",")
|
|
550
|
+
if entity.strip()
|
|
551
|
+
]
|
|
552
|
+
if not explicit_list:
|
|
553
|
+
sys.stderr.write("[Competitors] --competitors-list is empty.\n")
|
|
554
|
+
raise SystemExit(2)
|
|
555
|
+
|
|
556
|
+
competitors_flag = args.competitors
|
|
557
|
+
list_present = bool(explicit_list)
|
|
558
|
+
flag_present = competitors_flag is not None
|
|
559
|
+
|
|
560
|
+
if not list_present and not flag_present:
|
|
561
|
+
return False, 0, []
|
|
562
|
+
|
|
563
|
+
if list_present:
|
|
564
|
+
count = len(explicit_list)
|
|
565
|
+
if flag_present and competitors_flag != count:
|
|
566
|
+
sys.stderr.write(
|
|
567
|
+
f"[Competitors] --competitors={competitors_flag} ignored; using "
|
|
568
|
+
f"{count} entries from --competitors-list.\n"
|
|
569
|
+
)
|
|
570
|
+
if count > COMPETITORS_MAX:
|
|
571
|
+
sys.stderr.write(
|
|
572
|
+
f"[Competitors] --competitors-list has {count} entries, clamping to {COMPETITORS_MAX}.\n"
|
|
573
|
+
)
|
|
574
|
+
explicit_list = explicit_list[:COMPETITORS_MAX]
|
|
575
|
+
count = COMPETITORS_MAX
|
|
576
|
+
return True, count, explicit_list
|
|
577
|
+
|
|
578
|
+
# flag_present, no explicit list
|
|
579
|
+
count = competitors_flag
|
|
580
|
+
if count < COMPETITORS_MIN:
|
|
581
|
+
sys.stderr.write(
|
|
582
|
+
f"[Competitors] --competitors must be >= {COMPETITORS_MIN} (got {count}).\n"
|
|
583
|
+
)
|
|
584
|
+
raise SystemExit(2)
|
|
585
|
+
if count > COMPETITORS_MAX:
|
|
586
|
+
sys.stderr.write(
|
|
587
|
+
f"[Competitors] --competitors={count} exceeds max {COMPETITORS_MAX}; clamping.\n"
|
|
588
|
+
)
|
|
589
|
+
count = COMPETITORS_MAX
|
|
590
|
+
return True, count, []
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def _missing_sources_for_promo(diag: dict[str, object]) -> str | None:
|
|
594
|
+
available = set(diag.get("available_sources") or [])
|
|
595
|
+
missing = []
|
|
596
|
+
if "reddit" not in available:
|
|
597
|
+
missing.append("reddit")
|
|
598
|
+
if "x" not in available:
|
|
599
|
+
missing.append("x")
|
|
600
|
+
# The web promo nudges toward a paid backend for higher-quality web search.
|
|
601
|
+
# Grounding is now available keyless on non-native hosts, so key the promo on
|
|
602
|
+
# the absence of a *paid* backend, not on grounding availability. Suppress it
|
|
603
|
+
# entirely on native-search hosts, where the model's own search is better and
|
|
604
|
+
# setting a paid engine key would be the wrong advice.
|
|
605
|
+
if not diag.get("native_web_backend") and not diag.get("native_search"):
|
|
606
|
+
missing.append("web")
|
|
607
|
+
if not missing:
|
|
608
|
+
return None
|
|
609
|
+
if "reddit" in missing and "x" in missing:
|
|
610
|
+
return "both"
|
|
611
|
+
return missing[0]
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def _show_runtime_ui(
|
|
615
|
+
report: schema.Report,
|
|
616
|
+
progress: ui.ProgressDisplay,
|
|
617
|
+
diag: dict[str, object],
|
|
618
|
+
suppress_web_promo: bool = False,
|
|
619
|
+
) -> None:
|
|
620
|
+
counts = {source: len(items) for source, items in report.items_by_source.items()}
|
|
621
|
+
display_sources = list(
|
|
622
|
+
dict.fromkeys(
|
|
623
|
+
[
|
|
624
|
+
*report.query_plan.source_weights.keys(),
|
|
625
|
+
*report.items_by_source.keys(),
|
|
626
|
+
*report.errors_by_source.keys(),
|
|
627
|
+
]
|
|
628
|
+
)
|
|
629
|
+
)
|
|
630
|
+
progress.end_processing()
|
|
631
|
+
progress.show_complete(
|
|
632
|
+
source_counts=counts,
|
|
633
|
+
display_sources=display_sources,
|
|
634
|
+
)
|
|
635
|
+
promo = _missing_sources_for_promo(diag)
|
|
636
|
+
# The `web` promo nudges users to set BRAVE_API_KEY / SERPER_API_KEY, which
|
|
637
|
+
# is wrong advice when a hosting reasoning model (Claude Code, Codex,
|
|
638
|
+
# Hermes, Gemini) is driving — those already have WebSearch and can
|
|
639
|
+
# pre-resolve Step 0.55 themselves. Suppress the web promo when a hosting
|
|
640
|
+
# model signal is present (--plan or --competitors-plan was passed).
|
|
641
|
+
if promo:
|
|
642
|
+
if suppress_web_promo and promo == "web":
|
|
643
|
+
return
|
|
644
|
+
if suppress_web_promo and promo == "both":
|
|
645
|
+
# "both" means reddit + web both missing; still nudge reddit but
|
|
646
|
+
# skip the web line. show_promo has a per-source variant.
|
|
647
|
+
progress.show_promo("reddit", diag=diag)
|
|
648
|
+
return
|
|
649
|
+
progress.show_promo(promo, diag=diag)
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
REPORT_CACHE_VERSION = "last30days-report-cache/v1"
|
|
653
|
+
DEFAULT_REPORT_CACHE_TTL_SECONDS = 3600
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def _last_report_cache_path() -> Path | None:
|
|
657
|
+
if env.CONFIG_DIR is None:
|
|
658
|
+
return None
|
|
659
|
+
return env.CONFIG_DIR / "last-report.json"
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def _report_cache_ttl_seconds(config: dict[str, object]) -> int:
|
|
663
|
+
raw = os.environ.get("LAST30DAYS_REPORT_CACHE_TTL_SECONDS")
|
|
664
|
+
if raw is None:
|
|
665
|
+
raw = config.get("LAST30DAYS_REPORT_CACHE_TTL_SECONDS")
|
|
666
|
+
if raw is None or raw == "":
|
|
667
|
+
return DEFAULT_REPORT_CACHE_TTL_SECONDS
|
|
668
|
+
try:
|
|
669
|
+
return max(0, int(raw))
|
|
670
|
+
except (TypeError, ValueError):
|
|
671
|
+
return DEFAULT_REPORT_CACHE_TTL_SECONDS
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def _is_report_cache_fresh(timestamp: object, ttl_seconds: int) -> bool:
|
|
675
|
+
if ttl_seconds <= 0:
|
|
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
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
def _write_last_run(
|
|
692
|
+
topic: str,
|
|
693
|
+
report: "schema.Report",
|
|
694
|
+
entity_reports: list[tuple[str, schema.Report]] | None = None,
|
|
695
|
+
) -> None:
|
|
696
|
+
try:
|
|
697
|
+
if env.CONFIG_DIR is None:
|
|
698
|
+
return
|
|
699
|
+
target = env.CONFIG_DIR
|
|
700
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
701
|
+
counts = {source: len(items) for source, items in report.items_by_source.items()}
|
|
702
|
+
payload = {
|
|
703
|
+
"topic": topic,
|
|
704
|
+
"timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
|
705
|
+
"sources": counts,
|
|
706
|
+
"total": sum(counts.values()),
|
|
707
|
+
"report_cache": str(target / "last-report.json"),
|
|
708
|
+
"comparison": bool(entity_reports),
|
|
709
|
+
}
|
|
710
|
+
(target / "last-run.json").write_text(json.dumps(payload, indent=2))
|
|
711
|
+
cached_reports = entity_reports or [(report.topic, report)]
|
|
712
|
+
cache_payload = {
|
|
713
|
+
"schema": REPORT_CACHE_VERSION,
|
|
714
|
+
"topic": topic,
|
|
715
|
+
"timestamp": payload["timestamp"],
|
|
716
|
+
"comparison": bool(entity_reports),
|
|
717
|
+
"reports": [
|
|
718
|
+
{"entity": label, "report": schema.to_dict(cached_report)}
|
|
719
|
+
for label, cached_report in cached_reports
|
|
720
|
+
],
|
|
721
|
+
}
|
|
722
|
+
(target / "last-report.json").write_text(json.dumps(cache_payload, indent=2))
|
|
723
|
+
except Exception:
|
|
724
|
+
pass
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
def _load_last_report_cache(
|
|
728
|
+
topic: str,
|
|
729
|
+
ttl_seconds: int = DEFAULT_REPORT_CACHE_TTL_SECONDS,
|
|
730
|
+
) -> tuple[schema.Report, list[tuple[str, schema.Report]] | None, Path] | None:
|
|
731
|
+
cache_path = _last_report_cache_path()
|
|
732
|
+
if cache_path is None or not cache_path.exists():
|
|
733
|
+
return None
|
|
734
|
+
try:
|
|
735
|
+
payload = json.loads(cache_path.read_text(encoding="utf-8"))
|
|
736
|
+
if payload.get("schema") != REPORT_CACHE_VERSION:
|
|
737
|
+
return None
|
|
738
|
+
if not _is_report_cache_fresh(payload.get("timestamp"), ttl_seconds):
|
|
739
|
+
return None
|
|
740
|
+
cached_topic = str(payload.get("topic") or "").strip().lower()
|
|
741
|
+
if cached_topic != topic.strip().lower():
|
|
742
|
+
return None
|
|
743
|
+
reports_payload = payload.get("reports") or []
|
|
744
|
+
if not reports_payload:
|
|
745
|
+
return None
|
|
746
|
+
entity_reports = [
|
|
747
|
+
(str(item.get("entity") or ""), schema.report_from_dict(item["report"]))
|
|
748
|
+
for item in reports_payload
|
|
749
|
+
if isinstance(item, dict) and isinstance(item.get("report"), dict)
|
|
750
|
+
]
|
|
751
|
+
if not entity_reports:
|
|
752
|
+
return None
|
|
753
|
+
if payload.get("comparison"):
|
|
754
|
+
if len(entity_reports) < 2:
|
|
755
|
+
return None
|
|
756
|
+
if len(entity_reports) != len(reports_payload):
|
|
757
|
+
return None
|
|
758
|
+
return entity_reports[0][1], entity_reports, cache_path
|
|
759
|
+
return entity_reports[0][1], None, cache_path
|
|
760
|
+
except Exception:
|
|
761
|
+
return None
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
def _render_save_and_print(
|
|
765
|
+
args: argparse.Namespace,
|
|
766
|
+
report: schema.Report,
|
|
767
|
+
entity_reports: list[tuple[str, schema.Report]] | None,
|
|
768
|
+
synthesis_md: str | None,
|
|
769
|
+
config: dict[str, object],
|
|
770
|
+
) -> int:
|
|
771
|
+
fun_level = str(config.get("FUN_LEVEL", "medium")).lower()
|
|
772
|
+
# Comparison HTML is the one case where the saved file's title and content
|
|
773
|
+
# have to be overridden away from the leading entity's report. Compute the
|
|
774
|
+
# gate once so the footer-display and save-output paths can't disagree.
|
|
775
|
+
is_comparison_html = bool(entity_reports) and args.emit == "html"
|
|
776
|
+
footer_save_path = None
|
|
777
|
+
if args.output:
|
|
778
|
+
footer_save_path = compute_output_path_display(args.output)
|
|
779
|
+
elif args.save_dir:
|
|
780
|
+
save_topic_for_display = comparison_topic(entity_reports) if is_comparison_html else report.topic
|
|
781
|
+
footer_save_path = compute_save_path_display(
|
|
782
|
+
args.save_dir, save_topic_for_display, args.save_suffix or "", args.emit
|
|
783
|
+
)
|
|
784
|
+
|
|
785
|
+
if entity_reports:
|
|
786
|
+
rendered = emit_comparison_output(
|
|
787
|
+
entity_reports,
|
|
788
|
+
args.emit,
|
|
789
|
+
fun_level=fun_level,
|
|
790
|
+
save_path=footer_save_path,
|
|
791
|
+
synthesis_md=synthesis_md,
|
|
792
|
+
)
|
|
793
|
+
else:
|
|
794
|
+
rendered = emit_output(
|
|
795
|
+
report,
|
|
796
|
+
args.emit,
|
|
797
|
+
fun_level=fun_level,
|
|
798
|
+
save_path=footer_save_path,
|
|
799
|
+
synthesis_md=synthesis_md,
|
|
800
|
+
)
|
|
801
|
+
publish_companion_paths: list[Path] = []
|
|
802
|
+
if args.output:
|
|
803
|
+
output_path = save_rendered_output(rendered, args.output)
|
|
804
|
+
if args.emit == "html":
|
|
805
|
+
publish_companion_paths.append(output_path)
|
|
806
|
+
sys.stderr.write(f"[last30days] Saved output to {output_path}\n")
|
|
807
|
+
sys.stderr.flush()
|
|
808
|
+
if args.save_dir:
|
|
809
|
+
# Save the main topic's raw file (single-entity or comparison main).
|
|
810
|
+
save_path = save_output(
|
|
811
|
+
report,
|
|
812
|
+
args.emit,
|
|
813
|
+
args.save_dir,
|
|
814
|
+
suffix=args.save_suffix or "",
|
|
815
|
+
synthesis_md=synthesis_md,
|
|
816
|
+
topic_override=comparison_topic(entity_reports) if is_comparison_html else None,
|
|
817
|
+
rendered_content=rendered if is_comparison_html else None,
|
|
818
|
+
)
|
|
819
|
+
if args.emit == "html":
|
|
820
|
+
publish_companion_paths.append(save_path)
|
|
821
|
+
sys.stderr.write(f"[last30days] Saved output to {save_path}\n")
|
|
822
|
+
comparison_peer_paths: list[Path] = []
|
|
823
|
+
# Competitor / vs-mode: also save a per-entity raw file for each peer.
|
|
824
|
+
# Matches historical vs-mode behavior (N passes -> N save files).
|
|
825
|
+
if entity_reports and len(entity_reports) > 1:
|
|
826
|
+
for label, entity_report in entity_reports[1:]:
|
|
827
|
+
peer_path = save_output(
|
|
828
|
+
entity_report, args.emit, args.save_dir,
|
|
829
|
+
suffix=args.save_suffix or "",
|
|
830
|
+
synthesis_md=synthesis_md,
|
|
831
|
+
)
|
|
832
|
+
comparison_peer_paths.append(peer_path)
|
|
833
|
+
sys.stderr.write(f"[last30days] Saved output to {peer_path}\n")
|
|
834
|
+
peers_display = ", ".join(str(path) for path in comparison_peer_paths)
|
|
835
|
+
sys.stderr.write(
|
|
836
|
+
f"[last30days] Comparison artifact set: main={save_path}; "
|
|
837
|
+
f"peers={peers_display}\n"
|
|
838
|
+
)
|
|
839
|
+
sys.stderr.flush()
|
|
840
|
+
if args.publish_html:
|
|
841
|
+
try:
|
|
842
|
+
publish_result = publish_rendered_html(
|
|
843
|
+
rendered,
|
|
844
|
+
password=_publish_password_for_args(args),
|
|
845
|
+
companion_paths=publish_companion_paths,
|
|
846
|
+
)
|
|
847
|
+
sys.stderr.write(f"[last30days] Published HTML to {publish_result['url']}\n")
|
|
848
|
+
for warning in publish_result.get("_metadata_errors") or []:
|
|
849
|
+
sys.stderr.write(f"[last30days] Publish metadata warning: {warning}\n")
|
|
850
|
+
if publish_result.get("update_key"):
|
|
851
|
+
sys.stderr.write(
|
|
852
|
+
"[last30days] ht-ml.app returned an update key; not writing it "
|
|
853
|
+
"to stdout, HTML, or publish metadata.\n"
|
|
854
|
+
)
|
|
855
|
+
sys.stderr.flush()
|
|
856
|
+
except Exception as exc:
|
|
857
|
+
sys.stderr.write(f"[last30days] HTML publish failed: {exc}\n")
|
|
858
|
+
sys.stderr.flush()
|
|
859
|
+
print(rendered)
|
|
860
|
+
return 0
|
|
861
|
+
|
|
862
|
+
|
|
863
|
+
def _propagate_config_to_environ(config: dict[str, object]) -> None:
|
|
864
|
+
"""Push relevant env keys to os.environ so provider modules can read them.
|
|
865
|
+
|
|
866
|
+
The env.get_config() function reads from a .env file, but providers.py
|
|
867
|
+
reads from os.environ directly. Without this, OPENAI_BASE_URL and
|
|
868
|
+
XAI_BASE_URL overrides are silently ignored. This is a no-op for
|
|
869
|
+
keys that are already set in process env.
|
|
870
|
+
"""
|
|
871
|
+
for key in ("OPENAI_BASE_URL", "XAI_BASE_URL"):
|
|
872
|
+
val = config.get(key)
|
|
873
|
+
if val and not os.environ.get(key):
|
|
874
|
+
os.environ[key] = val
|
|
875
|
+
|
|
876
|
+
|
|
877
|
+
def _setup_allows_browser_cookies(args: argparse.Namespace, extra_argv: list[str]) -> bool:
|
|
878
|
+
return (
|
|
879
|
+
not args.no_browser_cookies
|
|
880
|
+
and not args.diagnose
|
|
881
|
+
and not args.preflight
|
|
882
|
+
and "--allow-browser-cookies" in extra_argv
|
|
883
|
+
)
|
|
884
|
+
|
|
885
|
+
|
|
886
|
+
SETUP_PASSTHROUGH_FLAGS = {
|
|
887
|
+
"--allow-browser-cookies",
|
|
888
|
+
"--device-auth",
|
|
889
|
+
"--github",
|
|
890
|
+
"--openclaw",
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
SKILL_ONLY_FLAGS = {
|
|
894
|
+
"--agent",
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
|
|
898
|
+
def _validate_extra_argv(parser: argparse.ArgumentParser, topic: str, extra_argv: list[str]) -> None:
|
|
899
|
+
if not extra_argv:
|
|
900
|
+
return
|
|
901
|
+
if topic.lower() == "setup":
|
|
902
|
+
unsupported = [arg for arg in extra_argv if arg not in SETUP_PASSTHROUGH_FLAGS]
|
|
903
|
+
if unsupported:
|
|
904
|
+
parser.error(
|
|
905
|
+
"unsupported setup argument(s): "
|
|
906
|
+
+ ", ".join(unsupported)
|
|
907
|
+
+ f"; supported setup passthrough flags are {', '.join(sorted(SETUP_PASSTHROUGH_FLAGS))}"
|
|
908
|
+
)
|
|
909
|
+
return
|
|
910
|
+
skill_only = [arg for arg in extra_argv if arg in SKILL_ONLY_FLAGS]
|
|
911
|
+
other_unknown = [arg for arg in extra_argv if arg not in SKILL_ONLY_FLAGS]
|
|
912
|
+
if skill_only:
|
|
913
|
+
message = (
|
|
914
|
+
"unsupported Python CLI argument(s): "
|
|
915
|
+
+ ", ".join(skill_only)
|
|
916
|
+
+ "; these are skill arguments and must not be forwarded to scripts/last30days.py"
|
|
917
|
+
)
|
|
918
|
+
if other_unknown:
|
|
919
|
+
message += "; also unsupported: " + ", ".join(other_unknown)
|
|
920
|
+
parser.error(message)
|
|
921
|
+
parser.error("unsupported Python CLI argument(s): " + ", ".join(extra_argv))
|
|
922
|
+
|
|
923
|
+
|
|
924
|
+
def _config_policy_for_args(args: argparse.Namespace, topic: str, extra_argv: list[str]) -> env.ConfigLoadPolicy:
|
|
925
|
+
if args.no_browser_cookies:
|
|
926
|
+
browser_mode = "off"
|
|
927
|
+
elif args.diagnose or args.preflight:
|
|
928
|
+
browser_mode = "plan_only"
|
|
929
|
+
elif topic.lower() == "setup":
|
|
930
|
+
browser_mode = "read" if _setup_allows_browser_cookies(args, extra_argv) else "off"
|
|
931
|
+
else:
|
|
932
|
+
browser_mode = "read"
|
|
933
|
+
return env.ConfigLoadPolicy(
|
|
934
|
+
browser_cookies=browser_mode,
|
|
935
|
+
inspect_ignored_project_config=args.diagnose or args.preflight,
|
|
936
|
+
)
|
|
937
|
+
|
|
938
|
+
|
|
939
|
+
def main() -> int:
|
|
940
|
+
parser = build_parser()
|
|
941
|
+
# Use parse_known_args so setup sub-flags (--device-auth, --github,
|
|
942
|
+
# --openclaw) pass through without argparse hard-exiting.
|
|
943
|
+
args, extra_argv = parser.parse_known_args()
|
|
944
|
+
if args.debug:
|
|
945
|
+
os.environ["LAST30DAYS_DEBUG"] = "1"
|
|
946
|
+
|
|
947
|
+
topic = " ".join(args.topic).strip()
|
|
948
|
+
original_topic = topic
|
|
949
|
+
_validate_extra_argv(parser, topic, extra_argv)
|
|
950
|
+
config = env.get_config(policy=_config_policy_for_args(args, topic, extra_argv))
|
|
951
|
+
_propagate_config_to_environ(config)
|
|
952
|
+
|
|
953
|
+
# Env-var fallback for --save-dir, mirroring the LAST30DAYS_STORE pattern below.
|
|
954
|
+
# Uses `is None` / `is not None` checks (not truthy `or`) at every layer so that
|
|
955
|
+
# `--save-dir ""`, `LAST30DAYS_MEMORY_DIR=""` (shell-export-empty), and explicit
|
|
956
|
+
# absence each correctly suppress save. An `or` chain would collapse the empty
|
|
957
|
+
# shell-export into the same path as unset, silently falling through to .env.
|
|
958
|
+
if args.save_dir is None:
|
|
959
|
+
env_val = os.environ.get("LAST30DAYS_MEMORY_DIR")
|
|
960
|
+
args.save_dir = env_val if env_val is not None else config.get("LAST30DAYS_MEMORY_DIR")
|
|
961
|
+
|
|
962
|
+
# Surface SSH-routing config as an env var so library modules (e.g.
|
|
963
|
+
# youtube_yt) can read it without taking a config dependency. This
|
|
964
|
+
# routes yt-dlp through `ssh <host>` to bypass YouTube's bot-wall on
|
|
965
|
+
# datacenter IPs (see lib/youtube_yt.py for details).
|
|
966
|
+
if config.get("LAST30DAYS_YOUTUBE_SSH_HOST") and "LAST30DAYS_YOUTUBE_SSH_HOST" not in os.environ:
|
|
967
|
+
os.environ["LAST30DAYS_YOUTUBE_SSH_HOST"] = config["LAST30DAYS_YOUTUBE_SSH_HOST"]
|
|
968
|
+
|
|
969
|
+
if args.preflight:
|
|
970
|
+
requested_sources = resolve_requested_sources(args.search, config)
|
|
971
|
+
diag = pipeline.diagnose(config, requested_sources, safe=True)
|
|
972
|
+
if args.save_dir or args.preflight_report_on_save_dir:
|
|
973
|
+
preflight = permission_preflight.build(
|
|
974
|
+
config,
|
|
975
|
+
diag,
|
|
976
|
+
planned_save_dir=args.save_dir,
|
|
977
|
+
report_on_save_dir=args.preflight_report_on_save_dir,
|
|
978
|
+
)
|
|
979
|
+
else:
|
|
980
|
+
preflight = diag["permission_preflight"]
|
|
981
|
+
if args.emit == "json":
|
|
982
|
+
print(json.dumps(preflight, indent=2, sort_keys=True))
|
|
983
|
+
else:
|
|
984
|
+
print(permission_preflight.render_text(preflight), end="")
|
|
985
|
+
return 0
|
|
986
|
+
|
|
987
|
+
# Handle setup subcommand
|
|
988
|
+
if topic.lower() == "setup":
|
|
989
|
+
from lib import setup_wizard
|
|
990
|
+
if "--openclaw" in extra_argv:
|
|
991
|
+
results = setup_wizard.run_openclaw_setup(config)
|
|
992
|
+
print(json.dumps(results))
|
|
993
|
+
return 0
|
|
994
|
+
if "--github" in extra_argv or "--device-auth" in extra_argv:
|
|
995
|
+
if "--github" in extra_argv:
|
|
996
|
+
results = setup_wizard.run_github_auth()
|
|
997
|
+
else:
|
|
998
|
+
results = setup_wizard.run_full_device_auth()
|
|
999
|
+
# Persist the returned key so the paid sources activate on the next
|
|
1000
|
+
# run, and mask it in stdout so the secret never lands in the host
|
|
1001
|
+
# model's captured Bash output.
|
|
1002
|
+
api_key = results.get("api_key")
|
|
1003
|
+
if results.get("status") == "success" and api_key:
|
|
1004
|
+
results["persisted"] = setup_wizard.write_api_key(env.CONFIG_FILE, api_key)
|
|
1005
|
+
results["api_key"] = setup_wizard.mask_api_key(api_key)
|
|
1006
|
+
else:
|
|
1007
|
+
results["persisted"] = False
|
|
1008
|
+
print(json.dumps(results))
|
|
1009
|
+
return 0
|
|
1010
|
+
sys.stderr.write("Running auto-setup...\n")
|
|
1011
|
+
results = setup_wizard.run_auto_setup(
|
|
1012
|
+
config,
|
|
1013
|
+
allow_browser_cookies=_setup_allows_browser_cookies(args, extra_argv),
|
|
1014
|
+
)
|
|
1015
|
+
# Persist FROM_BROWSER only when every service's cookies came from the
|
|
1016
|
+
# SAME single browser — then we can fast-path future runs to it. If
|
|
1017
|
+
# different services matched different browsers, or none matched, leave
|
|
1018
|
+
# FROM_BROWSER unset so the safe default remains no browser-cookie
|
|
1019
|
+
# reads. We deliberately do NOT pin "auto" here (it would re-probe
|
|
1020
|
+
# Chrome and re-trigger the prompt) nor a single browser (it would
|
|
1021
|
+
# silently skip the service that used the other one).
|
|
1022
|
+
found_browsers = set(results.get("cookies_found", {}).values())
|
|
1023
|
+
from_browser = found_browsers.pop() if len(found_browsers) == 1 else None
|
|
1024
|
+
setup_wizard.write_setup_config(env.CONFIG_FILE, from_browser=from_browser)
|
|
1025
|
+
results["env_written"] = True
|
|
1026
|
+
sys.stderr.write(setup_wizard.get_setup_status_text(results) + "\n")
|
|
1027
|
+
return 0
|
|
1028
|
+
|
|
1029
|
+
requested_sources = resolve_requested_sources(args.search, config)
|
|
1030
|
+
diag = pipeline.diagnose(config, requested_sources, safe=args.diagnose)
|
|
1031
|
+
|
|
1032
|
+
if args.diagnose:
|
|
1033
|
+
print(json.dumps(diag, indent=2, sort_keys=True))
|
|
1034
|
+
return 0
|
|
1035
|
+
|
|
1036
|
+
if not topic:
|
|
1037
|
+
parser.print_usage(sys.stderr)
|
|
1038
|
+
return 2
|
|
1039
|
+
if args.publish_html and args.emit != "html":
|
|
1040
|
+
sys.stderr.write("[last30days] --publish-html requires --emit=html\n")
|
|
1041
|
+
return 2
|
|
1042
|
+
|
|
1043
|
+
synthesis_md = None
|
|
1044
|
+
if args.synthesis_file:
|
|
1045
|
+
if args.emit == "html":
|
|
1046
|
+
synthesis_md = read_synthesis_file(args.synthesis_file)
|
|
1047
|
+
else:
|
|
1048
|
+
sys.stderr.write("[last30days] Warning: --synthesis-file is only used with --emit=html; ignoring.\n")
|
|
1049
|
+
|
|
1050
|
+
if not os.environ.get("LAST30DAYS_SKIP_PREFLIGHT"):
|
|
1051
|
+
from lib import preflight
|
|
1052
|
+
refuse_msg = preflight.check_class_1_trap(topic)
|
|
1053
|
+
if refuse_msg:
|
|
1054
|
+
sys.stderr.write(refuse_msg)
|
|
1055
|
+
return 2
|
|
1056
|
+
|
|
1057
|
+
if args.emit == "html" and synthesis_md is not None:
|
|
1058
|
+
cached = _load_last_report_cache(
|
|
1059
|
+
topic,
|
|
1060
|
+
ttl_seconds=_report_cache_ttl_seconds(config),
|
|
1061
|
+
)
|
|
1062
|
+
if cached is not None:
|
|
1063
|
+
cached_report, cached_entity_reports, cache_path = cached
|
|
1064
|
+
sys.stderr.write(
|
|
1065
|
+
f"[last30days] Reusing cached report data from {cache_path}\n"
|
|
1066
|
+
)
|
|
1067
|
+
sys.stderr.flush()
|
|
1068
|
+
return _render_save_and_print(
|
|
1069
|
+
args, cached_report, cached_entity_reports, synthesis_md, config
|
|
1070
|
+
)
|
|
1071
|
+
sys.stderr.write(
|
|
1072
|
+
"[last30days] No matching cached report data for "
|
|
1073
|
+
"--emit=html --synthesis-file; running fresh research.\n"
|
|
1074
|
+
)
|
|
1075
|
+
sys.stderr.flush()
|
|
1076
|
+
|
|
1077
|
+
progress = ui.ProgressDisplay(topic, show_banner=True)
|
|
1078
|
+
progress.start_processing()
|
|
1079
|
+
|
|
1080
|
+
depth = "deep" if args.deep else "quick" if args.quick else "default"
|
|
1081
|
+
try:
|
|
1082
|
+
x_related = [h.strip() for h in args.x_related.split(",") if h.strip()] if args.x_related else None
|
|
1083
|
+
subreddits = [s.strip().removeprefix("r/") for s in args.subreddits.split(",") if s.strip()] if args.subreddits else None
|
|
1084
|
+
dedicated_subreddits = [s.strip().removeprefix("r/") for s in args.dedicated_subreddits.split(",") if s.strip()] if args.dedicated_subreddits else None
|
|
1085
|
+
tiktok_hashtags = [h.strip().lstrip("#") for h in args.tiktok_hashtags.split(",") if h.strip()] if args.tiktok_hashtags else None
|
|
1086
|
+
tiktok_creators = [c.strip().lstrip("@") for c in args.tiktok_creators.split(",") if c.strip()] if args.tiktok_creators else None
|
|
1087
|
+
ig_creators = [c.strip().lstrip("@") for c in args.ig_creators.split(",") if c.strip()] if args.ig_creators else None
|
|
1088
|
+
# Parse external plan if provided via --plan flag
|
|
1089
|
+
external_plan = None
|
|
1090
|
+
if args.plan:
|
|
1091
|
+
import json as _json
|
|
1092
|
+
plan_str = args.plan
|
|
1093
|
+
if os.path.isfile(plan_str):
|
|
1094
|
+
try:
|
|
1095
|
+
plan_str = open(plan_str, encoding="utf-8").read()
|
|
1096
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
1097
|
+
sys.stderr.write(f"[Planner] Cannot read --plan file: {exc}\n")
|
|
1098
|
+
raise SystemExit(2)
|
|
1099
|
+
try:
|
|
1100
|
+
external_plan = _json.loads(plan_str)
|
|
1101
|
+
except _json.JSONDecodeError as exc:
|
|
1102
|
+
sys.stderr.write(f"[Planner] Invalid --plan JSON: {exc}\n")
|
|
1103
|
+
# Fail fast instead of silently dropping to the internal planner
|
|
1104
|
+
# and burning a paid run the user did not ask for. Mirrors the
|
|
1105
|
+
# --plan file-read branch above and parse_competitors_plan.
|
|
1106
|
+
raise SystemExit(2)
|
|
1107
|
+
|
|
1108
|
+
# Auto-resolve: use web search to discover subreddits/handles before planning.
|
|
1109
|
+
# This is the engine-side equivalent of SKILL.md Steps 0.55/0.75 for platforms
|
|
1110
|
+
# without WebSearch (OpenClaw, Codex, raw CLI).
|
|
1111
|
+
repos_from_auto_resolve = False
|
|
1112
|
+
if args.auto_resolve and not external_plan:
|
|
1113
|
+
from lib import resolve
|
|
1114
|
+
resolution = resolve.auto_resolve(topic, config)
|
|
1115
|
+
if resolution.get("subreddits") and not subreddits:
|
|
1116
|
+
subreddits = resolution["subreddits"]
|
|
1117
|
+
sys.stderr.write(f"[AutoResolve] Subreddits: {', '.join(subreddits)}\n")
|
|
1118
|
+
if resolution.get("x_handle") and not args.x_handle:
|
|
1119
|
+
args.x_handle = resolution["x_handle"]
|
|
1120
|
+
sys.stderr.write(f"[AutoResolve] X handle: @{args.x_handle}\n")
|
|
1121
|
+
if resolution.get("github_user") and not args.github_user:
|
|
1122
|
+
args.github_user = resolution["github_user"]
|
|
1123
|
+
sys.stderr.write(f"[AutoResolve] GitHub user: @{args.github_user}\n")
|
|
1124
|
+
if resolution.get("github_repos") and not args.github_repo:
|
|
1125
|
+
args.github_repo = ",".join(resolution["github_repos"])
|
|
1126
|
+
# auto_resolve already canonicalized via canonicalize_github_repos(cap=5);
|
|
1127
|
+
# mark so we don't re-canonicalize below and clobber its relevance order.
|
|
1128
|
+
repos_from_auto_resolve = True
|
|
1129
|
+
sys.stderr.write(f"[AutoResolve] GitHub repos: {args.github_repo}\n")
|
|
1130
|
+
if resolution.get("context"):
|
|
1131
|
+
# Inject context into external_plan metadata for the planner to use
|
|
1132
|
+
if not external_plan:
|
|
1133
|
+
external_plan = None # planner will use its own, but with context
|
|
1134
|
+
# Store context for the planner prompt injection
|
|
1135
|
+
config["_auto_resolve_context"] = resolution["context"]
|
|
1136
|
+
sys.stderr.write(f"[AutoResolve] Context: {resolution['context'][:80]}...\n")
|
|
1137
|
+
|
|
1138
|
+
github_user = args.github_user.lstrip("@").lower() if args.github_user else None
|
|
1139
|
+
github_repos = [r.strip() for r in args.github_repo.split(",") if r.strip() and "/" in r.strip()] if args.github_repo else None
|
|
1140
|
+
|
|
1141
|
+
# Only canonicalize when repos came from a user-supplied --github-repo flag.
|
|
1142
|
+
# When repos_from_auto_resolve is True, auto_resolve already ran
|
|
1143
|
+
# canonicalize_github_repos(cap=5) and ranked by relevance; re-running here
|
|
1144
|
+
# with cap=None can re-sort by topic-slug match and lose that ordering.
|
|
1145
|
+
if github_repos and not repos_from_auto_resolve:
|
|
1146
|
+
from lib import resolve as resolve_lib
|
|
1147
|
+
original_github_repos = github_repos[:]
|
|
1148
|
+
github_repos = resolve_lib.canonicalize_github_repos(topic, github_repos, cap=None)
|
|
1149
|
+
if github_repos != original_github_repos:
|
|
1150
|
+
sys.stderr.write(
|
|
1151
|
+
"[GitHub] Canonicalized repos: "
|
|
1152
|
+
f"{','.join(original_github_repos)} -> {','.join(github_repos)}\n"
|
|
1153
|
+
)
|
|
1154
|
+
|
|
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
|
+
# Polymarket disambiguation: if user passed --polymarket-keywords,
|
|
1170
|
+
# store on config so the polymarket adapter can filter matches.
|
|
1171
|
+
if args.polymarket_keywords:
|
|
1172
|
+
keywords = [
|
|
1173
|
+
k.strip().lower()
|
|
1174
|
+
for k in args.polymarket_keywords.split(",")
|
|
1175
|
+
if k.strip()
|
|
1176
|
+
]
|
|
1177
|
+
if keywords:
|
|
1178
|
+
config["_polymarket_keywords"] = keywords
|
|
1179
|
+
|
|
1180
|
+
# vs-mode: if the topic string contains " vs " / " versus " and the
|
|
1181
|
+
# planner can split it into >=2 entities, route through the same
|
|
1182
|
+
# N-pass fanout path as --competitors. The first entity becomes the
|
|
1183
|
+
# main topic; remaining entities become the competitor list. User's
|
|
1184
|
+
# outer --x-handle / --subreddits apply to the first entity unless
|
|
1185
|
+
# --competitors-plan covers it.
|
|
1186
|
+
from lib import planner as _planner
|
|
1187
|
+
vs_entities = _planner._comparison_entities(topic)
|
|
1188
|
+
if len(vs_entities) >= 2 and not comp_enabled:
|
|
1189
|
+
topic = vs_entities[0]
|
|
1190
|
+
comp_enabled = True
|
|
1191
|
+
comp_count = len(vs_entities) - 1
|
|
1192
|
+
comp_explicit = vs_entities[1:]
|
|
1193
|
+
sys.stderr.write(
|
|
1194
|
+
f"[Competitors] vs-mode: routing to N-pass fanout: "
|
|
1195
|
+
f"{' vs '.join(vs_entities)}\n"
|
|
1196
|
+
)
|
|
1197
|
+
|
|
1198
|
+
# Dedicated subs ride the config dict (already threaded to every source
|
|
1199
|
+
# fetch) so the keyless Reddit path can pull them floor-exempt without
|
|
1200
|
+
# widening pipeline.run / _retrieve_stream signatures.
|
|
1201
|
+
if dedicated_subreddits:
|
|
1202
|
+
config["_dedicated_subreddits"] = dedicated_subreddits
|
|
1203
|
+
|
|
1204
|
+
def _main_runner() -> schema.Report:
|
|
1205
|
+
r = pipeline.run(
|
|
1206
|
+
topic=topic,
|
|
1207
|
+
config=config,
|
|
1208
|
+
depth=depth,
|
|
1209
|
+
requested_sources=requested_sources,
|
|
1210
|
+
mock=args.mock,
|
|
1211
|
+
x_handle=args.x_handle,
|
|
1212
|
+
x_related=x_related,
|
|
1213
|
+
web_backend=args.web_backend,
|
|
1214
|
+
external_plan=external_plan,
|
|
1215
|
+
subreddits=subreddits,
|
|
1216
|
+
tiktok_hashtags=tiktok_hashtags,
|
|
1217
|
+
tiktok_creators=tiktok_creators,
|
|
1218
|
+
ig_creators=ig_creators,
|
|
1219
|
+
lookback_days=args.lookback_days,
|
|
1220
|
+
as_of_date=args.as_of_date,
|
|
1221
|
+
github_user=github_user,
|
|
1222
|
+
github_repos=github_repos,
|
|
1223
|
+
internal_subrun=comp_enabled,
|
|
1224
|
+
hiring_signals_mode=args.hiring_signals,
|
|
1225
|
+
)
|
|
1226
|
+
r.artifacts["resolved"] = {
|
|
1227
|
+
"entity": topic,
|
|
1228
|
+
"x_handle": (args.x_handle or "").lstrip("@"),
|
|
1229
|
+
"subreddits": list(subreddits or []),
|
|
1230
|
+
"github_user": (github_user or ""),
|
|
1231
|
+
"github_repos": list(github_repos or []),
|
|
1232
|
+
"context": config.get("_auto_resolve_context", "") or "",
|
|
1233
|
+
}
|
|
1234
|
+
return r
|
|
1235
|
+
|
|
1236
|
+
if comp_enabled:
|
|
1237
|
+
from lib import competitors as competitors_mod
|
|
1238
|
+
from lib import fanout, resolve as resolve_mod
|
|
1239
|
+
|
|
1240
|
+
if comp_explicit:
|
|
1241
|
+
discovered = comp_explicit
|
|
1242
|
+
else:
|
|
1243
|
+
if not resolve_mod._has_backend(config) and not args.mock:
|
|
1244
|
+
sys.stderr.write(
|
|
1245
|
+
"[Competitors] Cannot auto-discover peers without help.\n"
|
|
1246
|
+
"\n"
|
|
1247
|
+
"RECOMMENDED PATH (hosting reasoning models — Claude Code, Codex, "
|
|
1248
|
+
"Hermes, Gemini, any agent with a WebSearch tool): YOU have "
|
|
1249
|
+
"WebSearch. Use it to run full Step 0.55 per entity, then invoke "
|
|
1250
|
+
"the engine with a vs-topic plus --competitors-plan:\n"
|
|
1251
|
+
" 1. WebSearch for '{topic} competitors' or '{topic} alternatives'.\n"
|
|
1252
|
+
" 2. For each peer, WebSearch for handles/subs/github (Step 0.55).\n"
|
|
1253
|
+
" 3. Re-invoke: /last30days '{topic} vs {peer1} vs {peer2}' "
|
|
1254
|
+
"--competitors-plan '{\"Peer1\":{\"x_handle\":\"h1\",\"subreddits\":"
|
|
1255
|
+
"[\"s1\"],...},\"Peer2\":{...}}'.\n"
|
|
1256
|
+
"See SKILL.md 'Competitor mode' for the full protocol.\n"
|
|
1257
|
+
"\n"
|
|
1258
|
+
"HEADLESS / CRON PATH (no hosting model available): set "
|
|
1259
|
+
"BRAVE_API_KEY / EXA_API_KEY / SERPER_API_KEY / PARALLEL_API_KEY / "
|
|
1260
|
+
"PERPLEXITY_API_KEY / OPENROUTER_API_KEY and re-run.\n"
|
|
1261
|
+
"\n"
|
|
1262
|
+
"MINIMUM ESCAPE HATCH: pass --competitors-list 'A,B,C' to skip "
|
|
1263
|
+
"discovery. Without --competitors-plan, peer sub-runs fall back to "
|
|
1264
|
+
"planner defaults and produce visibly thinner data than the main.\n"
|
|
1265
|
+
)
|
|
1266
|
+
return 2
|
|
1267
|
+
discovered = competitors_mod.discover_competitors(
|
|
1268
|
+
topic, comp_count, config, lookback_days=args.lookback_days,
|
|
1269
|
+
)
|
|
1270
|
+
if not discovered:
|
|
1271
|
+
sys.stderr.write(
|
|
1272
|
+
f"[Competitors] No peers discovered for {topic!r}; aborting "
|
|
1273
|
+
"comparison run. Pass --competitors-list to override.\n"
|
|
1274
|
+
)
|
|
1275
|
+
return 2
|
|
1276
|
+
|
|
1277
|
+
sys.stderr.write(
|
|
1278
|
+
f"[Competitors] Comparing: {topic} vs " + " vs ".join(discovered) + "\n"
|
|
1279
|
+
)
|
|
1280
|
+
|
|
1281
|
+
def _competitor_runner(entity: str) -> schema.Report:
|
|
1282
|
+
# Deep-copy config so per-entity auto_resolve context does not
|
|
1283
|
+
# leak across sub-runs. Each sub-run writes its own
|
|
1284
|
+
# `_auto_resolve_context` into its local config copy.
|
|
1285
|
+
entity_config = dict(config)
|
|
1286
|
+
plan_entry = comp_plan.get(entity.strip().lower(), {})
|
|
1287
|
+
resolved = {
|
|
1288
|
+
"entity": entity,
|
|
1289
|
+
"x_handle": "",
|
|
1290
|
+
"subreddits": [],
|
|
1291
|
+
"github_user": "",
|
|
1292
|
+
"github_repos": [],
|
|
1293
|
+
"context": "",
|
|
1294
|
+
}
|
|
1295
|
+
# Skip engine-internal auto_resolve when the hosting model
|
|
1296
|
+
# pre-resolved via --competitors-plan (saves a redundant
|
|
1297
|
+
# round-trip and makes per-entity Step 0.55 purely
|
|
1298
|
+
# hosting-model-driven).
|
|
1299
|
+
plan_covers_fully = bool(plan_entry.get("x_handle")) and bool(
|
|
1300
|
+
plan_entry.get("subreddits")
|
|
1301
|
+
)
|
|
1302
|
+
if (
|
|
1303
|
+
not args.mock
|
|
1304
|
+
and not plan_covers_fully
|
|
1305
|
+
and resolve_mod._has_backend(entity_config)
|
|
1306
|
+
):
|
|
1307
|
+
try:
|
|
1308
|
+
r = resolve_mod.auto_resolve(entity, entity_config)
|
|
1309
|
+
except Exception as exc:
|
|
1310
|
+
sys.stderr.write(
|
|
1311
|
+
f"[Competitors] auto_resolve failed for {entity!r}: "
|
|
1312
|
+
f"{type(exc).__name__}: {exc}\n"
|
|
1313
|
+
)
|
|
1314
|
+
r = {}
|
|
1315
|
+
resolved["x_handle"] = r.get("x_handle", "") or ""
|
|
1316
|
+
resolved["subreddits"] = list(r.get("subreddits") or [])
|
|
1317
|
+
resolved["github_user"] = r.get("github_user", "") or ""
|
|
1318
|
+
resolved["github_repos"] = list(r.get("github_repos") or [])
|
|
1319
|
+
resolved["context"] = r.get("context", "") or ""
|
|
1320
|
+
kwargs = subrun_kwargs_for(entity, plan_entry, resolved=resolved)
|
|
1321
|
+
# Record effective per-entity targeting for the Resolved block.
|
|
1322
|
+
resolved_effective = {
|
|
1323
|
+
"entity": entity,
|
|
1324
|
+
"x_handle": kwargs["x_handle"] or "",
|
|
1325
|
+
"subreddits": kwargs["subreddits"] or [],
|
|
1326
|
+
"github_user": kwargs["github_user"] or "",
|
|
1327
|
+
"github_repos": kwargs["github_repos"] or [],
|
|
1328
|
+
"context": kwargs["_context"],
|
|
1329
|
+
}
|
|
1330
|
+
if kwargs["_context"]:
|
|
1331
|
+
entity_config["_auto_resolve_context"] = kwargs["_context"]
|
|
1332
|
+
sys.stderr.write(
|
|
1333
|
+
f"[Competitors] {entity}: "
|
|
1334
|
+
f"x=@{resolved_effective['x_handle'] or '-'} "
|
|
1335
|
+
f"subs={len(resolved_effective['subreddits'])} "
|
|
1336
|
+
f"gh={resolved_effective['github_user'] or '-'} "
|
|
1337
|
+
f"({'plan' if plan_entry else 'auto'})\n"
|
|
1338
|
+
)
|
|
1339
|
+
report = pipeline.run(
|
|
1340
|
+
topic=entity,
|
|
1341
|
+
config=entity_config,
|
|
1342
|
+
depth=depth,
|
|
1343
|
+
requested_sources=requested_sources,
|
|
1344
|
+
mock=args.mock,
|
|
1345
|
+
x_handle=kwargs["x_handle"],
|
|
1346
|
+
x_related=kwargs["x_related"],
|
|
1347
|
+
subreddits=kwargs["subreddits"],
|
|
1348
|
+
github_user=kwargs["github_user"],
|
|
1349
|
+
github_repos=kwargs["github_repos"],
|
|
1350
|
+
web_backend=args.web_backend,
|
|
1351
|
+
lookback_days=args.lookback_days,
|
|
1352
|
+
as_of_date=args.as_of_date,
|
|
1353
|
+
hiring_signals_mode=args.hiring_signals,
|
|
1354
|
+
internal_subrun=True,
|
|
1355
|
+
)
|
|
1356
|
+
report.artifacts["resolved"] = resolved_effective
|
|
1357
|
+
return report
|
|
1358
|
+
|
|
1359
|
+
entity_reports = fanout.run_competitor_fanout(
|
|
1360
|
+
main_topic=topic,
|
|
1361
|
+
main_runner=_main_runner,
|
|
1362
|
+
competitors=discovered,
|
|
1363
|
+
competitor_runner=_competitor_runner,
|
|
1364
|
+
)
|
|
1365
|
+
if len(entity_reports) < 2:
|
|
1366
|
+
progress.end_processing()
|
|
1367
|
+
sys.stderr.write(
|
|
1368
|
+
f"[Competitors] Fewer than 2 sub-runs survived ({len(entity_reports)}); "
|
|
1369
|
+
"cannot render a comparison. Re-run without --competitors or check the "
|
|
1370
|
+
"warnings above.\n"
|
|
1371
|
+
)
|
|
1372
|
+
return 1
|
|
1373
|
+
report = entity_reports[0][1]
|
|
1374
|
+
else:
|
|
1375
|
+
entity_reports = None
|
|
1376
|
+
report = _main_runner()
|
|
1377
|
+
except Exception as exc:
|
|
1378
|
+
progress.end_processing()
|
|
1379
|
+
progress.show_error(str(exc))
|
|
1380
|
+
raise
|
|
1381
|
+
_show_runtime_ui(
|
|
1382
|
+
report, progress, diag,
|
|
1383
|
+
suppress_web_promo=bool(external_plan or comp_plan),
|
|
1384
|
+
)
|
|
1385
|
+
_write_last_run(original_topic, report, entity_reports=entity_reports)
|
|
1386
|
+
# LAST30DAYS_STORE env var = persistence default-on. Read both os.environ
|
|
1387
|
+
# (for shell-exported users) and config (for users who set it in
|
|
1388
|
+
# ~/.config/last30days/.env, which env.py loads but does not propagate
|
|
1389
|
+
# to os.environ). Mirrors the LAST30DAYS_DEBUG / LAST30DAYS_SKIP_PREFLIGHT
|
|
1390
|
+
# convention; env-var or config wins, with `--store` flag still working.
|
|
1391
|
+
_store_env = (
|
|
1392
|
+
os.environ.get("LAST30DAYS_STORE")
|
|
1393
|
+
or config.get("LAST30DAYS_STORE")
|
|
1394
|
+
or ""
|
|
1395
|
+
).lower()
|
|
1396
|
+
if args.store or _store_env in ("1", "true", "yes"):
|
|
1397
|
+
counts = persist_report(report)
|
|
1398
|
+
sys.stderr.write(
|
|
1399
|
+
f"[last30days] Stored {counts['new']} new, {counts['updated']} updated findings\n"
|
|
1400
|
+
)
|
|
1401
|
+
sys.stderr.flush()
|
|
1402
|
+
|
|
1403
|
+
# Show quality nudge if applicable. Explicit hiring-signal runs are
|
|
1404
|
+
# intentionally jobs-focused, so generic source setup advice is noise.
|
|
1405
|
+
if not args.hiring_signals:
|
|
1406
|
+
try:
|
|
1407
|
+
from lib import quality_nudge
|
|
1408
|
+
from lib import youtube_yt as _youtube_yt
|
|
1409
|
+
# Populate transcript-fetch ratio so quality_nudge can detect the
|
|
1410
|
+
# degraded-YouTube failure mode (videos returned but transcripts
|
|
1411
|
+
# silently failed - typically a stale yt-dlp binary).
|
|
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
|
+
}
|
|
1442
|
+
quality = quality_nudge.compute_quality_score(config, research_results)
|
|
1443
|
+
if quality.get("nudge_text"):
|
|
1444
|
+
sys.stderr.write(f"\n{quality['nudge_text']}\n")
|
|
1445
|
+
sys.stderr.flush()
|
|
1446
|
+
except Exception:
|
|
1447
|
+
pass
|
|
1448
|
+
|
|
1449
|
+
# Signal to render_compact whether pre-research flags were supplied.
|
|
1450
|
+
# Used to emit a Pre-Research Status warning when the model skipped
|
|
1451
|
+
# Step 0.5 / 0.55 and invoked the engine bare on an eligible topic.
|
|
1452
|
+
pre_research_flags_present = bool(
|
|
1453
|
+
args.x_handle
|
|
1454
|
+
or args.github_user
|
|
1455
|
+
or args.subreddits
|
|
1456
|
+
or args.plan
|
|
1457
|
+
or args.auto_resolve
|
|
1458
|
+
or args.tiktok_creators
|
|
1459
|
+
or args.ig_creators
|
|
1460
|
+
)
|
|
1461
|
+
report.artifacts["pre_research_flags_present"] = pre_research_flags_present
|
|
1462
|
+
|
|
1463
|
+
return _render_save_and_print(args, report, entity_reports, synthesis_md, config)
|
|
1464
|
+
|
|
1465
|
+
|
|
1466
|
+
if __name__ == "__main__":
|
|
1467
|
+
raise SystemExit(main())
|