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,439 @@
1
+ """Deterministic, local-only document corpus source.
2
+
3
+ The corpus adapter deliberately has no HTTP dependency. It scans explicitly
4
+ registered directories, extracts small text documents (and PDFs only when the
5
+ local ``pdftotext`` binary is available), and returns normalized ``SourceItem``
6
+ objects for the shared relevance/fusion pipeline.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import json
13
+ import os
14
+ import subprocess
15
+ import threading
16
+ from dataclasses import dataclass, field
17
+ from datetime import datetime, timezone
18
+ from pathlib import Path
19
+ from shutil import which
20
+ from typing import Any, Iterable
21
+
22
+ from . import entity_extract, log, relevance, schema
23
+
24
+ SOURCE = "corpus"
25
+ SUPPORTED_SUFFIXES = {".md", ".txt", ".pdf"}
26
+ IGNORED_DIRECTORIES = {".git", "node_modules"}
27
+ MAX_FILES = 500
28
+ MAX_TEXT_CHARS = 1_000_000
29
+ MAX_CACHE_TEXT_CHARS = MAX_TEXT_CHARS
30
+ MAX_CACHE_BYTES = 50 * 1024 * 1024
31
+ MAX_CACHE_ENTRIES = 2_000
32
+ CACHE_FILENAME = "corpus-cache.json"
33
+ CACHE_SCHEMA_VERSION = "last30days-corpus-cache/v2"
34
+
35
+ _CACHE_LOCK = threading.Lock()
36
+
37
+
38
+ @dataclass
39
+ class CorpusScanResult:
40
+ """One bounded scan, including non-fatal extraction notes."""
41
+
42
+ items: list[schema.SourceItem]
43
+ notes: list[str] = field(default_factory=list)
44
+ files_scanned: int = 0
45
+ cache_hits: int = 0
46
+
47
+
48
+ def resolve_directories(
49
+ cli_directories: Iterable[str] | None,
50
+ configured: str | Iterable[str] | None,
51
+ ) -> list[Path]:
52
+ """Merge repeatable CLI paths with ``os.pathsep``-separated config paths."""
53
+ raw: list[str] = [str(value) for value in (cli_directories or []) if str(value).strip()]
54
+ if isinstance(configured, str):
55
+ raw.extend(value for value in configured.split(os.pathsep) if value.strip())
56
+ elif configured:
57
+ raw.extend(str(value) for value in configured if str(value).strip())
58
+
59
+ resolved: list[Path] = []
60
+ seen: set[str] = set()
61
+ for value in raw:
62
+ path = Path(value.strip()).expanduser().resolve()
63
+ key = os.path.normcase(str(path))
64
+ if key in seen:
65
+ continue
66
+ seen.add(key)
67
+ resolved.append(path)
68
+ return resolved
69
+
70
+
71
+ def _safe_error(exc: BaseException) -> str:
72
+ """Describe an error without str(exc), which embeds absolute paths.
73
+
74
+ These notes travel into source_status detail and render in coverage
75
+ diagnostics outside the private corpus block.
76
+ """
77
+ reason = getattr(exc, "strerror", None)
78
+ return str(reason) if reason else exc.__class__.__name__
79
+
80
+
81
+ def search(
82
+ topic: str,
83
+ directories: Iterable[Path | str],
84
+ *,
85
+ from_date: str,
86
+ to_date: str,
87
+ all_time: bool = False,
88
+ limit: int = 12,
89
+ cache_dir: Path | None = None,
90
+ ) -> CorpusScanResult:
91
+ """Search registered directories without making any network calls."""
92
+ roots = resolve_directories([str(path) for path in directories], None)
93
+ notes: list[str] = []
94
+ cache_path = cache_dir / CACHE_FILENAME if cache_dir is not None else None
95
+ with _CACHE_LOCK:
96
+ cache = _load_cache(cache_path)
97
+ cache_entries = cache.setdefault("entries", {})
98
+ cache_entry_sizes = {
99
+ path: _cache_entry_fragment_size(path, value)
100
+ for path, value in cache_entries.items()
101
+ }
102
+
103
+ candidates: list[tuple[float, int, schema.SourceItem]] = []
104
+ seen_files: set[str] = set()
105
+ files_scanned = 0
106
+ cache_hits = 0
107
+ pdf_available = which("pdftotext")
108
+ pdf_unavailable_noted = False
109
+
110
+ readable_roots: list[Path] = []
111
+ for root in roots:
112
+ if not root.is_dir():
113
+ notes.append(f"Skipped corpus root '{Path(root).name}': not a readable directory")
114
+ continue
115
+ readable_roots.append(root)
116
+
117
+ per_root_limit, extra_slots = divmod(MAX_FILES, len(readable_roots) or 1)
118
+ scan_limit_reached = False
119
+ for root_index, root in enumerate(readable_roots):
120
+ root_limit = per_root_limit + (1 if root_index < extra_slots else 0)
121
+ root_files_scanned = 0
122
+ for path in _iter_files(root, notes=notes):
123
+ if root_files_scanned >= root_limit:
124
+ scan_limit_reached = True
125
+ break
126
+ key = os.path.normcase(str(path))
127
+ if key in seen_files:
128
+ continue
129
+ seen_files.add(key)
130
+ root_files_scanned += 1
131
+ files_scanned += 1
132
+
133
+ try:
134
+ stat = path.stat()
135
+ except OSError as exc:
136
+ notes.append(f"Skipped {_display_path(path, root)}: {_safe_error(exc)}")
137
+ continue
138
+ published_at = datetime.fromtimestamp(
139
+ stat.st_mtime, tz=timezone.utc
140
+ ).date().isoformat()
141
+ if not all_time and not (from_date <= published_at <= to_date):
142
+ continue
143
+
144
+ cached = cache_entries.get(str(path))
145
+ if (
146
+ isinstance(cached, dict)
147
+ and cached.get("mtime_ns") == stat.st_mtime_ns
148
+ and cached.get("size") == stat.st_size
149
+ and isinstance(cached.get("text"), str)
150
+ ):
151
+ text = cached["text"]
152
+ cache_hits += 1
153
+ else:
154
+ if path.suffix.lower() == ".pdf" and not pdf_available:
155
+ if not pdf_unavailable_noted:
156
+ notes.append("Skipped PDF files because pdftotext is not on PATH")
157
+ pdf_unavailable_noted = True
158
+ continue
159
+ try:
160
+ text = _extract_text(path, pdftotext=pdf_available)
161
+ except (OSError, subprocess.SubprocessError) as exc:
162
+ notes.append(f"Skipped {_display_path(path, root)}: {_safe_error(exc)}")
163
+ continue
164
+ _cache_entry_put(cache_entries, cache_entry_sizes, str(path), {
165
+ "mtime_ns": stat.st_mtime_ns,
166
+ "size": stat.st_size,
167
+ "text": text[:MAX_CACHE_TEXT_CHARS],
168
+ })
169
+
170
+ title = _path_title(path)
171
+ score = _match_score(topic, f"{title}\n{text}")
172
+ if score < 0.15:
173
+ continue
174
+ relative_path = str(path.relative_to(root))
175
+ path_digest = hashlib.sha256(str(path).encode("utf-8")).hexdigest()
176
+ item = schema.SourceItem(
177
+ item_id=f"C{path_digest[:12]}",
178
+ source=SOURCE,
179
+ title=title,
180
+ body=text,
181
+ url=f"corpus://{path_digest}",
182
+ container=str(path.parent),
183
+ published_at=published_at,
184
+ date_confidence="high",
185
+ relevance_hint=score,
186
+ why_relevant=f"Matched local file {relative_path}",
187
+ # Leave empty so extract_best_snippet derives the matching
188
+ # window; a file-prefix snippet is preserved verbatim and can
189
+ # show unrelated intro text (and draw entity-miss demotion).
190
+ snippet="",
191
+ metadata={
192
+ "path": str(path),
193
+ "relative_path": relative_path,
194
+ "extension": path.suffix.lower(),
195
+ "local_only": True,
196
+ },
197
+ )
198
+ candidates.append((score, stat.st_mtime_ns, item))
199
+ if scan_limit_reached:
200
+ notes.append(f"Stopped after the {MAX_FILES}-file corpus scan limit")
201
+
202
+ cache["schema_version"] = CACHE_SCHEMA_VERSION
203
+ cache["entries"] = _bounded_entries(cache_entries)
204
+ with _CACHE_LOCK:
205
+ _write_cache(cache_path, cache, notes)
206
+
207
+ candidates.sort(key=lambda row: (-row[0], -row[1], row[2].title.casefold()))
208
+ items = [item for _score, _mtime, item in candidates[: max(0, limit)]]
209
+ log.source_log(
210
+ "Corpus",
211
+ f"scanned {files_scanned} file(s), {cache_hits} cache hit(s), {len(items)} match(es)",
212
+ tty_only=False,
213
+ )
214
+ return CorpusScanResult(
215
+ items=items,
216
+ notes=notes,
217
+ files_scanned=files_scanned,
218
+ cache_hits=cache_hits,
219
+ )
220
+
221
+
222
+ def _display_path(path: Path | str, root: Path | None = None) -> str:
223
+ """Render a note-safe path: never the absolute local path.
224
+
225
+ Corpus notes flow into source_status detail and the Partial Coverage
226
+ block, which render OUTSIDE the private corpus markers - an absolute
227
+ path like /home/user/private/notes/foo.md must not escape there.
228
+ """
229
+ candidate = Path(path)
230
+ if root is not None:
231
+ try:
232
+ return str(Path(root).name / candidate.relative_to(root))
233
+ except ValueError:
234
+ pass
235
+ return candidate.name
236
+
237
+
238
+ def _iter_files(root: Path, notes: list[str] | None = None) -> Iterable[Path]:
239
+ # Bounded newest-first selection: keep only the newest MAX_FILES paths in a
240
+ # heap while walking, so registering a huge tree does not materialize every
241
+ # path before the caller's extraction cap applies.
242
+ import heapq
243
+
244
+ heap: list[tuple[int, str]] = []
245
+ walk_errors = 0
246
+
247
+ def _on_walk_error(error: OSError) -> None:
248
+ nonlocal walk_errors
249
+ walk_errors += 1
250
+ if notes is not None and walk_errors <= 3:
251
+ unreadable = _display_path(error.filename, root) if error.filename else Path(root).name
252
+ notes.append(f"corpus: could not read {unreadable}: {error.strerror}")
253
+
254
+ for current, directory_names, file_names in os.walk(
255
+ root, followlinks=False, onerror=_on_walk_error
256
+ ):
257
+ directory_names[:] = sorted(
258
+ name
259
+ for name in directory_names
260
+ if name not in IGNORED_DIRECTORIES and not name.startswith(".")
261
+ )
262
+ current_path = Path(current)
263
+ for name in sorted(file_names):
264
+ if name.startswith("."):
265
+ continue
266
+ path = current_path / name
267
+ if path.suffix.lower() in SUPPORTED_SUFFIXES and not path.is_symlink():
268
+ entry = (_safe_mtime_ns(path), str(path))
269
+ if len(heap) < MAX_FILES:
270
+ heapq.heappush(heap, entry)
271
+ else:
272
+ heapq.heappushpop(heap, entry)
273
+ if notes is not None and walk_errors > 3:
274
+ notes.append(f"corpus: {walk_errors - 3} more unreadable directories suppressed")
275
+ ordered = sorted(heap, key=lambda item: (-item[0], item[1].casefold()))
276
+ for _mtime, raw_path in ordered:
277
+ yield Path(raw_path)
278
+
279
+
280
+ def _safe_mtime_ns(path: Path) -> int:
281
+ try:
282
+ return path.stat().st_mtime_ns
283
+ except OSError:
284
+ return 0
285
+
286
+
287
+ def _extract_text(path: Path, *, pdftotext: str | None) -> str:
288
+ if path.suffix.lower() == ".pdf":
289
+ if not pdftotext:
290
+ return ""
291
+ completed = subprocess.run(
292
+ [pdftotext, str(path), "-"],
293
+ capture_output=True,
294
+ check=True,
295
+ text=True,
296
+ timeout=20,
297
+ )
298
+ return completed.stdout[:MAX_TEXT_CHARS]
299
+ with path.open("r", encoding="utf-8", errors="replace") as handle:
300
+ return handle.read(MAX_TEXT_CHARS)
301
+
302
+
303
+ def _path_title(path: Path) -> str:
304
+ title = path.stem.replace("_", " ").replace("-", " ")
305
+ return " ".join(title.split()) or path.name
306
+
307
+
308
+ def _match_score(topic: str, text: str) -> float:
309
+ lexical = relevance.token_overlap_relevance(topic, text)
310
+ topic_entities = entity_extract.extract_text_entities(topic)
311
+ text_entities = entity_extract.extract_text_entities(text)
312
+ entity_score = entity_extract.entity_overlap(topic_entities, text_entities)
313
+ return round(max(lexical, entity_score * 0.9), 4)
314
+
315
+
316
+ def _load_cache(path: Path | None) -> dict[str, Any]:
317
+ if path is None:
318
+ return {"schema_version": CACHE_SCHEMA_VERSION, "entries": {}}
319
+ try:
320
+ if path.stat().st_size > MAX_CACHE_BYTES:
321
+ return {"schema_version": CACHE_SCHEMA_VERSION, "entries": {}}
322
+ payload = json.loads(path.read_text(encoding="utf-8"))
323
+ except (OSError, UnicodeError, json.JSONDecodeError):
324
+ return {"schema_version": CACHE_SCHEMA_VERSION, "entries": {}}
325
+ if not isinstance(payload, dict) or payload.get("schema_version") != CACHE_SCHEMA_VERSION:
326
+ return {"schema_version": CACHE_SCHEMA_VERSION, "entries": {}}
327
+ if not isinstance(payload.get("entries"), dict):
328
+ payload["entries"] = {}
329
+ payload["entries"] = _bounded_entries(payload["entries"])
330
+ return payload
331
+
332
+
333
+ def _bounded_entries(entries: Any) -> dict[str, Any]:
334
+ if not isinstance(entries, dict):
335
+ return {}
336
+ ordered = sorted(
337
+ (
338
+ (path, value)
339
+ for path, value in entries.items()
340
+ if (
341
+ isinstance(path, str)
342
+ and isinstance(value, dict)
343
+ and isinstance(value.get("text"), str)
344
+ )
345
+ ),
346
+ key=lambda row: int(row[1].get("mtime_ns") or 0),
347
+ reverse=True,
348
+ )
349
+ base_bytes = len(
350
+ json.dumps(
351
+ {"schema_version": CACHE_SCHEMA_VERSION, "entries": {}},
352
+ ensure_ascii=False,
353
+ ).encode("utf-8")
354
+ )
355
+ used_bytes = base_bytes
356
+ bounded: dict[str, Any] = {}
357
+ for path, value in ordered[:MAX_CACHE_ENTRIES]:
358
+ normalized = {
359
+ "mtime_ns": value.get("mtime_ns"),
360
+ "size": value.get("size"),
361
+ "text": value["text"][:MAX_CACHE_TEXT_CHARS],
362
+ }
363
+ fragment = json.dumps({path: normalized}, ensure_ascii=False).encode("utf-8")
364
+ fragment_bytes = len(fragment) - 2 + (2 if bounded else 0)
365
+ if used_bytes + fragment_bytes > MAX_CACHE_BYTES:
366
+ continue
367
+ bounded[path] = normalized
368
+ used_bytes += fragment_bytes
369
+ return bounded
370
+
371
+
372
+ def _cache_entry_fragment_size(path: str, value: dict[str, Any]) -> int:
373
+ return len(json.dumps({path: value}, ensure_ascii=False).encode("utf-8")) - 2
374
+
375
+
376
+ def _cache_entry_put(
377
+ entries: dict[str, Any],
378
+ sizes: dict[str, int],
379
+ path: str,
380
+ value: dict[str, Any],
381
+ ) -> None:
382
+ entries[path] = value
383
+ sizes[path] = _cache_entry_fragment_size(path, value)
384
+ while (
385
+ len(entries) > MAX_CACHE_ENTRIES
386
+ or _cache_payload_size(sizes) > MAX_CACHE_BYTES
387
+ ):
388
+ oldest = min(
389
+ entries,
390
+ key=lambda candidate: (
391
+ int(entries[candidate].get("mtime_ns") or 0),
392
+ candidate,
393
+ ),
394
+ )
395
+ del entries[oldest]
396
+ del sizes[oldest]
397
+
398
+
399
+ def _cache_payload_size(sizes: dict[str, int]) -> int:
400
+ base_bytes = len(
401
+ json.dumps(
402
+ {"schema_version": CACHE_SCHEMA_VERSION, "entries": {}},
403
+ ensure_ascii=False,
404
+ ).encode("utf-8")
405
+ )
406
+ separators = max(0, len(sizes) - 1) * 2
407
+ return base_bytes + sum(sizes.values()) + separators
408
+
409
+
410
+ def _write_cache(path: Path | None, payload: dict[str, Any], notes: list[str]) -> None:
411
+ if path is None:
412
+ return
413
+ try:
414
+ _ensure_private_directory(path.parent)
415
+ payload["entries"] = _bounded_entries(payload.get("entries", {}))
416
+ encoded = json.dumps(payload, ensure_ascii=False).encode("utf-8")
417
+ temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
418
+ try:
419
+ fd = os.open(temporary, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
420
+ except FileExistsError:
421
+ temporary.unlink()
422
+ fd = os.open(temporary, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
423
+ with os.fdopen(fd, "wb") as handle:
424
+ handle.write(encoded)
425
+ temporary.replace(path)
426
+ path.chmod(0o600)
427
+ except OSError as exc:
428
+ notes.append(f"Corpus cache unavailable: {_safe_error(exc)}")
429
+
430
+
431
+ def _ensure_private_directory(path: Path) -> None:
432
+ missing: list[Path] = []
433
+ current = path
434
+ while not current.exists():
435
+ missing.append(current)
436
+ current = current.parent
437
+ path.mkdir(parents=True, exist_ok=True, mode=0o700)
438
+ for directory in missing:
439
+ directory.chmod(0o700)