luckiest-co 1.0.47 → 1.0.49
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/package.json +1 -1
- package/skills/ab-testing/CHANGELOG.md +5 -0
- package/skills/ab-testing/SKILL.md +12 -9
- package/skills/ab-testing/evals/evals.json +18 -7
- package/skills/ab-testing/references/sample-size-guide.md +2 -2
- package/skills/ads/CHANGELOG.md +5 -0
- package/skills/ads/SKILL.md +4 -4
- package/skills/ai-seo/CHANGELOG.md +7 -0
- package/skills/ai-seo/SKILL.md +27 -20
- package/skills/analytics/CHANGELOG.md +6 -0
- package/skills/analytics/SKILL.md +4 -3
- package/skills/analytics/evals/evals.json +27 -0
- package/skills/analytics/references/ga4-implementation.md +7 -3
- package/skills/analytics/references/gtm-implementation.md +8 -2
- package/skills/aso/CHANGELOG.md +5 -0
- package/skills/aso/SKILL.md +4 -4
- package/skills/aso/evals/evals.json +15 -2
- package/skills/aso/references/benchmarks.md +4 -2
- package/skills/aso/references/google-play-specs.md +5 -1
- package/skills/brainstorm/SKILL.md +59 -16
- package/skills/brainstorm/builder-check-prompt.md +23 -0
- package/skills/churn-prevention/CHANGELOG.md +5 -0
- package/skills/churn-prevention/SKILL.md +3 -3
- package/skills/churn-prevention/evals/evals.json +14 -0
- package/skills/churn-prevention/references/dunning-playbook.md +6 -1
- package/skills/coder-brainstorming/CHANGELOG.md +10 -0
- package/skills/coder-brainstorming/SKILL.md +62 -20
- package/skills/coder-brainstorming/builder-check-prompt.md +23 -0
- package/skills/coder-brainstorming/scripts/server.cjs +1 -1
- package/skills/coder-debugging/CHANGELOG.md +4 -0
- package/skills/coder-debugging/SKILL.md +5 -5
- package/skills/coder-requesting-code-review/CHANGELOG.md +4 -0
- package/skills/coder-requesting-code-review/SKILL.md +4 -4
- package/skills/coder-subagent-driven-development/CHANGELOG.md +4 -0
- package/skills/coder-subagent-driven-development/SKILL.md +3 -3
- package/skills/coder-subagent-driven-development/scripts/sdd-workspace +3 -1
- package/skills/cold-email/CHANGELOG.md +7 -0
- package/skills/cold-email/SKILL.md +27 -7
- package/skills/cold-email/evals/evals.json +30 -0
- package/skills/cold-email/references/benchmarks.md +15 -11
- package/skills/cold-email/references/deliverability.md +110 -0
- package/skills/cold-email/references/linkedin-outreach.md +91 -0
- package/skills/cold-email/references/multichannel-cadence.md +79 -0
- package/skills/cold-email/references/outbound-plays.md +88 -0
- package/skills/cold-email/references/personalization.md +11 -0
- package/skills/cold-email/references/reply-handling.md +65 -0
- package/skills/competitor-profiling/CHANGELOG.md +5 -0
- package/skills/competitor-profiling/SKILL.md +4 -4
- package/skills/content-strategy/CHANGELOG.md +5 -0
- package/skills/content-strategy/SKILL.md +5 -3
- package/skills/copy-editing/CHANGELOG.md +5 -0
- package/skills/copy-editing/SKILL.md +5 -5
- package/skills/copy-editing/references/content-refresh.md +33 -0
- package/skills/cro/CHANGELOG.md +5 -0
- package/skills/cro/SKILL.md +14 -3
- package/skills/cro/references/form.md +1 -1
- package/skills/directory-submissions/CHANGELOG.md +5 -0
- package/skills/directory-submissions/SKILL.md +13 -4
- package/skills/directory-submissions/evals/evals.json +15 -1
- package/skills/directory-submissions/references/directory-list.md +2 -4
- package/skills/emails/CHANGELOG.md +5 -0
- package/skills/emails/SKILL.md +14 -4
- package/skills/launch/CHANGELOG.md +7 -0
- package/skills/launch/SKILL.md +8 -4
- package/skills/launch/evals/evals.json +29 -0
- package/skills/launch/references/shipped-changes.md +84 -0
- package/skills/launch/references/site-launch-qa.md +55 -0
- package/skills/lead-magnets/CHANGELOG.md +5 -0
- package/skills/lead-magnets/SKILL.md +6 -4
- package/skills/lead-magnets/evals/evals.json +18 -0
- package/skills/lead-magnets/references/content-to-skill-corpus-design.md +89 -0
- package/skills/lead-magnets/references/content-to-skill-release.md +71 -0
- package/skills/lead-magnets/references/content-to-skill.md +108 -0
- package/skills/marketing-plan/CHANGELOG.md +5 -0
- package/skills/marketing-plan/SKILL.md +3 -3
- package/skills/marketing-plan/evals/evals.json +13 -0
- package/skills/marketing-plan/references/budget-planning.md +26 -17
- package/skills/model-router/CHANGELOG.md +6 -0
- package/skills/model-router/SKILL.md +3 -3
- package/skills/model-router/scripts/route.py +1 -1
- package/skills/model-router/scripts/test_route.py +5 -0
- package/skills/pricing/CHANGELOG.md +5 -0
- package/skills/pricing/SKILL.md +4 -4
- package/skills/pricing/evals/evals.json +12 -0
- package/skills/pricing/references/research-methods.md +8 -4
- package/skills/programmatic-seo/CHANGELOG.md +5 -0
- package/skills/programmatic-seo/SKILL.md +8 -4
- package/skills/programmatic-seo/evals/evals.json +13 -0
- package/skills/programmatic-seo/references/implementation-platforms.md +37 -0
- package/skills/prospecting/CHANGELOG.md +7 -0
- package/skills/prospecting/SKILL.md +27 -13
- package/skills/prospecting/evals/evals.json +41 -0
- package/skills/prospecting/references/account-research.md +59 -0
- package/skills/prospecting/references/b2b-prospecting.md +1 -1
- package/skills/prospecting/references/compliance.md +44 -6
- package/skills/prospecting/references/data-sources.md +13 -15
- package/skills/prospecting/references/enrichment-playbook.md +61 -0
- package/skills/prospecting/references/saas-prospecting.md +1 -1
- package/skills/prospecting/references/signal-plays.md +99 -0
- package/skills/prospecting/references/sourcing-and-enrichment.md +89 -0
- package/skills/referrals/CHANGELOG.md +5 -0
- package/skills/referrals/SKILL.md +10 -4
- package/skills/referrals/evals/evals.json +99 -0
- package/skills/referrals/references/affiliate-operations.md +64 -0
- package/skills/referrals/references/affiliate-programs.md +2 -2
- package/skills/referrals/references/program-examples.md +14 -6
- package/skills/referrals/references/review-platform-rules.md +36 -0
- package/skills/referrals/references/review-program-operations.md +62 -0
- package/skills/referrals/references/review-programs.md +96 -0
- package/skills/revops/CHANGELOG.md +5 -0
- package/skills/revops/SKILL.md +10 -6
- package/skills/revops/evals/evals.json +13 -0
- package/skills/revops/references/automation-playbooks.md +1 -1
- package/skills/revops/references/inbound-routing.md +43 -0
- package/skills/revops/references/lifecycle-definitions.md +31 -0
- package/skills/sales-enablement/CHANGELOG.md +5 -0
- package/skills/sales-enablement/SKILL.md +6 -4
- package/skills/sales-enablement/evals/evals.json +14 -0
- package/skills/sales-enablement/references/cold-call-scripts.md +68 -0
- package/skills/schema/CHANGELOG.md +5 -0
- package/skills/schema/SKILL.md +20 -8
- package/skills/schema/evals/evals.json +40 -0
- package/skills/schema/references/schema-examples.md +9 -15
- package/skills/seo-audit/CHANGELOG.md +5 -0
- package/skills/seo-audit/SKILL.md +10 -16
- package/skills/seo-audit/evals/evals.json +68 -0
- package/skills/seo-audit/references/international-seo.md +9 -11
- package/skills/seo-audit/references/local-seo-pages-and-measurement.md +39 -0
- package/skills/seo-audit/references/local-seo-profile-and-citations.md +41 -0
- package/skills/seo-audit/references/local-seo.md +86 -0
- package/skills/seo-audit/references/rankings-push.md +62 -0
- package/skills/seo-audit/references/title-tags.md +89 -0
- package/skills/signup/CHANGELOG.md +5 -0
- package/skills/signup/SKILL.md +7 -3
- package/skills/site-architecture/CHANGELOG.md +5 -0
- package/skills/site-architecture/SKILL.md +5 -4
- package/skills/site-architecture/evals/evals.json +25 -0
- package/skills/site-architecture/references/platforms-and-migration.md +84 -0
- package/skills/social/CHANGELOG.md +5 -0
- package/skills/social/SKILL.md +4 -4
- package/skills/trends/.skillignore +3 -0
- package/skills/trends/CHANGELOG.md +20 -0
- package/skills/trends/SKILL.md +12 -8
- package/skills/trends/references/agent-mode.md +36 -0
- package/skills/trends/references/category-peers.md +38 -0
- package/skills/trends/references/comparison.md +227 -0
- package/skills/trends/references/competitors.md +29 -0
- package/skills/trends/references/discovery.md +90 -0
- package/skills/trends/references/engine-contract.md +111 -1889
- package/skills/trends/references/followups.md +112 -0
- package/skills/trends/references/grok-bot-x.md +55 -0
- package/skills/trends/references/hiring-signals.md +19 -0
- package/skills/trends/references/library-queue.md +33 -0
- package/skills/trends/references/recommendations.md +86 -0
- package/skills/trends/references/research-runbook.md +781 -0
- package/skills/trends/references/runtime.md +165 -0
- package/skills/trends/references/save-html-brief.md +39 -20
- package/skills/trends/references/setup-wizard.md +253 -0
- package/skills/trends/references/synthesis.md +289 -0
- package/skills/trends/scripts/box_chrome_login.py +209 -0
- package/skills/trends/scripts/briefing.py +9 -17
- package/skills/trends/scripts/last30days.py +3247 -280
- package/skills/trends/scripts/lib/agentcookie.py +196 -0
- package/skills/trends/scripts/lib/amazon.py +877 -0
- package/skills/trends/scripts/lib/arxiv.py +42 -8
- package/skills/trends/scripts/lib/backends.py +769 -0
- package/skills/trends/scripts/lib/bird_x.py +340 -57
- package/skills/trends/scripts/lib/bluesky.py +84 -10
- package/skills/trends/scripts/lib/bounded_get.py +156 -0
- package/skills/trends/scripts/lib/brightdata.py +291 -0
- package/skills/trends/scripts/lib/categories.py +7 -1
- package/skills/trends/scripts/lib/chrome_cdp.py +455 -0
- package/skills/trends/scripts/lib/chrome_cookies.py +132 -39
- package/skills/trends/scripts/lib/cluster.py +31 -54
- package/skills/trends/scripts/lib/competitors.py +10 -1
- package/skills/trends/scripts/lib/cookie_extract.py +221 -44
- package/skills/trends/scripts/lib/cookie_paths.py +27 -0
- package/skills/trends/scripts/lib/corpus.py +439 -0
- package/skills/trends/scripts/lib/devto.py +313 -0
- package/skills/trends/scripts/lib/discovery_handoff.py +988 -0
- package/skills/trends/scripts/lib/doctor.py +2166 -0
- package/skills/trends/scripts/lib/dripstack.py +203 -0
- package/skills/trends/scripts/lib/entity_extract.py +37 -0
- package/skills/trends/scripts/lib/env.py +1092 -138
- package/skills/trends/scripts/lib/fanout.py +11 -1
- package/skills/trends/scripts/lib/feed.py +61 -0
- package/skills/trends/scripts/lib/fetch_memo.py +135 -0
- package/skills/trends/scripts/lib/freshness.py +566 -0
- package/skills/trends/scripts/lib/fusion.py +314 -28
- package/skills/trends/scripts/lib/github.py +438 -24
- package/skills/trends/scripts/lib/grok_x.py +1237 -0
- package/skills/trends/scripts/lib/grounding.py +129 -11
- package/skills/trends/scripts/lib/hackernews.py +61 -4
- package/skills/trends/scripts/lib/health.py +373 -0
- package/skills/trends/scripts/lib/hosted.py +333 -0
- package/skills/trends/scripts/lib/html_publish.py +34 -0
- package/skills/trends/scripts/lib/html_render.py +169 -3
- package/skills/trends/scripts/lib/http.py +1215 -70
- package/skills/trends/scripts/lib/instagram.py +132 -9
- package/skills/trends/scripts/lib/jobs.py +4 -2
- package/skills/trends/scripts/lib/library.py +308 -0
- package/skills/trends/scripts/lib/library_index.py +477 -0
- package/skills/trends/scripts/lib/linkedin.py +271 -24
- package/skills/trends/scripts/lib/log.py +5 -2
- package/skills/trends/scripts/lib/meta_ads.py +982 -0
- package/skills/trends/scripts/lib/normalize.py +382 -28
- package/skills/trends/scripts/lib/parallel_mcp.py +234 -0
- package/skills/trends/scripts/lib/permission_preflight.py +42 -3
- package/skills/trends/scripts/lib/perplexity.py +1080 -468
- package/skills/trends/scripts/lib/pipeline.py +5079 -211
- package/skills/trends/scripts/lib/planner.py +396 -53
- package/skills/trends/scripts/lib/polymarket.py +285 -23
- package/skills/trends/scripts/lib/prescriptions.py +432 -0
- package/skills/trends/scripts/lib/private_files.py +21 -0
- package/skills/trends/scripts/lib/providers.py +231 -10
- package/skills/trends/scripts/lib/quality_nudge.py +122 -37
- package/skills/trends/scripts/lib/reddit.py +177 -12
- package/skills/trends/scripts/lib/reddit_arctic.py +146 -5
- package/skills/trends/scripts/lib/reddit_keyless.py +121 -60
- package/skills/trends/scripts/lib/reddit_listing.py +215 -14
- package/skills/trends/scripts/lib/reddit_public.py +12 -13
- package/skills/trends/scripts/lib/reddit_search.py +304 -0
- package/skills/trends/scripts/lib/reddit_shreddit.py +51 -7
- package/skills/trends/scripts/lib/registers.py +134 -0
- package/skills/trends/scripts/lib/relevance.py +17 -0
- package/skills/trends/scripts/lib/render.py +2161 -271
- package/skills/trends/scripts/lib/rerank.py +623 -55
- package/skills/trends/scripts/lib/resolve.py +50 -1
- package/skills/trends/scripts/lib/safari_cookies.py +42 -15
- package/skills/trends/scripts/lib/schema.py +782 -1
- package/skills/trends/scripts/lib/setup_wizard.py +779 -120
- package/skills/trends/scripts/lib/signals.py +206 -7
- package/skills/trends/scripts/lib/stocktwits.py +374 -0
- package/skills/trends/scripts/lib/subproc.py +307 -53
- package/skills/trends/scripts/lib/techmeme.py +89 -64
- package/skills/trends/scripts/lib/telegram.py +317 -0
- package/skills/trends/scripts/lib/tiktok.py +13 -9
- package/skills/trends/scripts/lib/topic_shape.py +340 -0
- package/skills/trends/scripts/lib/transcribe.py +12 -3
- package/skills/trends/scripts/lib/trustpilot.py +256 -5
- package/skills/trends/scripts/lib/truthsocial.py +9 -1
- package/skills/trends/scripts/lib/ui.py +6 -0
- package/skills/trends/scripts/lib/usage.py +132 -0
- package/skills/trends/scripts/lib/web_search_keyless.py +63 -4
- package/skills/trends/scripts/lib/windows_job.py +138 -0
- package/skills/trends/scripts/lib/x_api.py +824 -0
- package/skills/trends/scripts/lib/x_envelope.py +733 -0
- package/skills/trends/scripts/lib/x_judge.py +292 -0
- package/skills/trends/scripts/lib/xai_x.py +26 -3
- package/skills/trends/scripts/lib/xquik.py +89 -15
- package/skills/trends/scripts/lib/xurl_x.py +284 -91
- package/skills/trends/scripts/lib/youtube_yt.py +570 -119
- package/skills/trends/scripts/setup-keychain.sh +12 -4
- package/skills/trends/scripts/setup-pass.sh +3 -0
- package/skills/trends/scripts/store.py +444 -50
- package/skills/trends/scripts/watchlist.py +38 -11
- package/skills/trends/scripts/x_lane_parity.py +107 -0
- package/skills/video-studio/CHANGELOG.md +4 -0
- package/skills/video-studio/SKILL.md +4 -4
- package/skills/video-studio/references/styles/product-flight.md +45 -0
- package/skills/video-studio/references/styles.md +3 -1
- package/skills/video-studio-motion/ATTRIBUTION.md +1 -1
- package/skills/video-studio-motion/CHANGELOG.md +4 -0
- package/skills/video-studio-motion/SKILL.md +8 -5
- package/skills/youtube-planner/ATTRIBUTION.md +25 -0
- package/skills/youtube-planner/CHANGELOG.md +12 -0
- package/skills/youtube-planner/SKILL.md +191 -0
- package/skills/youtube-planner/references/plan-template.md +57 -0
- package/skills/youtube-planner/references/stages.md +119 -0
- package/skills/youtube-planner/scripts/floor_ceiling.py +132 -0
- package/skills/coder-brainstorming/spec-document-reviewer-prompt.md +0 -49
- package/skills/trends/scripts/lib/reddit_rss.py +0 -224
|
@@ -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)
|