luckiest-co 1.0.47 → 1.0.49

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (274) 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/model-router/CHANGELOG.md +6 -0
  81. package/skills/model-router/SKILL.md +3 -3
  82. package/skills/model-router/scripts/route.py +1 -1
  83. package/skills/model-router/scripts/test_route.py +5 -0
  84. package/skills/pricing/CHANGELOG.md +5 -0
  85. package/skills/pricing/SKILL.md +4 -4
  86. package/skills/pricing/evals/evals.json +12 -0
  87. package/skills/pricing/references/research-methods.md +8 -4
  88. package/skills/programmatic-seo/CHANGELOG.md +5 -0
  89. package/skills/programmatic-seo/SKILL.md +8 -4
  90. package/skills/programmatic-seo/evals/evals.json +13 -0
  91. package/skills/programmatic-seo/references/implementation-platforms.md +37 -0
  92. package/skills/prospecting/CHANGELOG.md +7 -0
  93. package/skills/prospecting/SKILL.md +27 -13
  94. package/skills/prospecting/evals/evals.json +41 -0
  95. package/skills/prospecting/references/account-research.md +59 -0
  96. package/skills/prospecting/references/b2b-prospecting.md +1 -1
  97. package/skills/prospecting/references/compliance.md +44 -6
  98. package/skills/prospecting/references/data-sources.md +13 -15
  99. package/skills/prospecting/references/enrichment-playbook.md +61 -0
  100. package/skills/prospecting/references/saas-prospecting.md +1 -1
  101. package/skills/prospecting/references/signal-plays.md +99 -0
  102. package/skills/prospecting/references/sourcing-and-enrichment.md +89 -0
  103. package/skills/referrals/CHANGELOG.md +5 -0
  104. package/skills/referrals/SKILL.md +10 -4
  105. package/skills/referrals/evals/evals.json +99 -0
  106. package/skills/referrals/references/affiliate-operations.md +64 -0
  107. package/skills/referrals/references/affiliate-programs.md +2 -2
  108. package/skills/referrals/references/program-examples.md +14 -6
  109. package/skills/referrals/references/review-platform-rules.md +36 -0
  110. package/skills/referrals/references/review-program-operations.md +62 -0
  111. package/skills/referrals/references/review-programs.md +96 -0
  112. package/skills/revops/CHANGELOG.md +5 -0
  113. package/skills/revops/SKILL.md +10 -6
  114. package/skills/revops/evals/evals.json +13 -0
  115. package/skills/revops/references/automation-playbooks.md +1 -1
  116. package/skills/revops/references/inbound-routing.md +43 -0
  117. package/skills/revops/references/lifecycle-definitions.md +31 -0
  118. package/skills/sales-enablement/CHANGELOG.md +5 -0
  119. package/skills/sales-enablement/SKILL.md +6 -4
  120. package/skills/sales-enablement/evals/evals.json +14 -0
  121. package/skills/sales-enablement/references/cold-call-scripts.md +68 -0
  122. package/skills/schema/CHANGELOG.md +5 -0
  123. package/skills/schema/SKILL.md +20 -8
  124. package/skills/schema/evals/evals.json +40 -0
  125. package/skills/schema/references/schema-examples.md +9 -15
  126. package/skills/seo-audit/CHANGELOG.md +5 -0
  127. package/skills/seo-audit/SKILL.md +10 -16
  128. package/skills/seo-audit/evals/evals.json +68 -0
  129. package/skills/seo-audit/references/international-seo.md +9 -11
  130. package/skills/seo-audit/references/local-seo-pages-and-measurement.md +39 -0
  131. package/skills/seo-audit/references/local-seo-profile-and-citations.md +41 -0
  132. package/skills/seo-audit/references/local-seo.md +86 -0
  133. package/skills/seo-audit/references/rankings-push.md +62 -0
  134. package/skills/seo-audit/references/title-tags.md +89 -0
  135. package/skills/signup/CHANGELOG.md +5 -0
  136. package/skills/signup/SKILL.md +7 -3
  137. package/skills/site-architecture/CHANGELOG.md +5 -0
  138. package/skills/site-architecture/SKILL.md +5 -4
  139. package/skills/site-architecture/evals/evals.json +25 -0
  140. package/skills/site-architecture/references/platforms-and-migration.md +84 -0
  141. package/skills/social/CHANGELOG.md +5 -0
  142. package/skills/social/SKILL.md +4 -4
  143. package/skills/trends/.skillignore +3 -0
  144. package/skills/trends/CHANGELOG.md +20 -0
  145. package/skills/trends/SKILL.md +12 -8
  146. package/skills/trends/references/agent-mode.md +36 -0
  147. package/skills/trends/references/category-peers.md +38 -0
  148. package/skills/trends/references/comparison.md +227 -0
  149. package/skills/trends/references/competitors.md +29 -0
  150. package/skills/trends/references/discovery.md +90 -0
  151. package/skills/trends/references/engine-contract.md +111 -1889
  152. package/skills/trends/references/followups.md +112 -0
  153. package/skills/trends/references/grok-bot-x.md +55 -0
  154. package/skills/trends/references/hiring-signals.md +19 -0
  155. package/skills/trends/references/library-queue.md +33 -0
  156. package/skills/trends/references/recommendations.md +86 -0
  157. package/skills/trends/references/research-runbook.md +781 -0
  158. package/skills/trends/references/runtime.md +165 -0
  159. package/skills/trends/references/save-html-brief.md +39 -20
  160. package/skills/trends/references/setup-wizard.md +253 -0
  161. package/skills/trends/references/synthesis.md +289 -0
  162. package/skills/trends/scripts/box_chrome_login.py +209 -0
  163. package/skills/trends/scripts/briefing.py +9 -17
  164. package/skills/trends/scripts/last30days.py +3247 -280
  165. package/skills/trends/scripts/lib/agentcookie.py +196 -0
  166. package/skills/trends/scripts/lib/amazon.py +877 -0
  167. package/skills/trends/scripts/lib/arxiv.py +42 -8
  168. package/skills/trends/scripts/lib/backends.py +769 -0
  169. package/skills/trends/scripts/lib/bird_x.py +340 -57
  170. package/skills/trends/scripts/lib/bluesky.py +84 -10
  171. package/skills/trends/scripts/lib/bounded_get.py +156 -0
  172. package/skills/trends/scripts/lib/brightdata.py +291 -0
  173. package/skills/trends/scripts/lib/categories.py +7 -1
  174. package/skills/trends/scripts/lib/chrome_cdp.py +455 -0
  175. package/skills/trends/scripts/lib/chrome_cookies.py +132 -39
  176. package/skills/trends/scripts/lib/cluster.py +31 -54
  177. package/skills/trends/scripts/lib/competitors.py +10 -1
  178. package/skills/trends/scripts/lib/cookie_extract.py +221 -44
  179. package/skills/trends/scripts/lib/cookie_paths.py +27 -0
  180. package/skills/trends/scripts/lib/corpus.py +439 -0
  181. package/skills/trends/scripts/lib/devto.py +313 -0
  182. package/skills/trends/scripts/lib/discovery_handoff.py +988 -0
  183. package/skills/trends/scripts/lib/doctor.py +2166 -0
  184. package/skills/trends/scripts/lib/dripstack.py +203 -0
  185. package/skills/trends/scripts/lib/entity_extract.py +37 -0
  186. package/skills/trends/scripts/lib/env.py +1092 -138
  187. package/skills/trends/scripts/lib/fanout.py +11 -1
  188. package/skills/trends/scripts/lib/feed.py +61 -0
  189. package/skills/trends/scripts/lib/fetch_memo.py +135 -0
  190. package/skills/trends/scripts/lib/freshness.py +566 -0
  191. package/skills/trends/scripts/lib/fusion.py +314 -28
  192. package/skills/trends/scripts/lib/github.py +438 -24
  193. package/skills/trends/scripts/lib/grok_x.py +1237 -0
  194. package/skills/trends/scripts/lib/grounding.py +129 -11
  195. package/skills/trends/scripts/lib/hackernews.py +61 -4
  196. package/skills/trends/scripts/lib/health.py +373 -0
  197. package/skills/trends/scripts/lib/hosted.py +333 -0
  198. package/skills/trends/scripts/lib/html_publish.py +34 -0
  199. package/skills/trends/scripts/lib/html_render.py +169 -3
  200. package/skills/trends/scripts/lib/http.py +1215 -70
  201. package/skills/trends/scripts/lib/instagram.py +132 -9
  202. package/skills/trends/scripts/lib/jobs.py +4 -2
  203. package/skills/trends/scripts/lib/library.py +308 -0
  204. package/skills/trends/scripts/lib/library_index.py +477 -0
  205. package/skills/trends/scripts/lib/linkedin.py +271 -24
  206. package/skills/trends/scripts/lib/log.py +5 -2
  207. package/skills/trends/scripts/lib/meta_ads.py +982 -0
  208. package/skills/trends/scripts/lib/normalize.py +382 -28
  209. package/skills/trends/scripts/lib/parallel_mcp.py +234 -0
  210. package/skills/trends/scripts/lib/permission_preflight.py +42 -3
  211. package/skills/trends/scripts/lib/perplexity.py +1080 -468
  212. package/skills/trends/scripts/lib/pipeline.py +5079 -211
  213. package/skills/trends/scripts/lib/planner.py +396 -53
  214. package/skills/trends/scripts/lib/polymarket.py +285 -23
  215. package/skills/trends/scripts/lib/prescriptions.py +432 -0
  216. package/skills/trends/scripts/lib/private_files.py +21 -0
  217. package/skills/trends/scripts/lib/providers.py +231 -10
  218. package/skills/trends/scripts/lib/quality_nudge.py +122 -37
  219. package/skills/trends/scripts/lib/reddit.py +177 -12
  220. package/skills/trends/scripts/lib/reddit_arctic.py +146 -5
  221. package/skills/trends/scripts/lib/reddit_keyless.py +121 -60
  222. package/skills/trends/scripts/lib/reddit_listing.py +215 -14
  223. package/skills/trends/scripts/lib/reddit_public.py +12 -13
  224. package/skills/trends/scripts/lib/reddit_search.py +304 -0
  225. package/skills/trends/scripts/lib/reddit_shreddit.py +51 -7
  226. package/skills/trends/scripts/lib/registers.py +134 -0
  227. package/skills/trends/scripts/lib/relevance.py +17 -0
  228. package/skills/trends/scripts/lib/render.py +2161 -271
  229. package/skills/trends/scripts/lib/rerank.py +623 -55
  230. package/skills/trends/scripts/lib/resolve.py +50 -1
  231. package/skills/trends/scripts/lib/safari_cookies.py +42 -15
  232. package/skills/trends/scripts/lib/schema.py +782 -1
  233. package/skills/trends/scripts/lib/setup_wizard.py +779 -120
  234. package/skills/trends/scripts/lib/signals.py +206 -7
  235. package/skills/trends/scripts/lib/stocktwits.py +374 -0
  236. package/skills/trends/scripts/lib/subproc.py +307 -53
  237. package/skills/trends/scripts/lib/techmeme.py +89 -64
  238. package/skills/trends/scripts/lib/telegram.py +317 -0
  239. package/skills/trends/scripts/lib/tiktok.py +13 -9
  240. package/skills/trends/scripts/lib/topic_shape.py +340 -0
  241. package/skills/trends/scripts/lib/transcribe.py +12 -3
  242. package/skills/trends/scripts/lib/trustpilot.py +256 -5
  243. package/skills/trends/scripts/lib/truthsocial.py +9 -1
  244. package/skills/trends/scripts/lib/ui.py +6 -0
  245. package/skills/trends/scripts/lib/usage.py +132 -0
  246. package/skills/trends/scripts/lib/web_search_keyless.py +63 -4
  247. package/skills/trends/scripts/lib/windows_job.py +138 -0
  248. package/skills/trends/scripts/lib/x_api.py +824 -0
  249. package/skills/trends/scripts/lib/x_envelope.py +733 -0
  250. package/skills/trends/scripts/lib/x_judge.py +292 -0
  251. package/skills/trends/scripts/lib/xai_x.py +26 -3
  252. package/skills/trends/scripts/lib/xquik.py +89 -15
  253. package/skills/trends/scripts/lib/xurl_x.py +284 -91
  254. package/skills/trends/scripts/lib/youtube_yt.py +570 -119
  255. package/skills/trends/scripts/setup-keychain.sh +12 -4
  256. package/skills/trends/scripts/setup-pass.sh +3 -0
  257. package/skills/trends/scripts/store.py +444 -50
  258. package/skills/trends/scripts/watchlist.py +38 -11
  259. package/skills/trends/scripts/x_lane_parity.py +107 -0
  260. package/skills/video-studio/CHANGELOG.md +4 -0
  261. package/skills/video-studio/SKILL.md +4 -4
  262. package/skills/video-studio/references/styles/product-flight.md +45 -0
  263. package/skills/video-studio/references/styles.md +3 -1
  264. package/skills/video-studio-motion/ATTRIBUTION.md +1 -1
  265. package/skills/video-studio-motion/CHANGELOG.md +4 -0
  266. package/skills/video-studio-motion/SKILL.md +8 -5
  267. package/skills/youtube-planner/ATTRIBUTION.md +25 -0
  268. package/skills/youtube-planner/CHANGELOG.md +12 -0
  269. package/skills/youtube-planner/SKILL.md +191 -0
  270. package/skills/youtube-planner/references/plan-template.md +57 -0
  271. package/skills/youtube-planner/references/stages.md +119 -0
  272. package/skills/youtube-planner/scripts/floor_ceiling.py +132 -0
  273. package/skills/coder-brainstorming/spec-document-reviewer-prompt.md +0 -49
  274. package/skills/trends/scripts/lib/reddit_rss.py +0 -224
@@ -0,0 +1,477 @@
1
+ """Offline FTS search across the saved research library and store sightings."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import os
7
+ import re
8
+ import sqlite3
9
+ from dataclasses import dataclass, replace
10
+ from datetime import date
11
+ from pathlib import Path
12
+
13
+ from . import library
14
+
15
+
16
+ DEFAULT_LIBRARY_DB = library.DEFAULT_BRIEFS_DIR.parent / "library.db"
17
+ DEFAULT_STORE_DB = library.DEFAULT_BRIEFS_DIR.parent / "research.db"
18
+ INDEX_FINGERPRINT_VERSION = "last30days-library-index/v2"
19
+ LIBRARY_CONTEXT_START = "<!-- last30days:library-context:start -->"
20
+ LIBRARY_CONTEXT_END = "<!-- last30days:library-context:end -->"
21
+ _TOKEN = re.compile(r"[^\W_]+", re.UNICODE)
22
+ _MARKED_LIBRARY_CONTEXT = re.compile(
23
+ rf"^{re.escape(LIBRARY_CONTEXT_START)}\s*$.*?"
24
+ rf"^{re.escape(LIBRARY_CONTEXT_END)}\s*$\n?",
25
+ re.MULTILINE | re.DOTALL,
26
+ )
27
+ _LEGACY_LIBRARY_CONTEXT = re.compile(
28
+ r"^## From your library\s*$.*?(?=^##\s|\Z)",
29
+ re.MULTILINE | re.DOTALL,
30
+ )
31
+ _PRIVATE_CORPUS_BLOCK = re.compile(
32
+ r"<!-- LAST30DAYS_PRIVATE_CORPUS_START -->.*?"
33
+ r"<!-- LAST30DAYS_PRIVATE_CORPUS_END -->\s*",
34
+ re.DOTALL,
35
+ )
36
+
37
+
38
+ class LibrarySearchUnavailable(RuntimeError):
39
+ """Raised when this Python SQLite build cannot provide FTS5."""
40
+
41
+
42
+ @dataclass(frozen=True, slots=True)
43
+ class LibrarySearchMatch:
44
+ topic: str
45
+ published_date: date
46
+ headline: str
47
+ snippet: str
48
+ source_kind: str
49
+ rank: float
50
+ source_path: str = ""
51
+ url: str = ""
52
+ engagement: float | None = None
53
+
54
+ @property
55
+ def run_key(self) -> tuple[str, date]:
56
+ return self.topic, self.published_date
57
+
58
+
59
+ @dataclass(frozen=True, slots=True)
60
+ class SyncResult:
61
+ indexed: int = 0
62
+ removed: int = 0
63
+ unchanged: int = 0
64
+ notes: tuple[str, ...] = ()
65
+ rebuilt: bool = False
66
+
67
+
68
+ _SCHEMA = """
69
+ CREATE TABLE IF NOT EXISTS library_documents (
70
+ entry_id TEXT PRIMARY KEY,
71
+ source_path TEXT UNIQUE NOT NULL,
72
+ source_mtime_ns INTEGER NOT NULL,
73
+ source_size INTEGER NOT NULL,
74
+ content_hash TEXT NOT NULL,
75
+ topic TEXT NOT NULL,
76
+ published_date TEXT NOT NULL,
77
+ headline TEXT NOT NULL,
78
+ summary TEXT NOT NULL,
79
+ source_format TEXT NOT NULL
80
+ );
81
+ CREATE VIRTUAL TABLE IF NOT EXISTS library_fts USING fts5(
82
+ entry_id UNINDEXED,
83
+ topic,
84
+ headline,
85
+ summary,
86
+ content,
87
+ tokenize='porter unicode61'
88
+ );
89
+ """
90
+
91
+
92
+ def fts5_available() -> bool:
93
+ try:
94
+ with sqlite3.connect(":memory:") as conn:
95
+ conn.execute("CREATE VIRTUAL TABLE probe USING fts5(value)")
96
+ except sqlite3.DatabaseError:
97
+ return False
98
+ return True
99
+
100
+
101
+ def sync_library(
102
+ memory_dir: Path | str = library.DEFAULT_MEMORY_DIR,
103
+ briefs_dir: Path | str = library.DEFAULT_BRIEFS_DIR,
104
+ *,
105
+ db_path: Path | str = DEFAULT_LIBRARY_DB,
106
+ ) -> SyncResult:
107
+ """Incrementally index the shared ``scan_library`` view of saved research."""
108
+ if not fts5_available():
109
+ raise LibrarySearchUnavailable(
110
+ "library search requires a Python SQLite build with FTS5 support"
111
+ )
112
+ target = Path(db_path).expanduser()
113
+ try:
114
+ return _sync_library(memory_dir, briefs_dir, target)
115
+ except sqlite3.DatabaseError as exc:
116
+ if "fts5" in str(exc).lower() and "malformed" not in str(exc).lower():
117
+ raise LibrarySearchUnavailable(
118
+ "library search requires a Python SQLite build with FTS5 support"
119
+ ) from exc
120
+ if not _is_confirmed_corruption(exc):
121
+ raise
122
+ _remove_database(target)
123
+ return replace(_sync_library(memory_dir, briefs_dir, target), rebuilt=True)
124
+
125
+
126
+ def index_brief(
127
+ path: Path | str,
128
+ *,
129
+ db_path: Path | str = DEFAULT_LIBRARY_DB,
130
+ ) -> bool:
131
+ """Index one saved artifact, parsing it through ``scan_library``."""
132
+ source = Path(path).expanduser().resolve()
133
+ if source.suffix.lower() == ".json":
134
+ entries, _ = library.scan_library(source.parent / ".missing", source.parent)
135
+ else:
136
+ entries, _ = library.scan_library(source.parent, source.parent / ".missing")
137
+ entry = next((item for item in entries if item.source_path.resolve() == source), None)
138
+ if entry is None:
139
+ return False
140
+ target = Path(db_path).expanduser()
141
+ _ensure_private_directory(target.parent)
142
+ with _connect(target) as conn:
143
+ _upsert_entry(conn, entry)
144
+ conn.commit()
145
+ return True
146
+
147
+
148
+ def search(
149
+ query: str,
150
+ *,
151
+ limit: int = 20,
152
+ db_path: Path | str = DEFAULT_LIBRARY_DB,
153
+ store_db_path: Path | str = DEFAULT_STORE_DB,
154
+ ) -> list[LibrarySearchMatch]:
155
+ """Search indexed briefs plus dated per-run findings from the research store."""
156
+ expression = _fts_expression(query)
157
+ if not expression or limit <= 0:
158
+ return []
159
+ target = Path(db_path).expanduser()
160
+ brief_matches: list[LibrarySearchMatch] = []
161
+ if target.is_file():
162
+ try:
163
+ with _connect(target) as conn:
164
+ rows = conn.execute(
165
+ """SELECT d.topic, d.published_date, d.headline,
166
+ snippet(library_fts, 4, '', '', ' … ', 36) AS snippet,
167
+ d.source_path, bm25(library_fts) AS rank
168
+ FROM library_fts
169
+ JOIN library_documents d ON d.entry_id = library_fts.entry_id
170
+ WHERE library_fts MATCH ?
171
+ ORDER BY rank, d.published_date DESC
172
+ LIMIT ?""",
173
+ (expression, limit),
174
+ ).fetchall()
175
+ except sqlite3.DatabaseError:
176
+ rows = []
177
+ brief_matches = [
178
+ LibrarySearchMatch(
179
+ topic=str(row["topic"]),
180
+ published_date=date.fromisoformat(str(row["published_date"])),
181
+ headline=str(row["headline"]),
182
+ snippet=_clean_snippet(row["snippet"]),
183
+ source_kind="brief",
184
+ rank=float(row["rank"]),
185
+ source_path=str(row["source_path"]),
186
+ )
187
+ for row in rows
188
+ ]
189
+ store_matches = _search_store_sightings(
190
+ expression, Path(store_db_path).expanduser(), limit
191
+ )
192
+ return _merge_ranked_matches([brief_matches, store_matches], limit=limit)
193
+
194
+
195
+ def sync_and_search(
196
+ query: str,
197
+ *,
198
+ memory_dir: Path | str = library.DEFAULT_MEMORY_DIR,
199
+ briefs_dir: Path | str = library.DEFAULT_BRIEFS_DIR,
200
+ db_path: Path | str = DEFAULT_LIBRARY_DB,
201
+ store_db_path: Path | str = DEFAULT_STORE_DB,
202
+ limit: int = 20,
203
+ ) -> tuple[list[LibrarySearchMatch], SyncResult]:
204
+ synced = sync_library(memory_dir, briefs_dir, db_path=db_path)
205
+ return search(
206
+ query,
207
+ limit=limit,
208
+ db_path=db_path,
209
+ store_db_path=store_db_path,
210
+ ), synced
211
+
212
+
213
+ def _sync_library(
214
+ memory_dir: Path | str,
215
+ briefs_dir: Path | str,
216
+ db_path: Path,
217
+ ) -> SyncResult:
218
+ entries, notes = library.scan_library(memory_dir, briefs_dir)
219
+ _ensure_private_directory(db_path.parent)
220
+ indexed = unchanged = 0
221
+ with _connect(db_path) as conn:
222
+ existing = {
223
+ row["entry_id"]: (row["source_mtime_ns"], row["source_size"], row["content_hash"])
224
+ for row in conn.execute(
225
+ "SELECT entry_id, source_mtime_ns, source_size, content_hash FROM library_documents"
226
+ )
227
+ }
228
+ current_ids: set[str] = set()
229
+ # If the FTS table was lost or recreated empty while library_documents
230
+ # survived, the fingerprint check alone would mark everything unchanged
231
+ # and searches would silently return nothing. Verify row counts agree
232
+ # before trusting fingerprints.
233
+ fts_rows = conn.execute("SELECT count(*) FROM library_fts").fetchone()[0]
234
+ fts_trustworthy = fts_rows >= len(existing) if existing else True
235
+ for entry in entries:
236
+ current_ids.add(entry.entry_id)
237
+ stat = entry.source_path.stat()
238
+ fingerprint = _fingerprint(_indexable_content(entry.content))
239
+ if fts_trustworthy and existing.get(entry.entry_id) == (
240
+ stat.st_mtime_ns, stat.st_size, fingerprint
241
+ ):
242
+ unchanged += 1
243
+ continue
244
+ _upsert_entry(conn, entry, fingerprint=fingerprint)
245
+ indexed += 1
246
+ stale_ids = set(existing) - current_ids
247
+ for entry_id in stale_ids:
248
+ conn.execute("DELETE FROM library_fts WHERE entry_id = ?", (entry_id,))
249
+ conn.execute("DELETE FROM library_documents WHERE entry_id = ?", (entry_id,))
250
+ conn.commit()
251
+ return SyncResult(
252
+ indexed=indexed,
253
+ removed=len(stale_ids),
254
+ unchanged=unchanged,
255
+ notes=tuple(notes),
256
+ )
257
+
258
+
259
+ def _connect(path: Path) -> sqlite3.Connection:
260
+ _ensure_private_directory(path.parent)
261
+ if not path.exists():
262
+ try:
263
+ fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
264
+ except FileExistsError:
265
+ pass
266
+ else:
267
+ os.close(fd)
268
+ path.chmod(0o600)
269
+ conn = sqlite3.connect(str(path))
270
+ try:
271
+ conn.row_factory = sqlite3.Row
272
+ conn.execute("PRAGMA busy_timeout=5000")
273
+ conn.executescript(_SCHEMA)
274
+ except Exception:
275
+ conn.close()
276
+ raise
277
+ return conn
278
+
279
+
280
+ def _upsert_entry(
281
+ conn: sqlite3.Connection,
282
+ entry: library.LibraryEntry,
283
+ *,
284
+ fingerprint: str | None = None,
285
+ ) -> None:
286
+ stat = entry.source_path.stat()
287
+ private_free_content = _PRIVATE_CORPUS_BLOCK.sub("", entry.content)
288
+ indexed_content = _indexable_content(private_free_content)
289
+ headline = entry.headline
290
+ summary = entry.summary
291
+ if private_free_content != entry.content and entry.source_format == "markdown":
292
+ headline = library._markdown_headline(private_free_content) or entry.topic
293
+ summary = library._markdown_summary(private_free_content) or headline
294
+ content_hash = fingerprint or _fingerprint(indexed_content)
295
+ source_path = str(entry.source_path.resolve())
296
+ replaced = conn.execute(
297
+ "SELECT entry_id FROM library_documents WHERE source_path = ? AND entry_id != ?",
298
+ (source_path, entry.entry_id),
299
+ ).fetchall()
300
+ for row in replaced:
301
+ conn.execute("DELETE FROM library_fts WHERE entry_id = ?", (row["entry_id"],))
302
+ conn.execute("DELETE FROM library_documents WHERE entry_id = ?", (row["entry_id"],))
303
+ conn.execute("DELETE FROM library_fts WHERE entry_id = ?", (entry.entry_id,))
304
+ conn.execute(
305
+ """INSERT INTO library_documents
306
+ (entry_id, source_path, source_mtime_ns, source_size, content_hash,
307
+ topic, published_date, headline, summary, source_format)
308
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
309
+ ON CONFLICT(entry_id) DO UPDATE SET
310
+ source_path=excluded.source_path,
311
+ source_mtime_ns=excluded.source_mtime_ns,
312
+ source_size=excluded.source_size,
313
+ content_hash=excluded.content_hash,
314
+ topic=excluded.topic,
315
+ published_date=excluded.published_date,
316
+ headline=excluded.headline,
317
+ summary=excluded.summary,
318
+ source_format=excluded.source_format""",
319
+ (
320
+ entry.entry_id,
321
+ source_path,
322
+ stat.st_mtime_ns,
323
+ stat.st_size,
324
+ content_hash,
325
+ entry.topic,
326
+ entry.published_date.isoformat(),
327
+ headline,
328
+ summary,
329
+ entry.source_format,
330
+ ),
331
+ )
332
+ conn.execute(
333
+ "INSERT INTO library_fts(entry_id, topic, headline, summary, content) VALUES (?, ?, ?, ?, ?)",
334
+ (entry.entry_id, entry.topic, headline, summary, indexed_content),
335
+ )
336
+
337
+
338
+ def _search_store_sightings(
339
+ expression: str,
340
+ store_db_path: Path,
341
+ limit: int,
342
+ ) -> list[LibrarySearchMatch]:
343
+ if not store_db_path.is_file():
344
+ return []
345
+ try:
346
+ with sqlite3.connect(str(store_db_path)) as conn:
347
+ conn.row_factory = sqlite3.Row
348
+ rows = conn.execute(
349
+ """SELECT t.name AS topic, rr.run_date,
350
+ COALESCE(fs.source_title, f.source_title, f.summary) AS headline,
351
+ snippet(findings_fts, 0, '', '', ' … ', 30) AS snippet,
352
+ fs.source_url, fs.engagement_score, bm25(findings_fts) AS rank
353
+ FROM findings_fts
354
+ JOIN findings f ON f.id = findings_fts.rowid
355
+ JOIN finding_sightings fs ON fs.finding_id = f.id
356
+ JOIN research_runs rr ON rr.id = fs.run_id
357
+ JOIN topics t ON t.id = fs.topic_id
358
+ WHERE findings_fts MATCH ? AND rr.status = 'completed'
359
+ AND fs.source != 'corpus'
360
+ ORDER BY rank, rr.run_date DESC
361
+ LIMIT ?""",
362
+ (expression, limit),
363
+ ).fetchall()
364
+ except (sqlite3.DatabaseError, OSError):
365
+ return []
366
+ matches: list[LibrarySearchMatch] = []
367
+ for row in rows:
368
+ try:
369
+ published = date.fromisoformat(str(row["run_date"])[:10])
370
+ except ValueError:
371
+ continue
372
+ matches.append(
373
+ LibrarySearchMatch(
374
+ topic=str(row["topic"]),
375
+ published_date=published,
376
+ headline=str(row["headline"] or "Saved finding"),
377
+ snippet=_clean_snippet(row["snippet"]),
378
+ source_kind="store",
379
+ rank=float(row["rank"]),
380
+ url=str(row["source_url"] or ""),
381
+ engagement=(
382
+ float(row["engagement_score"])
383
+ if row["engagement_score"] is not None
384
+ else None
385
+ ),
386
+ )
387
+ )
388
+ return matches
389
+
390
+
391
+ def _fts_expression(query: str) -> str:
392
+ tokens = _TOKEN.findall(query)
393
+ return " AND ".join(f'"{token.replace(chr(34), chr(34) * 2)}"' for token in tokens)
394
+
395
+
396
+ def _fingerprint(content: str) -> str:
397
+ payload = f"{INDEX_FINGERPRINT_VERSION}\0{content}"
398
+ return hashlib.sha256(payload.encode("utf-8")).hexdigest()
399
+
400
+
401
+ def _clean_snippet(value: object) -> str:
402
+ return re.sub(r"\s+", " ", str(value or "")).strip()[:500]
403
+
404
+
405
+ def _indexable_content(content: str) -> str:
406
+ without_private = _PRIVATE_CORPUS_BLOCK.sub("", content)
407
+ without_marked = _MARKED_LIBRARY_CONTEXT.sub("", without_private)
408
+ return _LEGACY_LIBRARY_CONTEXT.sub("", without_marked)
409
+
410
+
411
+ def _ensure_private_directory(path: Path) -> None:
412
+ missing: list[Path] = []
413
+ current = path
414
+ while not current.exists():
415
+ missing.append(current)
416
+ current = current.parent
417
+ path.mkdir(parents=True, exist_ok=True, mode=0o700)
418
+ for directory in missing:
419
+ directory.chmod(0o700)
420
+
421
+
422
+ def _is_confirmed_corruption(exc: sqlite3.DatabaseError) -> bool:
423
+ message = str(exc).casefold()
424
+ return any(
425
+ marker in message
426
+ for marker in (
427
+ "file is not a database",
428
+ "database disk image is malformed",
429
+ "database schema is corrupt",
430
+ "malformed database schema",
431
+ )
432
+ )
433
+
434
+
435
+ def _merge_ranked_matches(
436
+ corpora: list[list[LibrarySearchMatch]],
437
+ *,
438
+ limit: int,
439
+ ) -> list[LibrarySearchMatch]:
440
+ normalized: list[LibrarySearchMatch] = []
441
+ for matches in corpora:
442
+ for position, match in enumerate(matches, start=1):
443
+ normalized.append(replace(match, rank=-(1.0 / (60 + position))))
444
+ combined = _dedupe_matches(normalized)
445
+ return sorted(
446
+ combined,
447
+ key=lambda match: (
448
+ match.rank,
449
+ -match.published_date.toordinal(),
450
+ match.topic.casefold(),
451
+ match.headline.casefold(),
452
+ ),
453
+ )[:limit]
454
+
455
+
456
+ def _dedupe_matches(matches: list[LibrarySearchMatch]) -> list[LibrarySearchMatch]:
457
+ seen: set[tuple[str, date, str, str]] = set()
458
+ kept: list[LibrarySearchMatch] = []
459
+ for match in matches:
460
+ key = (
461
+ match.topic.casefold(),
462
+ match.published_date,
463
+ match.headline.casefold(),
464
+ match.source_kind,
465
+ )
466
+ if key not in seen:
467
+ seen.add(key)
468
+ kept.append(match)
469
+ return kept
470
+
471
+
472
+ def _remove_database(path: Path) -> None:
473
+ for candidate in (path, Path(f"{path}-wal"), Path(f"{path}-shm")):
474
+ try:
475
+ candidate.unlink()
476
+ except FileNotFoundError:
477
+ pass