luckiest-co 1.0.46 → 1.0.48

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (266) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/package.json +1 -1
  4. package/skills/ab-testing/CHANGELOG.md +5 -0
  5. package/skills/ab-testing/SKILL.md +12 -9
  6. package/skills/ab-testing/evals/evals.json +18 -7
  7. package/skills/ab-testing/references/sample-size-guide.md +2 -2
  8. package/skills/ads/CHANGELOG.md +5 -0
  9. package/skills/ads/SKILL.md +4 -4
  10. package/skills/ai-seo/CHANGELOG.md +7 -0
  11. package/skills/ai-seo/SKILL.md +27 -20
  12. package/skills/analytics/CHANGELOG.md +6 -0
  13. package/skills/analytics/SKILL.md +4 -3
  14. package/skills/analytics/evals/evals.json +27 -0
  15. package/skills/analytics/references/ga4-implementation.md +7 -3
  16. package/skills/analytics/references/gtm-implementation.md +8 -2
  17. package/skills/aso/CHANGELOG.md +5 -0
  18. package/skills/aso/SKILL.md +4 -4
  19. package/skills/aso/evals/evals.json +15 -2
  20. package/skills/aso/references/benchmarks.md +4 -2
  21. package/skills/aso/references/google-play-specs.md +5 -1
  22. package/skills/brainstorm/SKILL.md +59 -16
  23. package/skills/brainstorm/builder-check-prompt.md +23 -0
  24. package/skills/churn-prevention/CHANGELOG.md +5 -0
  25. package/skills/churn-prevention/SKILL.md +3 -3
  26. package/skills/churn-prevention/evals/evals.json +14 -0
  27. package/skills/churn-prevention/references/dunning-playbook.md +6 -1
  28. package/skills/coder-brainstorming/CHANGELOG.md +10 -0
  29. package/skills/coder-brainstorming/SKILL.md +62 -20
  30. package/skills/coder-brainstorming/builder-check-prompt.md +23 -0
  31. package/skills/coder-brainstorming/scripts/server.cjs +1 -1
  32. package/skills/coder-debugging/CHANGELOG.md +4 -0
  33. package/skills/coder-debugging/SKILL.md +5 -5
  34. package/skills/coder-requesting-code-review/CHANGELOG.md +4 -0
  35. package/skills/coder-requesting-code-review/SKILL.md +4 -4
  36. package/skills/coder-subagent-driven-development/CHANGELOG.md +4 -0
  37. package/skills/coder-subagent-driven-development/SKILL.md +3 -3
  38. package/skills/coder-subagent-driven-development/scripts/sdd-workspace +3 -1
  39. package/skills/cold-email/CHANGELOG.md +7 -0
  40. package/skills/cold-email/SKILL.md +27 -7
  41. package/skills/cold-email/evals/evals.json +30 -0
  42. package/skills/cold-email/references/benchmarks.md +15 -11
  43. package/skills/cold-email/references/deliverability.md +110 -0
  44. package/skills/cold-email/references/linkedin-outreach.md +91 -0
  45. package/skills/cold-email/references/multichannel-cadence.md +79 -0
  46. package/skills/cold-email/references/outbound-plays.md +88 -0
  47. package/skills/cold-email/references/personalization.md +11 -0
  48. package/skills/cold-email/references/reply-handling.md +65 -0
  49. package/skills/competitor-profiling/CHANGELOG.md +5 -0
  50. package/skills/competitor-profiling/SKILL.md +4 -4
  51. package/skills/content-strategy/CHANGELOG.md +5 -0
  52. package/skills/content-strategy/SKILL.md +5 -3
  53. package/skills/copy-editing/CHANGELOG.md +5 -0
  54. package/skills/copy-editing/SKILL.md +5 -5
  55. package/skills/copy-editing/references/content-refresh.md +33 -0
  56. package/skills/cro/CHANGELOG.md +5 -0
  57. package/skills/cro/SKILL.md +14 -3
  58. package/skills/cro/references/form.md +1 -1
  59. package/skills/directory-submissions/CHANGELOG.md +5 -0
  60. package/skills/directory-submissions/SKILL.md +13 -4
  61. package/skills/directory-submissions/evals/evals.json +15 -1
  62. package/skills/directory-submissions/references/directory-list.md +2 -4
  63. package/skills/emails/CHANGELOG.md +5 -0
  64. package/skills/emails/SKILL.md +14 -4
  65. package/skills/launch/CHANGELOG.md +7 -0
  66. package/skills/launch/SKILL.md +8 -4
  67. package/skills/launch/evals/evals.json +29 -0
  68. package/skills/launch/references/shipped-changes.md +84 -0
  69. package/skills/launch/references/site-launch-qa.md +55 -0
  70. package/skills/lead-magnets/CHANGELOG.md +5 -0
  71. package/skills/lead-magnets/SKILL.md +6 -4
  72. package/skills/lead-magnets/evals/evals.json +18 -0
  73. package/skills/lead-magnets/references/content-to-skill-corpus-design.md +89 -0
  74. package/skills/lead-magnets/references/content-to-skill-release.md +71 -0
  75. package/skills/lead-magnets/references/content-to-skill.md +108 -0
  76. package/skills/marketing-plan/CHANGELOG.md +5 -0
  77. package/skills/marketing-plan/SKILL.md +3 -3
  78. package/skills/marketing-plan/evals/evals.json +13 -0
  79. package/skills/marketing-plan/references/budget-planning.md +26 -17
  80. package/skills/pricing/CHANGELOG.md +5 -0
  81. package/skills/pricing/SKILL.md +4 -4
  82. package/skills/pricing/evals/evals.json +12 -0
  83. package/skills/pricing/references/research-methods.md +8 -4
  84. package/skills/programmatic-seo/CHANGELOG.md +5 -0
  85. package/skills/programmatic-seo/SKILL.md +8 -4
  86. package/skills/programmatic-seo/evals/evals.json +13 -0
  87. package/skills/programmatic-seo/references/implementation-platforms.md +37 -0
  88. package/skills/prospecting/CHANGELOG.md +7 -0
  89. package/skills/prospecting/SKILL.md +27 -13
  90. package/skills/prospecting/evals/evals.json +41 -0
  91. package/skills/prospecting/references/account-research.md +59 -0
  92. package/skills/prospecting/references/b2b-prospecting.md +1 -1
  93. package/skills/prospecting/references/compliance.md +44 -6
  94. package/skills/prospecting/references/data-sources.md +13 -15
  95. package/skills/prospecting/references/enrichment-playbook.md +61 -0
  96. package/skills/prospecting/references/saas-prospecting.md +1 -1
  97. package/skills/prospecting/references/signal-plays.md +99 -0
  98. package/skills/prospecting/references/sourcing-and-enrichment.md +89 -0
  99. package/skills/referrals/CHANGELOG.md +5 -0
  100. package/skills/referrals/SKILL.md +10 -4
  101. package/skills/referrals/evals/evals.json +99 -0
  102. package/skills/referrals/references/affiliate-operations.md +64 -0
  103. package/skills/referrals/references/affiliate-programs.md +2 -2
  104. package/skills/referrals/references/program-examples.md +14 -6
  105. package/skills/referrals/references/review-platform-rules.md +36 -0
  106. package/skills/referrals/references/review-program-operations.md +62 -0
  107. package/skills/referrals/references/review-programs.md +96 -0
  108. package/skills/revops/CHANGELOG.md +5 -0
  109. package/skills/revops/SKILL.md +10 -6
  110. package/skills/revops/evals/evals.json +13 -0
  111. package/skills/revops/references/automation-playbooks.md +1 -1
  112. package/skills/revops/references/inbound-routing.md +43 -0
  113. package/skills/revops/references/lifecycle-definitions.md +31 -0
  114. package/skills/sales-enablement/CHANGELOG.md +5 -0
  115. package/skills/sales-enablement/SKILL.md +6 -4
  116. package/skills/sales-enablement/evals/evals.json +14 -0
  117. package/skills/sales-enablement/references/cold-call-scripts.md +68 -0
  118. package/skills/schema/CHANGELOG.md +5 -0
  119. package/skills/schema/SKILL.md +20 -8
  120. package/skills/schema/evals/evals.json +40 -0
  121. package/skills/schema/references/schema-examples.md +9 -15
  122. package/skills/seo-audit/CHANGELOG.md +5 -0
  123. package/skills/seo-audit/SKILL.md +10 -16
  124. package/skills/seo-audit/evals/evals.json +68 -0
  125. package/skills/seo-audit/references/international-seo.md +9 -11
  126. package/skills/seo-audit/references/local-seo-pages-and-measurement.md +39 -0
  127. package/skills/seo-audit/references/local-seo-profile-and-citations.md +41 -0
  128. package/skills/seo-audit/references/local-seo.md +86 -0
  129. package/skills/seo-audit/references/rankings-push.md +62 -0
  130. package/skills/seo-audit/references/title-tags.md +89 -0
  131. package/skills/signup/CHANGELOG.md +5 -0
  132. package/skills/signup/SKILL.md +7 -3
  133. package/skills/site-architecture/CHANGELOG.md +5 -0
  134. package/skills/site-architecture/SKILL.md +5 -4
  135. package/skills/site-architecture/evals/evals.json +25 -0
  136. package/skills/site-architecture/references/platforms-and-migration.md +84 -0
  137. package/skills/social/CHANGELOG.md +5 -0
  138. package/skills/social/SKILL.md +4 -4
  139. package/skills/trends/.skillignore +3 -0
  140. package/skills/trends/CHANGELOG.md +20 -0
  141. package/skills/trends/SKILL.md +12 -8
  142. package/skills/trends/references/agent-mode.md +36 -0
  143. package/skills/trends/references/category-peers.md +38 -0
  144. package/skills/trends/references/comparison.md +227 -0
  145. package/skills/trends/references/competitors.md +29 -0
  146. package/skills/trends/references/discovery.md +90 -0
  147. package/skills/trends/references/engine-contract.md +111 -1889
  148. package/skills/trends/references/followups.md +112 -0
  149. package/skills/trends/references/grok-bot-x.md +55 -0
  150. package/skills/trends/references/hiring-signals.md +19 -0
  151. package/skills/trends/references/library-queue.md +33 -0
  152. package/skills/trends/references/recommendations.md +86 -0
  153. package/skills/trends/references/research-runbook.md +781 -0
  154. package/skills/trends/references/runtime.md +165 -0
  155. package/skills/trends/references/save-html-brief.md +39 -20
  156. package/skills/trends/references/setup-wizard.md +253 -0
  157. package/skills/trends/references/synthesis.md +289 -0
  158. package/skills/trends/scripts/box_chrome_login.py +209 -0
  159. package/skills/trends/scripts/briefing.py +9 -17
  160. package/skills/trends/scripts/last30days.py +3247 -280
  161. package/skills/trends/scripts/lib/agentcookie.py +196 -0
  162. package/skills/trends/scripts/lib/amazon.py +877 -0
  163. package/skills/trends/scripts/lib/arxiv.py +42 -8
  164. package/skills/trends/scripts/lib/backends.py +769 -0
  165. package/skills/trends/scripts/lib/bird_x.py +340 -57
  166. package/skills/trends/scripts/lib/bluesky.py +84 -10
  167. package/skills/trends/scripts/lib/bounded_get.py +156 -0
  168. package/skills/trends/scripts/lib/brightdata.py +291 -0
  169. package/skills/trends/scripts/lib/categories.py +7 -1
  170. package/skills/trends/scripts/lib/chrome_cdp.py +455 -0
  171. package/skills/trends/scripts/lib/chrome_cookies.py +132 -39
  172. package/skills/trends/scripts/lib/cluster.py +31 -54
  173. package/skills/trends/scripts/lib/competitors.py +10 -1
  174. package/skills/trends/scripts/lib/cookie_extract.py +221 -44
  175. package/skills/trends/scripts/lib/cookie_paths.py +27 -0
  176. package/skills/trends/scripts/lib/corpus.py +439 -0
  177. package/skills/trends/scripts/lib/devto.py +313 -0
  178. package/skills/trends/scripts/lib/discovery_handoff.py +988 -0
  179. package/skills/trends/scripts/lib/doctor.py +2166 -0
  180. package/skills/trends/scripts/lib/dripstack.py +203 -0
  181. package/skills/trends/scripts/lib/entity_extract.py +37 -0
  182. package/skills/trends/scripts/lib/env.py +1092 -138
  183. package/skills/trends/scripts/lib/fanout.py +11 -1
  184. package/skills/trends/scripts/lib/feed.py +61 -0
  185. package/skills/trends/scripts/lib/fetch_memo.py +135 -0
  186. package/skills/trends/scripts/lib/freshness.py +566 -0
  187. package/skills/trends/scripts/lib/fusion.py +314 -28
  188. package/skills/trends/scripts/lib/github.py +438 -24
  189. package/skills/trends/scripts/lib/grok_x.py +1237 -0
  190. package/skills/trends/scripts/lib/grounding.py +129 -11
  191. package/skills/trends/scripts/lib/hackernews.py +61 -4
  192. package/skills/trends/scripts/lib/health.py +373 -0
  193. package/skills/trends/scripts/lib/hosted.py +333 -0
  194. package/skills/trends/scripts/lib/html_publish.py +34 -0
  195. package/skills/trends/scripts/lib/html_render.py +169 -3
  196. package/skills/trends/scripts/lib/http.py +1215 -70
  197. package/skills/trends/scripts/lib/instagram.py +132 -9
  198. package/skills/trends/scripts/lib/jobs.py +4 -2
  199. package/skills/trends/scripts/lib/library.py +308 -0
  200. package/skills/trends/scripts/lib/library_index.py +477 -0
  201. package/skills/trends/scripts/lib/linkedin.py +271 -24
  202. package/skills/trends/scripts/lib/log.py +5 -2
  203. package/skills/trends/scripts/lib/meta_ads.py +982 -0
  204. package/skills/trends/scripts/lib/normalize.py +382 -28
  205. package/skills/trends/scripts/lib/parallel_mcp.py +234 -0
  206. package/skills/trends/scripts/lib/permission_preflight.py +42 -3
  207. package/skills/trends/scripts/lib/perplexity.py +1080 -468
  208. package/skills/trends/scripts/lib/pipeline.py +5079 -211
  209. package/skills/trends/scripts/lib/planner.py +396 -53
  210. package/skills/trends/scripts/lib/polymarket.py +285 -23
  211. package/skills/trends/scripts/lib/prescriptions.py +432 -0
  212. package/skills/trends/scripts/lib/private_files.py +21 -0
  213. package/skills/trends/scripts/lib/providers.py +231 -10
  214. package/skills/trends/scripts/lib/quality_nudge.py +122 -37
  215. package/skills/trends/scripts/lib/reddit.py +177 -12
  216. package/skills/trends/scripts/lib/reddit_arctic.py +146 -5
  217. package/skills/trends/scripts/lib/reddit_keyless.py +121 -60
  218. package/skills/trends/scripts/lib/reddit_listing.py +215 -14
  219. package/skills/trends/scripts/lib/reddit_public.py +12 -13
  220. package/skills/trends/scripts/lib/reddit_search.py +304 -0
  221. package/skills/trends/scripts/lib/reddit_shreddit.py +51 -7
  222. package/skills/trends/scripts/lib/registers.py +134 -0
  223. package/skills/trends/scripts/lib/relevance.py +17 -0
  224. package/skills/trends/scripts/lib/render.py +2161 -271
  225. package/skills/trends/scripts/lib/rerank.py +623 -55
  226. package/skills/trends/scripts/lib/resolve.py +50 -1
  227. package/skills/trends/scripts/lib/safari_cookies.py +42 -15
  228. package/skills/trends/scripts/lib/schema.py +782 -1
  229. package/skills/trends/scripts/lib/setup_wizard.py +779 -120
  230. package/skills/trends/scripts/lib/signals.py +206 -7
  231. package/skills/trends/scripts/lib/stocktwits.py +374 -0
  232. package/skills/trends/scripts/lib/subproc.py +307 -53
  233. package/skills/trends/scripts/lib/techmeme.py +89 -64
  234. package/skills/trends/scripts/lib/telegram.py +317 -0
  235. package/skills/trends/scripts/lib/tiktok.py +13 -9
  236. package/skills/trends/scripts/lib/topic_shape.py +340 -0
  237. package/skills/trends/scripts/lib/transcribe.py +12 -3
  238. package/skills/trends/scripts/lib/trustpilot.py +256 -5
  239. package/skills/trends/scripts/lib/truthsocial.py +9 -1
  240. package/skills/trends/scripts/lib/ui.py +6 -0
  241. package/skills/trends/scripts/lib/usage.py +132 -0
  242. package/skills/trends/scripts/lib/web_search_keyless.py +63 -4
  243. package/skills/trends/scripts/lib/windows_job.py +138 -0
  244. package/skills/trends/scripts/lib/x_api.py +824 -0
  245. package/skills/trends/scripts/lib/x_envelope.py +733 -0
  246. package/skills/trends/scripts/lib/x_judge.py +292 -0
  247. package/skills/trends/scripts/lib/xai_x.py +26 -3
  248. package/skills/trends/scripts/lib/xquik.py +89 -15
  249. package/skills/trends/scripts/lib/xurl_x.py +284 -91
  250. package/skills/trends/scripts/lib/youtube_yt.py +570 -119
  251. package/skills/trends/scripts/setup-keychain.sh +12 -4
  252. package/skills/trends/scripts/setup-pass.sh +3 -0
  253. package/skills/trends/scripts/store.py +444 -50
  254. package/skills/trends/scripts/watchlist.py +38 -11
  255. package/skills/trends/scripts/x_lane_parity.py +107 -0
  256. package/skills/video-studio/CHANGELOG.md +8 -0
  257. package/skills/video-studio/SKILL.md +6 -5
  258. package/skills/video-studio/references/styles/product-flight.md +45 -0
  259. package/skills/video-studio/references/styles.md +3 -1
  260. package/skills/video-studio/scripts/qa.mjs +64 -2
  261. package/skills/video-studio/scripts/qa.test.mjs +29 -1
  262. package/skills/video-studio-motion/ATTRIBUTION.md +1 -1
  263. package/skills/video-studio-motion/CHANGELOG.md +4 -0
  264. package/skills/video-studio-motion/SKILL.md +8 -5
  265. package/skills/coder-brainstorming/spec-document-reviewer-prompt.md +0 -49
  266. package/skills/trends/scripts/lib/reddit_rss.py +0 -224
@@ -12,16 +12,19 @@ Database location: ~/.local/share/last30days/research.db
12
12
 
13
13
  import argparse
14
14
  import json
15
+ import os
16
+ import re
15
17
  import sqlite3
16
18
  import sys
19
+ from contextlib import contextmanager
17
20
  from datetime import datetime, timedelta, timezone
18
21
  from pathlib import Path
19
- from typing import Any, Dict, List, Optional
22
+ from typing import Any, Dict, Iterator, List, Optional
20
23
 
21
24
  SCRIPT_DIR = Path(__file__).parent.resolve()
22
25
  sys.path.insert(0, str(SCRIPT_DIR))
23
26
 
24
- from lib import schema
27
+ from lib import dedupe, entity_extract, schema
25
28
 
26
29
  DB_DIR = Path.home() / ".local" / "share" / "last30days"
27
30
  DB_PATH = DB_DIR / "research.db"
@@ -34,6 +37,45 @@ def _get_db_path() -> Path:
34
37
  return _db_override or DB_PATH
35
38
 
36
39
 
40
+ @contextmanager
41
+ def scoped_db(db_path: Optional[Path]) -> Iterator[None]:
42
+ """Route all store access inside the block to ``db_path``.
43
+
44
+ ``None`` keeps the shared store. Scoped runs (``--save-dir``) use this so
45
+ their findings land next to their briefs instead of leaking into the
46
+ shared research.db that unscoped searches read.
47
+ """
48
+ global _db_override
49
+ if db_path is None:
50
+ yield
51
+ return
52
+ previous = _db_override
53
+ _db_override = Path(db_path)
54
+ try:
55
+ yield
56
+ finally:
57
+ _db_override = previous
58
+
59
+
60
+ def ensure_private_db_files(db_path: Optional[Path] = None) -> Path:
61
+ """Create/harden the research database and SQLite sidecars owner-only."""
62
+ path = db_path or _get_db_path()
63
+ path.parent.mkdir(parents=True, exist_ok=True)
64
+ if not path.exists():
65
+ try:
66
+ fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
67
+ except FileExistsError:
68
+ pass
69
+ else:
70
+ os.close(fd)
71
+ for candidate in (path, Path(f"{path}-wal"), Path(f"{path}-shm")):
72
+ try:
73
+ candidate.chmod(0o600)
74
+ except FileNotFoundError:
75
+ pass
76
+ return path
77
+
78
+
37
79
  SCHEMA_V1 = """
38
80
  PRAGMA journal_mode=WAL;
39
81
  PRAGMA synchronous=NORMAL;
@@ -123,20 +165,20 @@ CREATE TABLE IF NOT EXISTS settings (
123
165
  );
124
166
  """
125
167
 
126
- SCHEMA_V1_DEFAULTS = """
127
- INSERT OR IGNORE INTO schema_version (version) VALUES (1);
128
- INSERT OR IGNORE INTO settings (key, value) VALUES ('daily_budget', '5.00');
129
- INSERT OR IGNORE INTO settings (key, value) VALUES ('delivery_channel', '');
130
- INSERT OR IGNORE INTO settings (key, value) VALUES ('delivery_mode', 'announce');
131
- INSERT OR IGNORE INTO settings (key, value) VALUES ('briefing_format', 'concise');
132
- INSERT OR IGNORE INTO settings (key, value) VALUES ('default_schedule', '0 8 * * *');
133
- """
168
+ _DEFAULT_SETTINGS = {
169
+ "daily_budget": "5.00",
170
+ "delivery_channel": "",
171
+ "delivery_mode": "announce",
172
+ "briefing_format": "concise",
173
+ "default_schedule": "0 8 * * *",
174
+ }
134
175
 
135
176
  _UPDATABLE_RUN_COLUMNS = frozenset({
136
177
  "source_mode",
137
178
  "prompt_tokens",
138
179
  "completion_tokens",
139
180
  "token_cost",
181
+ "cost_unknown",
140
182
  "duration_seconds",
141
183
  "status",
142
184
  "error_message",
@@ -181,6 +223,28 @@ CREATE INDEX IF NOT EXISTS idx_finding_sightings_topic_seen
181
223
  ON finding_sightings(topic_id, seen_at);
182
224
  CREATE INDEX IF NOT EXISTS idx_finding_sightings_url
183
225
  ON finding_sightings(source_url);
226
+ """,
227
+ 3: """
228
+ CREATE TABLE IF NOT EXISTS discovery_topics (
229
+ id INTEGER PRIMARY KEY,
230
+ name TEXT NOT NULL,
231
+ normalized_name TEXT NOT NULL UNIQUE,
232
+ entity_key TEXT,
233
+ domain TEXT,
234
+ first_surfaced TEXT NOT NULL,
235
+ last_surfaced TEXT NOT NULL,
236
+ surface_count INTEGER NOT NULL DEFAULT 1,
237
+ status TEXT NOT NULL DEFAULT 'surfaced' CHECK(status IN ('surfaced','covered')),
238
+ covered_at TEXT,
239
+ last_run_ref TEXT
240
+ );
241
+
242
+ CREATE INDEX IF NOT EXISTS idx_discovery_topics_status_surfaced
243
+ ON discovery_topics(status, last_surfaced);
244
+ """,
245
+ 4: """
246
+ ALTER TABLE research_runs ADD COLUMN cost_unknown INTEGER NOT NULL DEFAULT 1;
247
+ UPDATE research_runs SET cost_unknown = 0 WHERE token_cost > 0;
184
248
  """,
185
249
  }
186
250
 
@@ -208,9 +272,22 @@ def init_db(db_path: Optional[Path] = None) -> Path:
208
272
  conn = _connect(path)
209
273
  try:
210
274
  conn.executescript(SCHEMA_V1)
211
- conn.executescript(SCHEMA_V1_DEFAULTS)
275
+ if not conn.execute("SELECT 1 FROM schema_version WHERE version = 1").fetchone():
276
+ conn.execute("INSERT OR IGNORE INTO schema_version (version) VALUES (1)")
277
+ existing_settings = {row["key"] for row in conn.execute("SELECT key FROM settings")}
278
+ missing_defaults = [
279
+ (key, value) for key, value in _DEFAULT_SETTINGS.items()
280
+ if key not in existing_settings
281
+ ]
282
+ if missing_defaults:
283
+ conn.executemany(
284
+ "INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)",
285
+ missing_defaults,
286
+ )
287
+ conn.commit()
212
288
  _run_migrations(conn)
213
289
  conn.commit()
290
+ _backfill_owner_sightings(conn)
214
291
  finally:
215
292
  conn.close()
216
293
 
@@ -219,27 +296,88 @@ def init_db(db_path: Optional[Path] = None) -> Path:
219
296
 
220
297
  def _run_migrations(conn: sqlite3.Connection):
221
298
  """Apply pending schema migrations."""
222
- current = conn.execute(
223
- "SELECT MAX(version) FROM schema_version"
224
- ).fetchone()[0] or 0
299
+ current = conn.execute("SELECT MAX(version) FROM schema_version").fetchone()[0] or 0
300
+ if current >= max(MIGRATIONS, default=0):
301
+ return
302
+ conn.execute("BEGIN IMMEDIATE")
303
+ try:
304
+ current = conn.execute(
305
+ "SELECT MAX(version) FROM schema_version"
306
+ ).fetchone()[0] or 0
307
+ for version in sorted(MIGRATIONS):
308
+ if version <= current:
309
+ continue
310
+ # executescript commits before running; keep DDL and its marker atomic.
311
+ statement = ""
312
+ for character in MIGRATIONS[version]:
313
+ statement += character
314
+ if character == ";" and sqlite3.complete_statement(statement):
315
+ conn.execute(statement)
316
+ statement = ""
317
+ if statement.strip():
318
+ conn.execute(statement)
319
+ conn.execute("INSERT INTO schema_version (version) VALUES (?)", (version,))
320
+ conn.commit()
321
+ except BaseException:
322
+ conn.rollback()
323
+ raise
225
324
 
226
- for version in sorted(MIGRATIONS.keys()):
227
- if version > current:
228
- conn.executescript(MIGRATIONS[version])
229
- conn.execute(
230
- "INSERT INTO schema_version (version) VALUES (?)", (version,)
231
- )
325
+
326
+ def _backfill_owner_sightings(conn: sqlite3.Connection) -> None:
327
+ """Preserve legacy first observations before aggregate ownership can change."""
328
+ marker = "_topic_sightings_backfilled_v1"
329
+ if conn.execute("SELECT 1 FROM settings WHERE key = ?", (marker,)).fetchone():
330
+ return
331
+ with conn:
332
+ conn.execute("BEGIN IMMEDIATE")
333
+ if conn.execute("SELECT 1 FROM settings WHERE key = ?", (marker,)).fetchone():
334
+ return
335
+ conn.execute(
336
+ """INSERT INTO finding_sightings
337
+ (finding_id, run_id, topic_id, source, source_url, source_title,
338
+ engagement_score, relevance_score, seen_at)
339
+ SELECT f.id, NULL, f.topic_id, f.source, COALESCE(f.source_url, ''),
340
+ f.source_title, f.engagement_score, f.relevance_score, f.first_seen
341
+ FROM findings f
342
+ WHERE f.topic_id IS NOT NULL AND f.first_seen IS NOT NULL
343
+ AND NOT EXISTS (
344
+ SELECT 1 FROM finding_sightings s
345
+ WHERE s.finding_id = f.id AND s.topic_id = f.topic_id
346
+ AND s.seen_at <= f.first_seen
347
+ )"""
348
+ )
349
+ conn.execute("INSERT INTO settings (key, value) VALUES (?, '1')", (marker,))
232
350
 
233
351
 
234
352
  # --- Topics ---
235
353
 
236
354
 
355
+ # Ledger dates survive aggregate ownership reassignment; references are a legacy fallback.
356
+ _FINDING_TOPICS_SQL = """
357
+ SELECT finding_id, topic_id,
358
+ COALESCE(MIN(sighting_seen), MIN(legacy_first_seen)) AS first_seen
359
+ FROM (
360
+ SELECT id AS finding_id, topic_id, NULL AS sighting_seen, first_seen AS legacy_first_seen
361
+ FROM findings WHERE topic_id IS NOT NULL
362
+ UNION ALL
363
+ SELECT finding_id, topic_id, seen_at, NULL
364
+ FROM finding_sightings WHERE topic_id IS NOT NULL
365
+ UNION ALL
366
+ SELECT f.id, r.topic_id, NULL, r.run_date FROM findings f
367
+ JOIN research_runs r ON r.id = f.run_id WHERE r.topic_id IS NOT NULL
368
+ )
369
+ GROUP BY finding_id, topic_id
370
+ """
371
+
372
+
237
373
  def add_topic(
238
374
  name: str,
239
375
  search_queries: Optional[List[str]] = None,
240
376
  schedule: str = "0 8 * * *",
377
+ *,
378
+ update_existing: bool = True,
241
379
  ) -> Dict[str, Any]:
242
- """Add a topic to the watchlist. Returns the topic dict."""
380
+ """Add a topic, optionally preserving an existing topic's configuration."""
243
381
  init_db()
244
382
  conn = _connect()
245
383
  try:
@@ -250,8 +388,9 @@ def add_topic(
250
388
  ON CONFLICT(name) DO UPDATE SET
251
389
  search_queries = excluded.search_queries,
252
390
  schedule = excluded.schedule,
253
- updated_at = datetime('now')""",
254
- (name, queries_json, schedule),
391
+ updated_at = datetime('now')
392
+ WHERE ?""",
393
+ (name, queries_json, schedule, update_existing),
255
394
  )
256
395
  conn.commit()
257
396
  row = conn.execute(
@@ -267,14 +406,44 @@ def remove_topic(name: str) -> bool:
267
406
  init_db()
268
407
  conn = _connect()
269
408
  try:
409
+ conn.execute("BEGIN IMMEDIATE")
270
410
  row = conn.execute(
271
411
  "SELECT id FROM topics WHERE name = ?", (name,)
272
412
  ).fetchone()
273
413
  if not row:
274
414
  return False
275
415
  topic_id = row["id"]
276
- # Delete findings and runs for this topic
277
- conn.execute("DELETE FROM findings WHERE topic_id = ?", (topic_id,))
416
+ findings = conn.execute(
417
+ f"""WITH memberships AS ({_FINDING_TOPICS_SQL})
418
+ SELECT f.id, f.topic_id, f.run_id, r.topic_id AS run_topic_id,
419
+ (SELECT MIN(m.topic_id) FROM memberships m
420
+ WHERE m.finding_id = f.id AND m.topic_id != ?) AS survivor
421
+ FROM findings f
422
+ LEFT JOIN research_runs r ON r.id = f.run_id
423
+ WHERE f.id IN (SELECT finding_id FROM memberships WHERE topic_id = ?)""",
424
+ (topic_id, topic_id),
425
+ ).fetchall()
426
+ for finding in findings:
427
+ if finding["survivor"] is None:
428
+ conn.execute("DELETE FROM findings WHERE id = ?", (finding["id"],))
429
+ continue
430
+ owner_id = finding["topic_id"]
431
+ if owner_id == topic_id:
432
+ owner_id = finding["survivor"]
433
+ run_id = finding["run_id"]
434
+ if finding["run_topic_id"] == topic_id:
435
+ surviving_run = conn.execute(
436
+ """SELECT s.run_id FROM finding_sightings s
437
+ JOIN research_runs r ON r.id = s.run_id
438
+ WHERE s.finding_id = ? AND s.topic_id != ? AND r.topic_id != ?
439
+ ORDER BY s.seen_at DESC, s.id DESC LIMIT 1""",
440
+ (finding["id"], topic_id, topic_id),
441
+ ).fetchone()
442
+ run_id = surviving_run["run_id"] if surviving_run else None
443
+ conn.execute(
444
+ "UPDATE findings SET topic_id = ?, run_id = ? WHERE id = ?",
445
+ (owner_id, run_id, finding["id"]),
446
+ )
278
447
  conn.execute("DELETE FROM research_runs WHERE topic_id = ?", (topic_id,))
279
448
  conn.execute("DELETE FROM topics WHERE id = ?", (topic_id,))
280
449
  conn.commit()
@@ -289,8 +458,9 @@ def list_topics() -> List[Dict[str, Any]]:
289
458
  conn = _connect()
290
459
  try:
291
460
  rows = conn.execute(
292
- """SELECT t.*,
293
- (SELECT COUNT(*) FROM findings WHERE topic_id = t.id) as finding_count,
461
+ f"""WITH memberships AS ({_FINDING_TOPICS_SQL})
462
+ SELECT t.*,
463
+ (SELECT COUNT(*) FROM memberships WHERE topic_id = t.id) as finding_count,
294
464
  (SELECT MAX(run_date) FROM research_runs WHERE topic_id = t.id) as last_run,
295
465
  (SELECT status FROM research_runs WHERE topic_id = t.id
296
466
  ORDER BY created_at DESC LIMIT 1) as last_status
@@ -326,7 +496,7 @@ def record_run(
326
496
  duration_seconds: float = 0,
327
497
  prompt_tokens: int = 0,
328
498
  completion_tokens: int = 0,
329
- token_cost: float = 0,
499
+ token_cost: Optional[float] = None,
330
500
  ) -> int:
331
501
  """Record a research run. Returns the run ID."""
332
502
  conn = _connect()
@@ -334,11 +504,12 @@ def record_run(
334
504
  cursor = conn.execute(
335
505
  """INSERT INTO research_runs
336
506
  (topic_id, run_date, source_mode, status, error_message,
337
- duration_seconds, prompt_tokens, completion_tokens, token_cost)
338
- VALUES (?, datetime('now'), ?, ?, ?, ?, ?, ?, ?)""",
507
+ duration_seconds, prompt_tokens, completion_tokens, token_cost, cost_unknown)
508
+ VALUES (?, datetime('now'), ?, ?, ?, ?, ?, ?, ?, ?)""",
339
509
  (
340
510
  topic_id, source_mode, status, error_message,
341
511
  duration_seconds, prompt_tokens, completion_tokens, token_cost,
512
+ int(token_cost is None),
342
513
  ),
343
514
  )
344
515
  conn.commit()
@@ -424,7 +595,7 @@ def store_findings(
424
595
 
425
596
  for url, f in with_urls:
426
597
  existing = existing_by_url.get(url)
427
- new_engagement = f.get("engagement_score", 0)
598
+ new_engagement = f.get("engagement_score") or 0
428
599
  if existing:
429
600
  update_rows.append((
430
601
  max(new_engagement, existing["engagement_score"] or 0),
@@ -543,8 +714,8 @@ def _record_sightings(
543
714
  finding.get("source", "unknown"),
544
715
  url,
545
716
  finding.get("source_title") or finding.get("title", ""),
546
- finding.get("engagement_score", 0),
547
- finding.get("relevance_score", 0),
717
+ finding.get("engagement_score") if finding.get("engagement_score") is not None else 0,
718
+ finding.get("relevance_score") if finding.get("relevance_score") is not None else 0,
548
719
  ))
549
720
 
550
721
  if not sighting_rows:
@@ -666,25 +837,31 @@ def _delta_source_counts(
666
837
  def get_new_findings(
667
838
  topic_id: int,
668
839
  since: Optional[str] = None,
840
+ before: Optional[str] = None,
669
841
  ) -> List[Dict[str, Any]]:
670
- """Get findings for a topic, optionally since a date."""
842
+ """Get findings by their first observation for this topic within the date bounds."""
671
843
  conn = _connect()
672
844
  try:
845
+ date_filter = ""
846
+ parameters: List[Any] = [topic_id]
673
847
  if since:
674
- rows = conn.execute(
675
- """SELECT * FROM findings
676
- WHERE topic_id = ? AND first_seen >= ? AND dismissed = 0
677
- ORDER BY first_seen DESC""",
678
- (topic_id, since),
679
- ).fetchall()
680
- else:
681
- rows = conn.execute(
682
- """SELECT * FROM findings
683
- WHERE topic_id = ? AND dismissed = 0
684
- ORDER BY first_seen DESC""",
685
- (topic_id,),
686
- ).fetchall()
687
- return [dict(r) for r in rows]
848
+ date_filter += " AND m.first_seen >= ?"
849
+ parameters.append(since)
850
+ if before:
851
+ date_filter += " AND m.first_seen < ?"
852
+ parameters.append(before)
853
+ rows = conn.execute(
854
+ f"""WITH memberships AS ({_FINDING_TOPICS_SQL})
855
+ SELECT f.*, m.first_seen AS topic_first_seen FROM findings f
856
+ JOIN memberships m ON m.finding_id = f.id
857
+ WHERE m.topic_id = ? AND f.dismissed = 0{date_filter}
858
+ ORDER BY m.first_seen DESC""",
859
+ parameters,
860
+ ).fetchall()
861
+ findings = [dict(r) for r in rows]
862
+ for finding in findings:
863
+ finding["first_seen"] = finding.pop("topic_first_seen")
864
+ return findings
688
865
  finally:
689
866
  conn.close()
690
867
 
@@ -740,6 +917,209 @@ def dismiss_finding(finding_id: int):
740
917
  update_finding(finding_id, dismissed=1)
741
918
 
742
919
 
920
+ # --- Discovery topic queue ---
921
+
922
+ # Conservative floor for fuzzy queue matching (overlap coefficient of entity
923
+ # tokens via entity_extract.entity_overlap). Tunable: raise toward 1.0 for
924
+ # stricter matching, lower for looser. Matching is annotate-only - a fuzzy
925
+ # match stamps prior-surfacing context onto an incoming topic but NEVER merges
926
+ # queue rows, so a too-loose threshold can mislabel a card yet never lose data.
927
+ DISCOVERY_QUEUE_OVERLAP_THRESHOLD = 0.6
928
+
929
+
930
+ def _normalize_discovery_name(name: str) -> str:
931
+ """Queue identity: lowercased, punctuation-stripped, whitespace-collapsed
932
+ (thin alias for dedupe.normalize_text)."""
933
+ return dedupe.normalize_text(name)
934
+
935
+
936
+ def _discovery_entity_key(name: str) -> str:
937
+ """Sorted joined significant tokens, computed once at write time."""
938
+ return " ".join(sorted(entity_extract.extract_text_entities(name)))
939
+
940
+
941
+ def _discovery_anchor_entities(name: str) -> set[str]:
942
+ """Anchor tokens for fuzzy matching: capitalized, all-caps, or
943
+ digit-bearing words minus stopwords (product/person/version anchors).
944
+
945
+ Generic lowercase words ("chat", "templates") are excluded so two angles
946
+ on the same subject ("Gemma 4 chat templates" / "Gemma 4 tool calling
947
+ fixes") cross-match while different subjects sharing filler words don't.
948
+ """
949
+ anchors = set()
950
+ for word in re.sub(r"[^\w\s]", " ", name).split():
951
+ lower = word.casefold()
952
+ if lower in entity_extract.ENTITY_STOPWORDS:
953
+ continue
954
+ if entity_extract.has_anchor_signal(word):
955
+ anchors.add(lower)
956
+ return anchors
957
+
958
+
959
+ def record_discovery_surfacing(
960
+ name: str,
961
+ domain: str = "",
962
+ run_ref: str = "",
963
+ as_of: str = "",
964
+ inherit_covered_at: Optional[str] = None,
965
+ ) -> Dict[str, Any]:
966
+ """Upsert a queue row by normalized name.
967
+
968
+ A fresh topic inserts with surface_count 1; re-surfacing the same
969
+ normalized name increments the count and refreshes last_surfaced and
970
+ last_run_ref (first_surfaced never changes). Returns the resulting row.
971
+
972
+ A resurfacing with a blank domain (e.g. a global-trending sweep with no
973
+ domain) never blanks a domain recorded by an earlier, domain-scoped
974
+ surfacing - the stored domain only changes when the incoming domain is
975
+ non-empty. ``domain`` is normalized to "" here (never NULL bound) so the
976
+ column's storage convention stays consistent regardless of whether a
977
+ caller passes "" or None.
978
+
979
+ ``inherit_covered_at`` makes a FRESH row be born covered (status
980
+ 'covered', covered_at set to the given date). Callers pass it when this
981
+ name fuzzy-matched an already-covered prior row, so a user's covered
982
+ mark survives judge naming drift instead of forking into a fresh
983
+ uncovered row. An existing row's status/covered_at are never modified
984
+ by this function - the ON CONFLICT path deliberately ignores it.
985
+
986
+ Idempotency guard: when the existing row's last_run_ref already equals
987
+ this call's (non-blank) run_ref, the surfacing was ALREADY counted by
988
+ this run identity - a retry (e.g. a --finalize re-run with a corrected
989
+ angles file) returns the row unchanged instead of double-counting.
990
+ Blank run_refs never guard, so callers without a run identity keep the
991
+ every-call-increments behavior.
992
+ """
993
+ init_db()
994
+ domain = domain or ""
995
+ normalized = _normalize_discovery_name(name)
996
+ entity_key = _discovery_entity_key(name)
997
+ status = "covered" if inherit_covered_at else "surfaced"
998
+ conn = _connect()
999
+ try:
1000
+ if run_ref:
1001
+ existing = conn.execute(
1002
+ "SELECT * FROM discovery_topics WHERE normalized_name = ?",
1003
+ (normalized,),
1004
+ ).fetchone()
1005
+ if existing is not None and existing["last_run_ref"] == run_ref:
1006
+ return dict(existing)
1007
+ conn.execute(
1008
+ """INSERT INTO discovery_topics
1009
+ (name, normalized_name, entity_key, domain, first_surfaced,
1010
+ last_surfaced, surface_count, last_run_ref, status, covered_at)
1011
+ VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?, ?)
1012
+ ON CONFLICT(normalized_name) DO UPDATE SET
1013
+ surface_count = surface_count + 1,
1014
+ last_surfaced = excluded.last_surfaced,
1015
+ last_run_ref = excluded.last_run_ref,
1016
+ domain = CASE WHEN excluded.domain <> '' THEN excluded.domain ELSE domain END""",
1017
+ (name, normalized, entity_key, domain, as_of, as_of, run_ref, status, inherit_covered_at),
1018
+ )
1019
+ conn.commit()
1020
+ row = conn.execute(
1021
+ "SELECT * FROM discovery_topics WHERE normalized_name = ?",
1022
+ (normalized,),
1023
+ ).fetchone()
1024
+ return dict(row)
1025
+ finally:
1026
+ conn.close()
1027
+
1028
+
1029
+ def match_discovery_topic(name: str) -> Optional[Dict[str, Any]]:
1030
+ """Find the queue row a topic name refers to, or None.
1031
+
1032
+ Exact normalized-name match wins; otherwise the best entity-overlap match
1033
+ at or above DISCOVERY_QUEUE_OVERLAP_THRESHOLD. Overlap is the better of
1034
+ the full entity_key token overlap and the anchor-token overlap (see
1035
+ _discovery_anchor_entities) - full-token overlap alone dilutes the subject
1036
+ anchor with generic words, so same-subject near-duplicates would never
1037
+ clear a conservative floor. Matching NEVER merges rows: a fuzzy match only
1038
+ annotates the incoming topic with the prior row's context.
1039
+ """
1040
+ init_db()
1041
+ normalized = _normalize_discovery_name(name)
1042
+ conn = _connect()
1043
+ try:
1044
+ row = conn.execute(
1045
+ "SELECT * FROM discovery_topics WHERE normalized_name = ?",
1046
+ (normalized,),
1047
+ ).fetchone()
1048
+ if row:
1049
+ return dict(row)
1050
+
1051
+ entities = entity_extract.extract_text_entities(name)
1052
+ anchors = _discovery_anchor_entities(name)
1053
+ if not entities and not anchors:
1054
+ return None
1055
+ best: Optional[sqlite3.Row] = None
1056
+ best_overlap = 0.0
1057
+ for candidate in conn.execute("SELECT * FROM discovery_topics").fetchall():
1058
+ candidate_entities = set((candidate["entity_key"] or "").split())
1059
+ overlap = max(
1060
+ entity_extract.entity_overlap(entities, candidate_entities),
1061
+ entity_extract.entity_overlap(
1062
+ anchors, _discovery_anchor_entities(candidate["name"])
1063
+ ),
1064
+ )
1065
+ if overlap > best_overlap:
1066
+ best, best_overlap = candidate, overlap
1067
+ if best is not None and best_overlap >= DISCOVERY_QUEUE_OVERLAP_THRESHOLD:
1068
+ return dict(best)
1069
+ return None
1070
+ finally:
1071
+ conn.close()
1072
+
1073
+
1074
+ def list_discovery_queue(status: Optional[str] = None) -> List[Dict[str, Any]]:
1075
+ """List queue rows, newest surfacing first, optionally filtered by status."""
1076
+ init_db()
1077
+ conn = _connect()
1078
+ try:
1079
+ if status:
1080
+ rows = conn.execute(
1081
+ """SELECT * FROM discovery_topics WHERE status = ?
1082
+ ORDER BY last_surfaced DESC, id DESC""",
1083
+ (status,),
1084
+ ).fetchall()
1085
+ else:
1086
+ rows = conn.execute(
1087
+ "SELECT * FROM discovery_topics ORDER BY last_surfaced DESC, id DESC"
1088
+ ).fetchall()
1089
+ return [dict(r) for r in rows]
1090
+ finally:
1091
+ conn.close()
1092
+
1093
+
1094
+ def mark_discovery_covered(name: str, as_of: str) -> Optional[Dict[str, Any]]:
1095
+ """Mark a queued topic covered by EXACT normalized name.
1096
+
1097
+ Returns the updated row, or None when no row matches - callers must error
1098
+ loudly on None, never silently no-op. Fuzzy matching is deliberately not
1099
+ offered here: covering mutates state, so it demands the exact name.
1100
+ """
1101
+ init_db()
1102
+ normalized = _normalize_discovery_name(name)
1103
+ conn = _connect()
1104
+ try:
1105
+ cursor = conn.execute(
1106
+ """UPDATE discovery_topics
1107
+ SET status = 'covered', covered_at = ?
1108
+ WHERE normalized_name = ?""",
1109
+ (as_of, normalized),
1110
+ )
1111
+ conn.commit()
1112
+ if cursor.rowcount == 0:
1113
+ return None
1114
+ row = conn.execute(
1115
+ "SELECT * FROM discovery_topics WHERE normalized_name = ?",
1116
+ (normalized,),
1117
+ ).fetchone()
1118
+ return dict(row)
1119
+ finally:
1120
+ conn.close()
1121
+
1122
+
743
1123
  # --- Cost Tracking ---
744
1124
 
745
1125
 
@@ -763,6 +1143,18 @@ def get_daily_cost(date: Optional[str] = None) -> float:
763
1143
  # --- Settings ---
764
1144
 
765
1145
 
1146
+ def get_daily_unknown_cost_runs(date: Optional[str] = None) -> int:
1147
+ conn = _connect()
1148
+ try:
1149
+ date = date or datetime.now(timezone.utc).strftime("%Y-%m-%d")
1150
+ return conn.execute(
1151
+ "SELECT COUNT(*) FROM research_runs WHERE date(run_date) = date(?) AND cost_unknown = 1",
1152
+ (date,),
1153
+ ).fetchone()[0]
1154
+ finally:
1155
+ conn.close()
1156
+
1157
+
766
1158
  def get_setting(key: str, default: Optional[str] = None) -> Optional[str]:
767
1159
  """Get a setting value."""
768
1160
  init_db()
@@ -852,11 +1244,13 @@ def get_trending(days: int = 7) -> List[Dict[str, Any]]:
852
1244
  try:
853
1245
  since = (datetime.now(timezone.utc) - timedelta(days=days)).strftime("%Y-%m-%d")
854
1246
  rows = conn.execute(
855
- """SELECT t.name, t.id,
1247
+ f"""WITH memberships AS ({_FINDING_TOPICS_SQL})
1248
+ SELECT t.name, t.id,
856
1249
  COUNT(f.id) as new_findings,
857
1250
  COALESCE(SUM(f.engagement_score), 0) as total_engagement
858
1251
  FROM topics t
859
- LEFT JOIN findings f ON f.topic_id = t.id AND f.first_seen >= ?
1252
+ LEFT JOIN memberships m ON m.topic_id = t.id
1253
+ LEFT JOIN findings f ON f.id = m.finding_id AND m.first_seen >= ?
860
1254
  WHERE t.enabled = 1
861
1255
  GROUP BY t.id
862
1256
  ORDER BY new_findings DESC""",