feat(sources): local corpus source - your own files as a ranked signal (#808)

* feat(sources): local corpus source - your own files as a ranked signal

* fix: address self-review findings

* fix: address round-2 residual (surgical round)

* fix: defang corpus sentinels, matching-window snippets, exclusion-aware hosted gate, traversal notes, bounded discovery

* fix: keep absolute local paths out of corpus notes and coverage diagnostics

* fix(corpus): keep raw exception text out of coverage notes

OS and subprocess errors embed the failing absolute path in str(exc),
and scan/cache notes flow into source_status detail rendered in
coverage diagnostics outside the private corpus block. Notes now carry
the error's strerror (or class name) instead, so a permission failure
or file race can no longer leak a local path from a private run.

---------

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
This commit is contained in:
Matt Van Horn
2026-07-12 07:33:01 -07:00
committed by GitHub
parent 9c020b1393
commit 3b0b021e2c
16 changed files with 1984 additions and 70 deletions
+23 -1
View File
@@ -46,6 +46,8 @@ The engine's `.env` reader doesn't expand `$HOME` — only the tilde, via `Path(
- `--save-dir <path>` - one-off output location. **Flag wins over env var.** If neither flag nor env var is set, the engine does not write a file (DB persistence is independent — see `LAST30DAYS_STORE` below).
- `--output <file>` - write the rendered output to an exact file path, using the format selected by `--emit`.
- `--json-profile {agent,raw}` - select the research JSON shape used with `--emit=json`. `agent` is the default, versioned workflow contract; `raw` preserves the full internal `Report` dump for debugging and power users. See the [JSON export reference](docs/reference/json-export.md).
- `--corpus <dir>` - add a local `.md`/`.txt` directory as a private ranked source; repeat the flag for multiple directories. PDFs are extracted only when `pdftotext` is on PATH and otherwise skip with a note. File modification time supplies recency, so the normal research window applies.
- `--corpus-all-time` - include relevant registered files whose modification time is older than the current research window. Without this flag, a 30-day run includes only files modified in those 30 days.
- `--register {default,exec,dev,creator,eli5}` - shape a standard single-topic Markdown or HTML research brief for its audience. `exec` is decisions-first with five core findings and numbers up top; `dev` gives GitHub, code, and technical signals more room; `creator` leads with hooks, Best Takes, community reactions, and virality metrics; `eli5` keeps the established evidence layout and asks the synthesizing agent for accessible language. Registers do not change retrieval, JSON exports, discovery, drill, library feed/search, or comparison output.
- `--discover <domain>` - topic-less trending discovery. Sweeps rising/top-week Reddit listings (category-mapped communities, with r/all as the uncategorized fallback), Hacker News front/best stories, Digg AI 1000 clusters when `digg-pp-cli` is on PATH, and broad X activity when an X backend is authenticated, then returns 5-10 engagement-velocity-ranked topics. Run without a positional topic; it is mutually exclusive with `--drill`. `--emit=json` uses the separate versioned discovery contract documented in the [JSON export reference](docs/reference/json-export.md).
- `--drill <target>` - deep follow-up over the fresh `~/.config/last30days/last-report.json` cache. Accepts a 1-based index (`--drill "cluster 3"` or `--drill "3"`) or a fuzzy cluster title/entity description. It re-fetches only sources that contributed to the matched cluster, enables their deep comment/transcript enrichment paths, merges/dedupes the evidence, and replaces the cache so drills can chain. Run it without a positional topic; if the cache is absent or expired, run a normal research pass first.
@@ -98,12 +100,32 @@ Override the global location with `LAST30DAYS_CONFIG_DIR=/path` (or `LAST30DAYS_
The project-scoped file is useful for **intentional per-client setups**: drop a `.claude/last30days.env` into each client folder (`SCRAPECREATORS_API_KEY`, `INCLUDE_SOURCES`, `LAST30DAYS_MEMORY_DIR`, `BSKY_HANDLE`, etc), then opt in with `LAST30DAYS_TRUST_PROJECT_CONFIG=1` from your shell or `~/.config/last30days/.env`. Folder-mode hosts such as Codex desktop do not trust hidden project config by default, and discovery stops at the git root so unrelated parent folders cannot silently influence runs.
**`LAST30DAYS_API_KEY`** + **`LAST30DAYS_API_BASE`** - optional remote-API backend. Set BOTH to route research through a remote API endpoint instead of running the local sources: `LAST30DAYS_API_BASE` is the endpoint (there is no built-in default), and `LAST30DAYS_API_KEY` is the bearer key for it. When both are set (and `--mock` is not passed), the engine submits the topic to that endpoint, polls with progress on stderr, and prints the server's report; none of the per-source keys below are used for that run. Non-default `--register` selections are forwarded with the request so server-side synthesis uses the same audience preset. Leave either unset to run local sources exactly as normal. Unlike the other keys here, these two are read only from the **process environment** (export them in your shell or host config) - they are deliberately not loaded from the `.env` files above, so a project-scoped `.env` can never silently redirect research to a remote endpoint. The remote endpoint does not return the local `Report` needed for the versioned agent JSON profile; use `--emit=json --json-profile=raw` for its existing server-response JSON contract.
**`LAST30DAYS_API_KEY`** + **`LAST30DAYS_API_BASE`** - optional remote-API backend. Set BOTH to route research through a remote API endpoint instead of running the local sources: `LAST30DAYS_API_BASE` is the endpoint (there is no built-in default), and `LAST30DAYS_API_KEY` is the bearer key for it. When both are set (and `--mock` is not passed), the engine submits the topic to that endpoint, polls with progress on stderr, and prints the server's report; none of the per-source keys below are used for that run. A configured local corpus is the privacy exception: the engine bypasses the hosted backend and runs locally rather than forwarding file-derived input. Non-default `--register` selections are forwarded with the request so server-side synthesis uses the same audience preset. Leave either unset to run local sources exactly as normal. Unlike the other keys here, these two are read only from the **process environment** (export them in your shell or host config) - they are deliberately not loaded from the `.env` files above, so a project-scoped `.env` can never silently redirect research to a remote endpoint. The remote endpoint does not return the local `Report` needed for the versioned agent JSON profile; use `--emit=json --json-profile=raw` for its existing server-response JSON contract.
### Local corpus (your files)
Register persistent directories with `LAST30DAYS_CORPUS_DIRS`. Separate paths with `:` on macOS/Linux (the platform path separator is `;` on Windows):
```bash
# ~/.config/last30days/.env
LAST30DAYS_CORPUS_DIRS=~/notes:~/meeting-transcripts
# LAST30DAYS_CORPUS_IN_EXPORT=1 # explicit agent-JSON opt-in; off by default
```
The slash-command experience remains primary: ask `/last30days` to include your registered notes. For direct engine scripting or development, the equivalent one-off invocation is:
```bash
python3 skills/last30days/scripts/last30days.py "MCP servers" \
--corpus ~/notes --corpus ~/meeting-transcripts
```
**Privacy:** corpus files are read locally, never sent through a source HTTP client, never forwarded to `LAST30DAYS_API_BASE`, never included in remote reranker/fun-scoring prompts, and do not consume network-source concurrency or retry budget. Matches appear in a badged **From your files** section. Corpus candidates are removed from `--publish-html`, `library feed --publish`, and the versioned agent JSON export by default, including corpus-derived cluster titles and source outcomes. Set `LAST30DAYS_CORPUS_IN_EXPORT=1` only when you intentionally want corpus results in the agent JSON written to local stdout/files. The unversioned `--json-profile=raw` debug dump remains a full local report and can contain corpus text; do not redirect it to an external system unless that is intentional. Extracted text is cached by file mtime in `~/.config/last30days/corpus-cache.json` with mode `0600`; a corpus-bearing `last-report.json` cache is also tightened to `0600`. Delete either cache at any time to clear it.
**Source-by-source** - what each key unlocks:
| Source | Key(s) | Required for | Free tier |
|---|---|---|---|
| Local corpus | `--corpus <dir>` or `LAST30DAYS_CORPUS_DIRS` | private `.md`/`.txt`; `.pdf` when `pdftotext` is on PATH | yes (offline) |
| Reddit (public) | none (default); `SCRAPECREATORS_API_KEY` + `LAST30DAYS_REDDIT_BACKEND=scrapecreators` to pin SC primary with public fallback | always on; SC pin requires `SCRAPECREATORS_API_KEY` | yes |
| Hacker News | none | always on | yes |
| Polymarket | none | always on | yes |
+4
View File
@@ -21,6 +21,10 @@ python3 skills/last30days/scripts/last30days.py "AI coding agents" --emit=json -
The raw profile is intentionally unversioned and may change when pipeline internals change. It preserves the JSON serialization used before the agent profile was introduced.
### Local corpus privacy
Evidence from `--corpus` / `LAST30DAYS_CORPUS_DIRS` is excluded from the versioned agent profile by default. The exclusion removes corpus results, corpus-only clusters, corpus source outcomes, freshness verdicts, and titles derived from a corpus representative. Set `LAST30DAYS_CORPUS_IN_EXPORT=1` only for a run whose JSON is intentionally allowed to contain local file contents. This opt-in does not change the schema shape or version; it permits `source: "corpus"` entries in the existing result fields. The unversioned `raw` profile is a complete local debug dump and may contain corpus paths and text.
## Discovery export
Discovery mode has a separate versioned contract so its topic results do not change the normal research export:
+4 -2
View File
@@ -416,7 +416,7 @@ Set `LAST30DAYS_MEMORY_DIR` before invoking the skill to choose where raw resear
The engine reads `LAST30DAYS_MEMORY_DIR` from either the process env or `~/.config/last30days/.env`, so direct CLI invocations (`python3 scripts/last30days.py ...`) without `--save-dir` will still save when the env var is set. Mirrors the `LAST30DAYS_STORE` env-or-flag convention. Explicit `--save-dir` always wins.
When both `LAST30DAYS_API_KEY` and `LAST30DAYS_API_BASE` are set, the engine runs the research through that configured remote API instead of local sources (unless `--mock` is passed); `LAST30DAYS_API_BASE` is the endpoint and has no built-in default, so leaving either variable unset runs local sources normally. The invocation is unchanged: same flags, `--quick`/`--deep` map to search depth, a non-default `--register` is forwarded for server-side synthesis, progress lines still stream on stderr (`[narrate] step=...` plus a compact elapsed/eta line), and the report prints on stdout and saves to the memory dir as usual, so Steps 1-4 proceed normally on the output. The exception is research JSON: the remote endpoint does not return the local `Report` needed for the versioned agent profile, so use `--emit=json --json-profile=raw` for its existing server-response JSON contract. No per-source keys or setup-wizard credentials are needed for the search itself in this mode. Two engine exits need specific handling: exit code 3 means the API asked a clarifying question first - the engine prints the question and options on stderr; present them to the user and re-run with the chosen angle folded into the topic. An insufficient-credits failure (HTTP 402) prints the account's balance, the amount needed, and a billing link - relay those lines to the user verbatim; do not fall back to WebSearch-only synthesis.
When both `LAST30DAYS_API_KEY` and `LAST30DAYS_API_BASE` are set, the engine runs the research through that configured remote API instead of local sources (unless `--mock` is passed); `LAST30DAYS_API_BASE` is the endpoint and has no built-in default, so leaving either variable unset runs local sources normally. A configured `--corpus` / `LAST30DAYS_CORPUS_DIRS` is the privacy exception: the engine bypasses the hosted backend and runs locally so no file-derived input is forwarded. The invocation is otherwise unchanged: same flags, `--quick`/`--deep` map to search depth, a non-default `--register` is forwarded for server-side synthesis, progress lines still stream on stderr (`[narrate] step=...` plus a compact elapsed/eta line), and the report prints on stdout and saves to the memory dir as usual, so Steps 1-4 proceed normally on the output. The exception is research JSON: the remote endpoint does not return the local `Report` needed for the versioned agent profile, so use `--emit=json --json-profile=raw` for its existing server-response JSON contract. No per-source keys or setup-wizard credentials are needed for the search itself in this mode. Two engine exits need specific handling: exit code 3 means the API asked a clarifying question first - the engine prints the question and options on stderr; present them to the user and re-run with the chosen angle folded into the topic. An insufficient-credits failure (HTTP 402) prints the account's balance, the amount needed, and a billing link - relay those lines to the user verbatim; do not fall back to WebSearch-only synthesis.
**Developer-only eval capture:** `--record-fixtures <dir>` is a hidden direct-engine flag for maintaining the deterministic research-quality suite. It records scrubbed HTTP and CLI-adapter responses to `<dir>/http.json`; it is never part of the user-facing slash-command invocation. Follow `docs/reference/eval.md` for fixture review, replay, and baseline rules.
@@ -644,10 +644,12 @@ SKILL_DIR="<absolute path of the directory containing the SKILL.md you just Read
"${LAST30DAYS_PYTHON}" "${SKILL_DIR}/scripts/last30days.py" --diagnose
```
`--diagnose` prints JSON. `ACTIVE_SOURCES_LIST` is its `available_sources` array — the engine's authoritative source set, computed after credential resolution. Map the tokens to display names: `reddit`→Reddit, `hackernews`→Hacker News, `polymarket`→Polymarket, `github`→GitHub, `digg`→Digg, `x`→X, `youtube`→YouTube, `tiktok`→TikTok, `instagram`→Instagram, `threads`→Threads, `pinterest`→Pinterest, `linkedin`→LinkedIn, `bluesky`→Bluesky, `perplexity`→Perplexity, `grounding`→Web, `jobs`→Jobs.
`--diagnose` prints JSON. `ACTIVE_SOURCES_LIST` is its `available_sources` array — the engine's authoritative source set, computed after credential resolution. Map the tokens to display names: `reddit`→Reddit, `hackernews`→Hacker News, `polymarket`→Polymarket, `github`→GitHub, `digg`→Digg, `x`→X, `youtube`→YouTube, `tiktok`→TikTok, `instagram`→Instagram, `threads`→Threads, `pinterest`→Pinterest, `linkedin`→LinkedIn, `bluesky`→Bluesky, `perplexity`→Perplexity, `grounding`→Web, `jobs`→Jobs, `corpus`→Your files.
- If EXCLUDE_SOURCES is set (comma-separated, case-insensitive): drop any matching source from ACTIVE_SOURCES_LIST before displaying
**Local corpus source:** If the user asks to include their own notes/documents, preserve each supplied directory as a repeatable `--corpus <dir>` engine flag. `LAST30DAYS_CORPUS_DIRS` activates persistent registered directories automatically. Do not WebSearch, upload, quote into a hosted request, or otherwise expose those paths or contents. Corpus retrieval is an offline source lane; its candidates also bypass remote reranker/fun-scoring prompts and use deterministic local scoring. The engine renders matches under the 🔒 **From your files** badge. The normal recency window uses file modification time; add `--corpus-all-time` only when the user explicitly asks to include older files. Corpus evidence is excluded from `--publish-html`, `library feed --publish`, and agent JSON by default. `LAST30DAYS_CORPUS_IN_EXPORT=1` is the explicit agent-JSON privacy opt-in; never enable it on the user's behalf. When a corpus is configured alongside `LAST30DAYS_API_KEY`/`LAST30DAYS_API_BASE`, the engine deliberately bypasses the hosted backend and runs locally.
**Perplexity source:** use it only when the user asks for Perplexity, Deep Research, or paid grounded synthesis, or when `perplexity` is already enabled in `INCLUDE_SOURCES` / `--search`. Direct `PERPLEXITY_API_KEY` supports Sonar synthesis, Search API rows, and async Deep Research. `OPENROUTER_API_KEY` is only a Sonar fallback. Normal runs default to `LAST30DAYS_PERPLEXITY_MODE=sonar`; use `search` for raw ranked web rows, `both` for synthesis plus rows, and `--deep-research` for `sonar-deep-research` with a 600s default wall timeout. A local Deep Research timeout is not a failed API key; inspect the raw artifact's async request id/status and resume by id if needed.
**Reddit backend pin:** Reddit defaults to the free public backend with ScrapeCreators as a backup when `SCRAPECREATORS_API_KEY` is available. If the user says public Reddit is shallow, bot-gated, or missing nested comments, tell them they can set `LAST30DAYS_REDDIT_BACKEND=scrapecreators` alongside `SCRAPECREATORS_API_KEY` to make ScrapeCreators primary and keep public Reddit as fallback. Do not set this automatically for normal runs.
+178 -16
View File
@@ -48,7 +48,7 @@ if os.name == "nt":
SCRIPT_DIR = Path(__file__).parent.resolve()
sys.path.insert(0, str(SCRIPT_DIR))
from lib import dates, env, freshness, html_render, http, permission_preflight, pipeline, registers, render, schema, ui
from lib import corpus, dates, env, freshness, html_render, http, permission_preflight, pipeline, registers, render, schema, ui
_child_pids: set[int] = set()
_child_pids_lock = threading.Lock()
@@ -121,6 +121,34 @@ def slugify(value: str) -> str:
return slug or "last30days"
def _report_has_private_corpus(report: schema.Report) -> bool:
items_by_source = getattr(report, "items_by_source", {})
if isinstance(items_by_source, dict) and items_by_source.get("corpus"):
return True
candidates = getattr(report, "ranked_candidates", ())
if not isinstance(candidates, (list, tuple)):
return False
return any(
candidate.source == "corpus"
or any(item.source == "corpus" for item in candidate.source_items)
for candidate in candidates
)
def _ensure_output_directory(path: Path, *, private: bool) -> None:
if not private:
path.mkdir(parents=True, exist_ok=True)
return
missing: list[Path] = []
current = path
while not current.exists():
missing.append(current)
current = current.parent
path.mkdir(parents=True, exist_ok=True, mode=0o700)
for directory in missing:
directory.chmod(0o700)
def save_output(
report: schema.Report,
emit: str,
@@ -131,10 +159,10 @@ def save_output(
rendered_content: str | None = None,
json_profile: str = "agent",
register: str = "default",
private: bool | None = None,
) -> Path:
from datetime import datetime
path = Path(save_dir).expanduser().resolve()
path.mkdir(parents=True, exist_ok=True)
slug = slugify(topic_override or report.topic)
extension = "json" if emit == "json" else "html" if emit == "html" else "md"
raw_label = "raw-html" if emit == "html" else "raw"
@@ -159,10 +187,16 @@ def save_output(
)
else:
content = render.render_full(report)
private_corpus = _report_has_private_corpus(report) or bool(private)
_ensure_output_directory(path, private=private_corpus)
encoded = content.encode("utf-8")
for candidate in candidates:
try:
fd = os.open(candidate, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
fd = os.open(
candidate,
os.O_CREAT | os.O_EXCL | os.O_WRONLY,
0o600 if private_corpus else 0o644,
)
except FileExistsError:
continue
with os.fdopen(fd, "wb") as f:
@@ -194,10 +228,25 @@ def save_output(
)
def save_rendered_output(rendered_content: str, output_file: str) -> Path:
def save_rendered_output(
rendered_content: str,
output_file: str,
*,
private: bool = False,
) -> Path:
out_path = Path(output_file).expanduser().resolve()
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(rendered_content, encoding="utf-8")
_ensure_output_directory(out_path.parent, private=private)
if private and out_path.exists():
out_path.chmod(0o600)
fd = os.open(
out_path,
os.O_CREAT | os.O_TRUNC | os.O_WRONLY,
0o600 if private else 0o644,
)
with os.fdopen(fd, "w", encoding="utf-8") as handle:
handle.write(rendered_content)
if private:
out_path.chmod(0o600)
return out_path
@@ -384,14 +433,21 @@ def _scoped_store_db(args: argparse.Namespace) -> Path | None:
def persist_report(report: schema.Report, store_db: Path | None = None) -> dict[str, int]:
import store
private_corpus = _report_has_private_corpus(report)
with store.scoped_db(store_db):
if private_corpus:
store.ensure_private_db_files()
store.init_db()
if private_corpus:
store.ensure_private_db_files()
topic_row = store.add_topic(report.topic)
topic_id = topic_row["id"]
source_mode = ",".join(sorted(report.items_by_source)) or "v3"
run_id = store.record_run(topic_id, source_mode=source_mode, status="running")
try:
findings = store.findings_from_report(report)
if private_corpus:
store.ensure_private_db_files()
counts = store.store_findings(run_id, topic_id, findings)
store.update_run(
run_id,
@@ -403,6 +459,9 @@ def persist_report(report: schema.Report, store_db: Path | None = None) -> dict[
except Exception as exc:
store.update_run(run_id, status="failed", error_message=str(exc)[:500])
raise
finally:
if private_corpus:
store.ensure_private_db_files()
def build_parser() -> argparse.ArgumentParser:
@@ -466,6 +525,18 @@ def build_parser() -> argparse.ArgumentParser:
parser.add_argument("--no-browser-cookies", action="store_true",
help="Disable browser-cookie extraction even when FROM_BROWSER is configured")
parser.add_argument("--save-dir", help="Optional directory for saving the rendered output")
parser.add_argument(
"--corpus",
action="append",
default=[],
metavar="DIR",
help="Add a local .md/.txt/.pdf directory as a private ranked source (repeatable)",
)
parser.add_argument(
"--corpus-all-time",
action="store_true",
help="Include matching corpus files older than the research window",
)
parser.add_argument("--output", help="Optional exact file path for saving the rendered output")
parser.add_argument("--synthesis-file", help="Markdown synthesis to embed in --emit=html output")
parser.add_argument("--publish-html", action="store_true",
@@ -827,7 +898,12 @@ def _write_last_run(
if env.CONFIG_DIR is None:
return False
target = env.CONFIG_DIR
target.mkdir(parents=True, exist_ok=True)
cached_reports = entity_reports or [(report.topic, report)]
has_private_corpus = any(
cached_report.items_by_source.get("corpus")
for _, cached_report in cached_reports
)
_ensure_output_directory(target, private=has_private_corpus)
counts = {source: len(items) for source, items in report.items_by_source.items()}
payload = {
"topic": topic,
@@ -838,7 +914,6 @@ def _write_last_run(
"comparison": bool(entity_reports),
}
(target / "last-run.json").write_text(json.dumps(payload, indent=2))
cached_reports = entity_reports or [(report.topic, report)]
cache_payload = {
"schema": REPORT_CACHE_VERSION,
"topic": topic,
@@ -849,7 +924,10 @@ def _write_last_run(
for label, cached_report in cached_reports
],
}
(target / "last-report.json").write_text(json.dumps(cache_payload, indent=2))
report_cache_path = target / "last-report.json"
report_cache_path.write_text(json.dumps(cache_payload, indent=2))
if has_private_corpus:
report_cache_path.chmod(0o600)
return True
except Exception as exc:
# Never fatal, but never silent either (#787's lesson): callers that
@@ -1105,6 +1183,8 @@ def _run_drill(
if "trustpilot" in sources else None
),
internal_subrun=True,
corpus_dirs=args.corpus,
corpus_all_time=args.corpus_all_time,
)
except Exception:
progress.end_processing()
@@ -1388,9 +1468,18 @@ def _render_save_and_print(
json_profile=args.json_profile,
register=audience.name,
)
has_private_corpus = _report_has_private_corpus(report) or bool(
entity_reports
and any(_report_has_private_corpus(entity) for _label, entity in entity_reports)
)
private_saved_format = has_private_corpus
publish_companion_paths: list[Path] = []
if args.output:
output_path = save_rendered_output(rendered, args.output)
output_path = save_rendered_output(
rendered,
args.output,
private=private_saved_format,
)
if args.emit == "html":
publish_companion_paths.append(output_path)
sys.stderr.write(f"[last30days] Saved output to {output_path}\n")
@@ -1407,6 +1496,7 @@ def _render_save_and_print(
rendered_content=rendered if is_comparison_html else None,
json_profile=args.json_profile,
register=audience.name,
private=private_saved_format,
)
if args.emit == "html":
publish_companion_paths.append(save_path)
@@ -1421,6 +1511,7 @@ def _render_save_and_print(
suffix=args.save_suffix or "",
synthesis_md=synthesis_md,
json_profile=args.json_profile,
private=_report_has_private_corpus(entity_report),
)
comparison_peer_paths.append(peer_path)
sys.stderr.write(f"[last30days] Saved output to {peer_path}\n")
@@ -1432,8 +1523,39 @@ def _render_save_and_print(
sys.stderr.flush()
if args.publish_html:
try:
has_private_corpus = "corpus" in report.source_status or bool(
entity_reports
and any("corpus" in entity.source_status for _label, entity in entity_reports)
)
publish_rendered = rendered
if has_private_corpus:
sys.stderr.write(
"[last30days] Excluding local corpus evidence and synthesis from published HTML.\n"
)
if entity_reports:
publish_rendered = emit_comparison_output(
[
(label, schema.without_sources(entity, {"corpus"}))
for label, entity in entity_reports
],
"html",
fun_level=fun_level,
save_path=footer_save_path,
synthesis_md=None,
json_profile=args.json_profile,
)
else:
publish_rendered = emit_output(
schema.without_sources(report, {"corpus"}),
"html",
fun_level=fun_level,
save_path=footer_save_path,
synthesis_md=None,
json_profile=args.json_profile,
register=audience.name,
)
publish_result = publish_rendered_html(
rendered,
publish_rendered,
password=_publish_password_for_args(args, config),
companion_paths=publish_companion_paths,
)
@@ -1594,7 +1716,10 @@ def _run_library_feed(args: argparse.Namespace, config: dict[str, object]) -> in
output_dir.mkdir(parents=True, exist_ok=True)
library_id = library.get_or_create_library_id(output_dir)
rendered_briefs_dir = output_dir / "briefs"
rendered_briefs_dir.mkdir(parents=True, exist_ok=True)
has_private_entries = any(
render.PRIVATE_CORPUS_START in entry.content for entry in entries
)
_ensure_output_directory(rendered_briefs_dir, private=has_private_entries)
def _preserve_hand_written_page(existing_path: Path, generated_marker: str) -> None:
"""Back up any page library feed did not generate before overwriting it."""
@@ -1617,13 +1742,19 @@ def _run_library_feed(args: argparse.Namespace, config: dict[str, object]) -> in
f"library feed; preserved the original at {backup.name}\n"
)
brief_documents: dict[str, str] = {}
publishable_brief_documents: dict[str, str] = {}
for entry in entries:
rendered = html_render.render_library_brief(entry)
target = rendered_briefs_dir / entry.output_name
_preserve_hand_written_page(target, html_render.LIBRARY_BRIEF_MARKER)
target.write_text(rendered, encoding="utf-8")
brief_documents[entry.entry_id] = rendered
save_rendered_output(
rendered,
str(target),
private=render.PRIVATE_CORPUS_START in entry.content,
)
publishable_brief_documents[entry.entry_id] = html_render.render_library_brief(
entry, include_private=False
)
current_brief_names = {entry.output_name for entry in entries}
for path in rendered_briefs_dir.glob("*.html"):
@@ -1664,7 +1795,7 @@ def _run_library_feed(args: argparse.Namespace, config: dict[str, object]) -> in
entry_urls: dict[str, str] = {}
try:
brief_results = html_publish.publish_html_documents(
brief_documents,
publishable_brief_documents,
password=password,
)
entry_urls = {
@@ -1810,6 +1941,23 @@ def _main(
)
return 2
config = env.get_config(policy=_config_policy_for_args(args, topic, extra_argv))
resolved_corpus_dirs = corpus.resolve_directories(
args.corpus, config.get("LAST30DAYS_CORPUS_DIRS")
)
# EXCLUDE_SOURCES=corpus disables corpus retrieval entirely; the hosted
# privacy bypass below must use the same predicate, or hosted users with
# configured-but-excluded dirs silently lose the remote backend.
excluded_sources = {
value.strip().lower()
for value in str(config.get("EXCLUDE_SOURCES") or "").split(",")
if value.strip()
}
if "corpus" in excluded_sources:
resolved_corpus_dirs = []
if resolved_corpus_dirs:
config["_CORPUS_DIRS"] = [str(path) for path in resolved_corpus_dirs]
if _config_truthy(config.get("LAST30DAYS_CORPUS_IN_EXPORT")):
config["_CORPUS_IN_EXPORT"] = True
_propagate_config_to_environ(config)
# Env-var fallback for --save-dir, mirroring the LAST30DAYS_STORE pattern below.
@@ -1979,6 +2127,15 @@ def _main(
# instead of local sources; no local provider keys are needed (see
# lib/hosted.py). With either env var unset, behavior below is byte-identical
# to local-only runs - there is no built-in endpoint.
if (
topic
and resolved_corpus_dirs
and env.read_secret_env("LAST30DAYS_API_KEY")
and os.environ.get("LAST30DAYS_API_BASE")
):
sys.stderr.write(
"[last30days] Local corpus configured; bypassing the hosted backend so files stay on this machine.\n"
)
if (
topic
and not args.diagnose
@@ -1986,6 +2143,7 @@ def _main(
and not args.record_fixtures
and env.read_secret_env("LAST30DAYS_API_KEY")
and os.environ.get("LAST30DAYS_API_BASE")
and not resolved_corpus_dirs
):
if _freshness_enabled(args, config):
if args.verify_freshness is True:
@@ -2238,6 +2396,8 @@ def _main(
internal_subrun=comp_enabled,
hiring_signals_mode=args.hiring_signals,
save_dir=args.save_dir,
corpus_dirs=args.corpus,
corpus_all_time=args.corpus_all_time,
)
r.artifacts["resolved"] = {
"entity": topic,
@@ -2375,6 +2535,8 @@ def _main(
hiring_signals_mode=args.hiring_signals,
internal_subrun=True,
save_dir=args.save_dir,
corpus_dirs=args.corpus,
corpus_all_time=args.corpus_all_time,
)
report.artifacts["resolved"] = resolved_effective
return report
+439
View File
@@ -0,0 +1,439 @@
"""Deterministic, local-only document corpus source.
The corpus adapter deliberately has no HTTP dependency. It scans explicitly
registered directories, extracts small text documents (and PDFs only when the
local ``pdftotext`` binary is available), and returns normalized ``SourceItem``
objects for the shared relevance/fusion pipeline.
"""
from __future__ import annotations
import hashlib
import json
import os
import subprocess
import threading
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from shutil import which
from typing import Any, Iterable
from . import entity_extract, log, relevance, schema
SOURCE = "corpus"
SUPPORTED_SUFFIXES = {".md", ".txt", ".pdf"}
IGNORED_DIRECTORIES = {".git", "node_modules"}
MAX_FILES = 500
MAX_TEXT_CHARS = 1_000_000
MAX_CACHE_TEXT_CHARS = MAX_TEXT_CHARS
MAX_CACHE_BYTES = 50 * 1024 * 1024
MAX_CACHE_ENTRIES = 2_000
CACHE_FILENAME = "corpus-cache.json"
CACHE_SCHEMA_VERSION = "last30days-corpus-cache/v2"
_CACHE_LOCK = threading.Lock()
@dataclass
class CorpusScanResult:
"""One bounded scan, including non-fatal extraction notes."""
items: list[schema.SourceItem]
notes: list[str] = field(default_factory=list)
files_scanned: int = 0
cache_hits: int = 0
def resolve_directories(
cli_directories: Iterable[str] | None,
configured: str | Iterable[str] | None,
) -> list[Path]:
"""Merge repeatable CLI paths with ``os.pathsep``-separated config paths."""
raw: list[str] = [str(value) for value in (cli_directories or []) if str(value).strip()]
if isinstance(configured, str):
raw.extend(value for value in configured.split(os.pathsep) if value.strip())
elif configured:
raw.extend(str(value) for value in configured if str(value).strip())
resolved: list[Path] = []
seen: set[str] = set()
for value in raw:
path = Path(value.strip()).expanduser().resolve()
key = os.path.normcase(str(path))
if key in seen:
continue
seen.add(key)
resolved.append(path)
return resolved
def _safe_error(exc: BaseException) -> str:
"""Describe an error without str(exc), which embeds absolute paths.
These notes travel into source_status detail and render in coverage
diagnostics outside the private corpus block.
"""
reason = getattr(exc, "strerror", None)
return str(reason) if reason else exc.__class__.__name__
def search(
topic: str,
directories: Iterable[Path | str],
*,
from_date: str,
to_date: str,
all_time: bool = False,
limit: int = 12,
cache_dir: Path | None = None,
) -> CorpusScanResult:
"""Search registered directories without making any network calls."""
roots = resolve_directories([str(path) for path in directories], None)
notes: list[str] = []
cache_path = cache_dir / CACHE_FILENAME if cache_dir is not None else None
with _CACHE_LOCK:
cache = _load_cache(cache_path)
cache_entries = cache.setdefault("entries", {})
cache_entry_sizes = {
path: _cache_entry_fragment_size(path, value)
for path, value in cache_entries.items()
}
candidates: list[tuple[float, int, schema.SourceItem]] = []
seen_files: set[str] = set()
files_scanned = 0
cache_hits = 0
pdf_available = which("pdftotext")
pdf_unavailable_noted = False
readable_roots: list[Path] = []
for root in roots:
if not root.is_dir():
notes.append(f"Skipped corpus root '{Path(root).name}': not a readable directory")
continue
readable_roots.append(root)
per_root_limit, extra_slots = divmod(MAX_FILES, len(readable_roots) or 1)
scan_limit_reached = False
for root_index, root in enumerate(readable_roots):
root_limit = per_root_limit + (1 if root_index < extra_slots else 0)
root_files_scanned = 0
for path in _iter_files(root, notes=notes):
if root_files_scanned >= root_limit:
scan_limit_reached = True
break
key = os.path.normcase(str(path))
if key in seen_files:
continue
seen_files.add(key)
root_files_scanned += 1
files_scanned += 1
try:
stat = path.stat()
except OSError as exc:
notes.append(f"Skipped {_display_path(path, root)}: {_safe_error(exc)}")
continue
published_at = datetime.fromtimestamp(
stat.st_mtime, tz=timezone.utc
).date().isoformat()
if not all_time and not (from_date <= published_at <= to_date):
continue
cached = cache_entries.get(str(path))
if (
isinstance(cached, dict)
and cached.get("mtime_ns") == stat.st_mtime_ns
and cached.get("size") == stat.st_size
and isinstance(cached.get("text"), str)
):
text = cached["text"]
cache_hits += 1
else:
if path.suffix.lower() == ".pdf" and not pdf_available:
if not pdf_unavailable_noted:
notes.append("Skipped PDF files because pdftotext is not on PATH")
pdf_unavailable_noted = True
continue
try:
text = _extract_text(path, pdftotext=pdf_available)
except (OSError, subprocess.SubprocessError) as exc:
notes.append(f"Skipped {_display_path(path, root)}: {_safe_error(exc)}")
continue
_cache_entry_put(cache_entries, cache_entry_sizes, str(path), {
"mtime_ns": stat.st_mtime_ns,
"size": stat.st_size,
"text": text[:MAX_CACHE_TEXT_CHARS],
})
title = _path_title(path)
score = _match_score(topic, f"{title}\n{text}")
if score < 0.15:
continue
relative_path = str(path.relative_to(root))
path_digest = hashlib.sha256(str(path).encode("utf-8")).hexdigest()
item = schema.SourceItem(
item_id=f"C{path_digest[:12]}",
source=SOURCE,
title=title,
body=text,
url=f"corpus://{path_digest}",
container=str(path.parent),
published_at=published_at,
date_confidence="high",
relevance_hint=score,
why_relevant=f"Matched local file {relative_path}",
# Leave empty so extract_best_snippet derives the matching
# window; a file-prefix snippet is preserved verbatim and can
# show unrelated intro text (and draw entity-miss demotion).
snippet="",
metadata={
"path": str(path),
"relative_path": relative_path,
"extension": path.suffix.lower(),
"local_only": True,
},
)
candidates.append((score, stat.st_mtime_ns, item))
if scan_limit_reached:
notes.append(f"Stopped after the {MAX_FILES}-file corpus scan limit")
cache["schema_version"] = CACHE_SCHEMA_VERSION
cache["entries"] = _bounded_entries(cache_entries)
with _CACHE_LOCK:
_write_cache(cache_path, cache, notes)
candidates.sort(key=lambda row: (-row[0], -row[1], row[2].title.casefold()))
items = [item for _score, _mtime, item in candidates[: max(0, limit)]]
log.source_log(
"Corpus",
f"scanned {files_scanned} file(s), {cache_hits} cache hit(s), {len(items)} match(es)",
tty_only=False,
)
return CorpusScanResult(
items=items,
notes=notes,
files_scanned=files_scanned,
cache_hits=cache_hits,
)
def _display_path(path: Path | str, root: Path | None = None) -> str:
"""Render a note-safe path: never the absolute local path.
Corpus notes flow into source_status detail and the Partial Coverage
block, which render OUTSIDE the private corpus markers - an absolute
path like /home/user/private/notes/foo.md must not escape there.
"""
candidate = Path(path)
if root is not None:
try:
return str(Path(root).name / candidate.relative_to(root))
except ValueError:
pass
return candidate.name
def _iter_files(root: Path, notes: list[str] | None = None) -> Iterable[Path]:
# Bounded newest-first selection: keep only the newest MAX_FILES paths in a
# heap while walking, so registering a huge tree does not materialize every
# path before the caller's extraction cap applies.
import heapq
heap: list[tuple[int, str]] = []
walk_errors = 0
def _on_walk_error(error: OSError) -> None:
nonlocal walk_errors
walk_errors += 1
if notes is not None and walk_errors <= 3:
unreadable = _display_path(error.filename, root) if error.filename else Path(root).name
notes.append(f"corpus: could not read {unreadable}: {error.strerror}")
for current, directory_names, file_names in os.walk(
root, followlinks=False, onerror=_on_walk_error
):
directory_names[:] = sorted(
name
for name in directory_names
if name not in IGNORED_DIRECTORIES and not name.startswith(".")
)
current_path = Path(current)
for name in sorted(file_names):
if name.startswith("."):
continue
path = current_path / name
if path.suffix.lower() in SUPPORTED_SUFFIXES and not path.is_symlink():
entry = (_safe_mtime_ns(path), str(path))
if len(heap) < MAX_FILES:
heapq.heappush(heap, entry)
else:
heapq.heappushpop(heap, entry)
if notes is not None and walk_errors > 3:
notes.append(f"corpus: {walk_errors - 3} more unreadable directories suppressed")
ordered = sorted(heap, key=lambda item: (-item[0], item[1].casefold()))
for _mtime, raw_path in ordered:
yield Path(raw_path)
def _safe_mtime_ns(path: Path) -> int:
try:
return path.stat().st_mtime_ns
except OSError:
return 0
def _extract_text(path: Path, *, pdftotext: str | None) -> str:
if path.suffix.lower() == ".pdf":
if not pdftotext:
return ""
completed = subprocess.run(
[pdftotext, str(path), "-"],
capture_output=True,
check=True,
text=True,
timeout=20,
)
return completed.stdout[:MAX_TEXT_CHARS]
with path.open("r", encoding="utf-8", errors="replace") as handle:
return handle.read(MAX_TEXT_CHARS)
def _path_title(path: Path) -> str:
title = path.stem.replace("_", " ").replace("-", " ")
return " ".join(title.split()) or path.name
def _match_score(topic: str, text: str) -> float:
lexical = relevance.token_overlap_relevance(topic, text)
topic_entities = entity_extract.extract_text_entities(topic)
text_entities = entity_extract.extract_text_entities(text)
entity_score = entity_extract.entity_overlap(topic_entities, text_entities)
return round(max(lexical, entity_score * 0.9), 4)
def _load_cache(path: Path | None) -> dict[str, Any]:
if path is None:
return {"schema_version": CACHE_SCHEMA_VERSION, "entries": {}}
try:
if path.stat().st_size > MAX_CACHE_BYTES:
return {"schema_version": CACHE_SCHEMA_VERSION, "entries": {}}
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError):
return {"schema_version": CACHE_SCHEMA_VERSION, "entries": {}}
if not isinstance(payload, dict) or payload.get("schema_version") != CACHE_SCHEMA_VERSION:
return {"schema_version": CACHE_SCHEMA_VERSION, "entries": {}}
if not isinstance(payload.get("entries"), dict):
payload["entries"] = {}
payload["entries"] = _bounded_entries(payload["entries"])
return payload
def _bounded_entries(entries: Any) -> dict[str, Any]:
if not isinstance(entries, dict):
return {}
ordered = sorted(
(
(path, value)
for path, value in entries.items()
if (
isinstance(path, str)
and isinstance(value, dict)
and isinstance(value.get("text"), str)
)
),
key=lambda row: int(row[1].get("mtime_ns") or 0),
reverse=True,
)
base_bytes = len(
json.dumps(
{"schema_version": CACHE_SCHEMA_VERSION, "entries": {}},
ensure_ascii=False,
).encode("utf-8")
)
used_bytes = base_bytes
bounded: dict[str, Any] = {}
for path, value in ordered[:MAX_CACHE_ENTRIES]:
normalized = {
"mtime_ns": value.get("mtime_ns"),
"size": value.get("size"),
"text": value["text"][:MAX_CACHE_TEXT_CHARS],
}
fragment = json.dumps({path: normalized}, ensure_ascii=False).encode("utf-8")
fragment_bytes = len(fragment) - 2 + (2 if bounded else 0)
if used_bytes + fragment_bytes > MAX_CACHE_BYTES:
continue
bounded[path] = normalized
used_bytes += fragment_bytes
return bounded
def _cache_entry_fragment_size(path: str, value: dict[str, Any]) -> int:
return len(json.dumps({path: value}, ensure_ascii=False).encode("utf-8")) - 2
def _cache_entry_put(
entries: dict[str, Any],
sizes: dict[str, int],
path: str,
value: dict[str, Any],
) -> None:
entries[path] = value
sizes[path] = _cache_entry_fragment_size(path, value)
while (
len(entries) > MAX_CACHE_ENTRIES
or _cache_payload_size(sizes) > MAX_CACHE_BYTES
):
oldest = min(
entries,
key=lambda candidate: (
int(entries[candidate].get("mtime_ns") or 0),
candidate,
),
)
del entries[oldest]
del sizes[oldest]
def _cache_payload_size(sizes: dict[str, int]) -> int:
base_bytes = len(
json.dumps(
{"schema_version": CACHE_SCHEMA_VERSION, "entries": {}},
ensure_ascii=False,
).encode("utf-8")
)
separators = max(0, len(sizes) - 1) * 2
return base_bytes + sum(sizes.values()) + separators
def _write_cache(path: Path | None, payload: dict[str, Any], notes: list[str]) -> None:
if path is None:
return
try:
_ensure_private_directory(path.parent)
payload["entries"] = _bounded_entries(payload.get("entries", {}))
encoded = json.dumps(payload, ensure_ascii=False).encode("utf-8")
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
try:
fd = os.open(temporary, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
except FileExistsError:
temporary.unlink()
fd = os.open(temporary, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
with os.fdopen(fd, "wb") as handle:
handle.write(encoded)
temporary.replace(path)
path.chmod(0o600)
except OSError as exc:
notes.append(f"Corpus cache unavailable: {_safe_error(exc)}")
def _ensure_private_directory(path: Path) -> None:
missing: list[Path] = []
current = path
while not current.exists():
missing.append(current)
current = current.parent
path.mkdir(parents=True, exist_ok=True, mode=0o700)
for directory in missing:
directory.chmod(0o700)
+6
View File
@@ -470,6 +470,12 @@ def get_config(policy: ConfigLoadPolicy | None = None) -> dict[str, Any]:
# degraded (neither ok, no-results, nor skipped-unconfigured). #384.
('LAST30DAYS_STRICT_EXIT', None),
('LAST30DAYS_MEMORY_DIR', None),
# Optional local-only evidence source. Paths are separated with the
# platform path separator (":" on macOS/Linux, ";" on Windows).
('LAST30DAYS_CORPUS_DIRS', None),
# Corpus evidence is omitted from the stable agent JSON export unless
# this explicit privacy opt-in is truthy.
('LAST30DAYS_CORPUS_IN_EXPORT', None),
('LAST30DAYS_LIBRARY_OWNER', None),
('LAST30DAYS_LIBRARY_CONTEXT', 'on'),
('LAST30DAYS_PUBLISH_PASSWORD', None),
+15 -1
View File
@@ -438,10 +438,12 @@ def render_html_comparison(
LIBRARY_BRIEF_MARKER = "<!-- generated by last30days library feed -->"
def render_library_brief(entry: LibraryEntry) -> str:
def render_library_brief(entry: LibraryEntry, *, include_private: bool = True) -> str:
"""Render a scanned Markdown or JSON briefing as a safe standalone page."""
md = _strip_invitation(entry.content)
md = _strip_canonical_boundary(md)
if not include_private:
md = _strip_private_corpus(md)
body = _markdown_to_html(md)
body = _wrap_engine_footer(body)
colophon = (
@@ -458,6 +460,18 @@ def render_library_brief(entry: LibraryEntry) -> str:
return rendered.replace("</body>", f"{LIBRARY_BRIEF_MARKER}\n</body>", 1)
_PRIVATE_CORPUS_BLOCK = re.compile(
r"<!-- LAST30DAYS_PRIVATE_CORPUS_START -->.*?"
r"<!-- LAST30DAYS_PRIVATE_CORPUS_END -->\s*",
re.DOTALL,
)
def _strip_private_corpus(markdown: str) -> str:
"""Remove the renderer-marked local corpus section before publication."""
return _PRIVATE_CORPUS_BLOCK.sub("", markdown)
def render_library_index(
entries: Sequence[LibraryEntry],
*,
+10 -4
View File
@@ -29,6 +29,11 @@ _LIBRARY_ID = re.compile(r"[0-9a-f]{32}")
_GENERATED_BRIEF_NAME = re.compile(
r"[a-z0-9]+(?:-[a-z0-9]+)*-[0-9a-f]{8}-\d{4}-\d{2}-\d{2}\.html"
)
_PRIVATE_CORPUS_BLOCK = re.compile(
r"<!-- LAST30DAYS_PRIVATE_CORPUS_START -->.*?"
r"<!-- LAST30DAYS_PRIVATE_CORPUS_END -->\s*",
re.DOTALL,
)
@dataclass(frozen=True, slots=True)
@@ -140,15 +145,16 @@ def _keep_preferred(entries: dict[str, LibraryEntry], entry: LibraryEntry) -> No
def _parse_markdown(path: Path) -> LibraryEntry:
content = path.read_text(encoding="utf-8")
title_match = _REPORT_TITLE.search(content) or _FIRST_TITLE.search(content)
public_content = _PRIVATE_CORPUS_BLOCK.sub("", content)
title_match = _REPORT_TITLE.search(public_content) or _FIRST_TITLE.search(public_content)
if not title_match:
raise ValueError("no Markdown title found")
topic = _clean_inline(title_match.group(1))
if not topic:
raise ValueError("empty Markdown title")
published_date = _markdown_date(content, path)
headline = _markdown_headline(content) or topic
summary = _markdown_summary(content) or headline
published_date = _markdown_date(public_content, path)
headline = _markdown_headline(public_content) or topic
summary = _markdown_summary(public_content) or headline
return LibraryEntry(
slug=slugify(topic),
topic=topic,
+44 -8
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import hashlib
import os
import re
import sqlite3
from dataclasses import dataclass, replace
@@ -14,6 +15,7 @@ from . import library
DEFAULT_LIBRARY_DB = library.DEFAULT_BRIEFS_DIR.parent / "library.db"
DEFAULT_STORE_DB = library.DEFAULT_BRIEFS_DIR.parent / "research.db"
INDEX_FINGERPRINT_VERSION = "last30days-library-index/v2"
LIBRARY_CONTEXT_START = "<!-- last30days:library-context:start -->"
LIBRARY_CONTEXT_END = "<!-- last30days:library-context:end -->"
_TOKEN = re.compile(r"[^\W_]+", re.UNICODE)
@@ -26,6 +28,11 @@ _LEGACY_LIBRARY_CONTEXT = re.compile(
r"^## From your library\s*$.*?(?=^##\s|\Z)",
re.MULTILINE | re.DOTALL,
)
_PRIVATE_CORPUS_BLOCK = re.compile(
r"<!-- LAST30DAYS_PRIVATE_CORPUS_START -->.*?"
r"<!-- LAST30DAYS_PRIVATE_CORPUS_END -->\s*",
re.DOTALL,
)
class LibrarySearchUnavailable(RuntimeError):
@@ -131,7 +138,7 @@ def index_brief(
if entry is None:
return False
target = Path(db_path).expanduser()
target.parent.mkdir(parents=True, exist_ok=True)
_ensure_private_directory(target.parent)
with _connect(target) as conn:
_upsert_entry(conn, entry)
conn.commit()
@@ -209,7 +216,7 @@ def _sync_library(
db_path: Path,
) -> SyncResult:
entries, notes = library.scan_library(memory_dir, briefs_dir)
db_path.parent.mkdir(parents=True, exist_ok=True)
_ensure_private_directory(db_path.parent)
indexed = unchanged = 0
with _connect(db_path) as conn:
existing = {
@@ -250,6 +257,15 @@ def _sync_library(
def _connect(path: Path) -> sqlite3.Connection:
_ensure_private_directory(path.parent)
if not path.exists():
try:
fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
except FileExistsError:
pass
else:
os.close(fd)
path.chmod(0o600)
conn = sqlite3.connect(str(path))
try:
conn.row_factory = sqlite3.Row
@@ -268,7 +284,13 @@ def _upsert_entry(
fingerprint: str | None = None,
) -> None:
stat = entry.source_path.stat()
indexed_content = _indexable_content(entry.content)
private_free_content = _PRIVATE_CORPUS_BLOCK.sub("", entry.content)
indexed_content = _indexable_content(private_free_content)
headline = entry.headline
summary = entry.summary
if private_free_content != entry.content and entry.source_format == "markdown":
headline = library._markdown_headline(private_free_content) or entry.topic
summary = library._markdown_summary(private_free_content) or headline
content_hash = fingerprint or _fingerprint(indexed_content)
source_path = str(entry.source_path.resolve())
replaced = conn.execute(
@@ -302,14 +324,14 @@ def _upsert_entry(
content_hash,
entry.topic,
entry.published_date.isoformat(),
entry.headline,
entry.summary,
headline,
summary,
entry.source_format,
),
)
conn.execute(
"INSERT INTO library_fts(entry_id, topic, headline, summary, content) VALUES (?, ?, ?, ?, ?)",
(entry.entry_id, entry.topic, entry.headline, entry.summary, indexed_content),
(entry.entry_id, entry.topic, headline, summary, indexed_content),
)
@@ -334,6 +356,7 @@ def _search_store_sightings(
JOIN research_runs rr ON rr.id = fs.run_id
JOIN topics t ON t.id = fs.topic_id
WHERE findings_fts MATCH ? AND rr.status = 'completed'
AND fs.source != 'corpus'
ORDER BY rank, rr.run_date DESC
LIMIT ?""",
(expression, limit),
@@ -371,7 +394,8 @@ def _fts_expression(query: str) -> str:
def _fingerprint(content: str) -> str:
return hashlib.sha256(content.encode("utf-8")).hexdigest()
payload = f"{INDEX_FINGERPRINT_VERSION}\0{content}"
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
def _clean_snippet(value: object) -> str:
@@ -379,10 +403,22 @@ def _clean_snippet(value: object) -> str:
def _indexable_content(content: str) -> str:
without_marked = _MARKED_LIBRARY_CONTEXT.sub("", content)
without_private = _PRIVATE_CORPUS_BLOCK.sub("", content)
without_marked = _MARKED_LIBRARY_CONTEXT.sub("", without_private)
return _LEGACY_LIBRARY_CONTEXT.sub("", without_marked)
def _ensure_private_directory(path: Path) -> None:
missing: list[Path] = []
current = path
while not current.exists():
missing.append(current)
current = current.parent
path.mkdir(parents=True, exist_ok=True, mode=0o700)
for directory in missing:
directory.chmod(0o700)
def _is_confirmed_corruption(exc: sqlite3.DatabaseError) -> bool:
message = str(exc).casefold()
return any(
+140 -5
View File
@@ -19,6 +19,7 @@ from . import (
arxiv,
bird_x,
bluesky,
corpus,
dates,
dedupe,
digg,
@@ -125,6 +126,7 @@ MOCK_AVAILABLE_SOURCES = [
"trustpilot",
"jobs",
"linkedin",
"corpus",
]
@@ -156,6 +158,10 @@ def available_sources(
available: list[str] = []
# reddit_public needs no API key - always available
available.append("reddit")
if corpus.resolve_directories(
config.get("_CORPUS_DIRS"), config.get("LAST30DAYS_CORPUS_DIRS")
):
available.append("corpus")
if config.get("SCRAPECREATORS_API_KEY"):
available.extend(["tiktok", "instagram"])
if env.get_x_source(config, local_only=local_only):
@@ -889,10 +895,25 @@ def run(
hiring_signals_mode: bool = False,
internal_subrun: bool = False,
save_dir: Path | str | None = None,
corpus_dirs: list[str] | None = None,
corpus_all_time: bool = False,
) -> schema.Report:
settings = DEPTH_SETTINGS[depth]
requested_sources = normalize_requested_sources(requested_sources)
from_date, to_date = dates.get_date_range(lookback_days, as_of_date=as_of_date)
resolved_corpus_dirs = corpus.resolve_directories(
corpus_dirs or config.get("_CORPUS_DIRS"),
config.get("LAST30DAYS_CORPUS_DIRS"),
)
excluded_sources = {
source.strip().lower()
for source in str(config.get("EXCLUDE_SOURCES") or "").split(",")
if source.strip()
}
corpus_enabled = bool(resolved_corpus_dirs) and "corpus" not in excluded_sources
corpus_requested = bool(requested_sources and "corpus" in requested_sources)
if corpus_enabled and requested_sources and "corpus" not in requested_sources:
requested_sources = [*requested_sources, "corpus"]
# Gate StockTwits to ticker/crypto topics. Single chokepoint: when False,
# available_sources() never registers stocktwits, so the planner can't
@@ -903,6 +924,10 @@ def run(
runtime = providers.mock_runtime(config, depth)
reasoning_provider = None
available = list(requested_sources or MOCK_AVAILABLE_SOURCES)
if corpus_enabled and "corpus" not in available:
available.append("corpus")
if not corpus_enabled and not corpus_requested:
available = [source for source in available if source != "corpus"]
if not requested_sources and not hiring_signals_mode and not _company_topic_likely(topic):
available = [source for source in available if source != "jobs"]
else:
@@ -910,6 +935,11 @@ def run(
available = available_sources(config, requested_sources)
if requested_sources:
available = [source for source in available if source in requested_sources]
# Keep an explicitly requested but unconfigured corpus in the plan long
# enough to record its skipped-unconfigured source outcome. It is never
# submitted to the network executor below.
if corpus_requested and "corpus" not in excluded_sources and "corpus" not in available:
available.append("corpus")
if web_backend == "none":
available = [s for s in available if s != "grounding"]
elif web_backend in ("brave", "exa", "serper", "parallel", "keyless") and "grounding" not in available:
@@ -970,6 +1000,15 @@ def run(
# Drill plans re-fetch only the sources that contributed to the matched
# cluster; the company-topic jobs injection must not widen that set.
_ensure_jobs_in_plan(plan, available, explicit=hiring_signals_mode, topic=topic)
if "corpus" in available and plan.subqueries:
# Corpus is deterministic and user-registered, so it always gets one
# bounded stream even when a quick/LLM plan omits it. Reuse the primary
# subquery instead of multiplying local scans across every subquery.
if "corpus" not in plan.subqueries[0].sources:
plan.subqueries[0].sources.append("corpus")
if "corpus" not in plan.source_weights:
plan.source_weights["corpus"] = 1.0
plan.source_weights = planner._normalize_weights(plan.source_weights)
# Always-on planner trace. Emits one summary line plus one per subquery
# so retrieval-breadth failures like the 2026-04-19 Hermes Agent Use Cases
@@ -1001,10 +1040,18 @@ def run(
"Source was requested but is not configured for this run.",
attempted=False,
)
if corpus_requested and not corpus_enabled:
bundle.record_failure(
"corpus",
schema.SKIPPED_UNCONFIGURED,
"Corpus was requested but no readable directory was configured.",
attempted=False,
)
# Expose plan_source to the renderer so render_compact can emit the
# DEGRADED RUN banner when a named-entity topic was invoked bare
# (source=deterministic AND no pre-research flags). LAW 7 backstop.
bundle.artifacts["plan_source"] = plan_source
bundle.artifacts["corpus_in_export"] = bool(config.get("_CORPUS_IN_EXPORT"))
# Hiring-signals is deliberately jobs-only with no multi-source --plan, so
# the LAW 7 degraded-run and Step 0.55 pre-research banners do not apply -
# they would contradict the documented jobs-scoped flow. Suppress them.
@@ -1070,6 +1117,53 @@ def run(
rate_limited_sources: set[str] = set()
rate_limit_lock = threading.Lock()
# Local corpus retrieval is intentionally outside the network executor and
# retry budget. One bounded stream participates in the same signal scoring,
# fusion, reranking, and per-source result cap as remote sources.
if corpus_enabled and plan.subqueries:
primary = plan.subqueries[0]
bundle.mark_attempted("corpus")
result = corpus.search(
topic,
resolved_corpus_dirs,
from_date=from_date,
to_date=to_date,
all_time=corpus_all_time,
limit=settings["per_stream_limit"],
cache_dir=env.CONFIG_DIR,
)
prepared_query = relevance.PreparedQuery(primary.ranking_query)
lookback_window_days = (
datetime.strptime(to_date, "%Y-%m-%d").date()
- datetime.strptime(from_date, "%Y-%m-%d").date()
).days
corpus_items = signals.annotate_stream(
result.items,
prepared_query,
plan.freshness_mode,
reference_date=to_date,
max_days=lookback_window_days,
)
corpus_items = signals.prune_low_relevance(corpus_items)
corpus_items = dedupe.dedupe_items(corpus_items)
for item in corpus_items:
item.snippet = snippet.extract_best_snippet(item, prepared_query)
bundle.add_items(primary.label, "corpus", corpus_items)
if result.notes:
outcome = bundle.source_status["corpus"]
bundle.source_status["corpus"] = schema.SourceOutcome(
source="corpus",
state=outcome.state,
items_returned=outcome.items_returned,
attempted=True,
detail="; ".join(result.notes),
)
bundle.artifacts["corpus"] = {
"files_scanned": result.files_scanned,
"cache_hits": result.cache_hits,
"all_time": corpus_all_time,
}
futures = {}
# Per-source fetch budget prevents redundant API calls
source_fetch_count: dict[str, int] = {}
@@ -1077,7 +1171,7 @@ def run(
1
for subquery in plan.subqueries
for source in subquery.sources
if source in available
if source in available and source != "corpus"
)
max_workers = _inner_max_workers(stream_count, internal_subrun=internal_subrun)
with ThreadPoolExecutor(max_workers=max_workers) as executor:
@@ -1085,6 +1179,8 @@ def run(
for source in subquery.sources:
if source not in available:
continue
if source == "corpus":
continue
# Skip GitHub keyword search if person-mode already ran
if source == "github" and (_github_person_done or _github_custom_done):
continue
@@ -1206,7 +1302,9 @@ def run(
# Phase 2b: retry thin sources with simplified query
# Note: _github_skip_sources tells the retry to not re-run GitHub keyword search
# when project-mode or person-mode already provided authoritative data.
_github_skip_retry = {"github"} if (_github_person_done or _github_custom_done) else set()
_github_skip_retry = {"corpus"}
if _github_person_done or _github_custom_done:
_github_skip_retry.add("github")
_retry_thin_sources(
topic=topic,
bundle=bundle,
@@ -1258,21 +1356,58 @@ def run(
for h in ([x_handle, github_user, *(x_related or [])])
if h and h.strip()
}
ranked_candidates = rerank.rerank_candidates(
private_candidates = [
candidate
for candidate in candidates
if candidate.source == "corpus"
or any(item.source == "corpus" for item in candidate.source_items)
]
private_candidate_ids = {id(candidate) for candidate in private_candidates}
public_candidates = [
candidate for candidate in candidates if id(candidate) not in private_candidate_ids
]
ranked_public = rerank.rerank_candidates(
topic=topic,
plan=plan,
candidates=candidates,
candidates=public_candidates,
provider=None if mock else reasoning_provider,
model=None if mock else runtime.rerank_model,
shortlist_size=settings["rerank_limit"],
resolved_handles=resolved_handles,
)
# Corpus titles/snippets must never enter a hosted reasoning prompt. Score
# every candidate carrying corpus evidence with the deterministic fallback,
# even when the rest of the run uses a remote reranker.
ranked_private = rerank.rerank_candidates(
topic=topic,
plan=plan,
candidates=private_candidates,
provider=None,
model=None,
shortlist_size=settings["rerank_limit"],
resolved_handles=resolved_handles,
)
ranked_candidates = sorted(
[*ranked_public, *ranked_private],
key=lambda candidate: (
-candidate.final_score,
-(candidate.engagement or -1),
min(candidate.native_ranks.values(), default=999),
candidate.title,
),
)
rerank.score_fun(
topic=topic,
candidates=ranked_candidates,
candidates=ranked_public,
provider=None if mock else reasoning_provider,
model=None if mock else runtime.rerank_model,
)
rerank.score_fun(
topic=topic,
candidates=ranked_private,
provider=None,
model=None,
)
# Phase 3: post-rerank GitHub star enrichment. Record/replay-aware so the
# eval harness stays fully offline: this path calls the GitHub API (and the
+1
View File
@@ -131,6 +131,7 @@ SOURCE_CAPABILITIES = {
"grounding": {"web", "reference", "link"},
"perplexity": {"web", "reference", "analysis"},
"jobs": {"jobs", "company_signal", "link"},
"corpus": {"reference", "analysis"},
}
DEFAULT_INTENT_CAPABILITIES = {
"comparison": {"discussion", "video", "web", "reference", "social", "link", "market"},
+119 -31
View File
@@ -131,8 +131,12 @@ SOURCE_LABELS = {
"trustpilot": "Trustpilot",
"perplexity": "Perplexity",
"jobs": "Jobs",
"corpus": "Your files",
}
PRIVATE_CORPUS_START = "<!-- LAST30DAYS_PRIVATE_CORPUS_START -->"
PRIVATE_CORPUS_END = "<!-- LAST30DAYS_PRIVATE_CORPUS_END -->"
# vote_weight = max points a fully on-topic, max-upvoted top comment can add to
# the LLM humor score. Tuned against real runs: typical funny comments score
@@ -277,6 +281,48 @@ def _render_ranked_clusters(
return lines
def _render_corpus_section(report: schema.Report, limit: int = 8) -> list[str]:
"""Render private local evidence in one removable, clearly badged block."""
candidates = [
candidate
for candidate in report.ranked_candidates
if candidate.source == "corpus"
][:limit]
if not candidates:
return []
lines = [
PRIVATE_CORPUS_START,
"## From your files",
"",
"> 🔒 **LOCAL ONLY** - excluded from hosted publishing and agent JSON unless explicitly opted in.",
"",
]
for candidate in candidates:
primary = schema.candidate_primary_item(candidate)
path = str((primary.metadata if primary else {}).get("relative_path") or "")
published = primary.published_at if primary else None
detail = f"modified {published}" if published else "modification date unknown"
lines.append(
f"- **{_defang_corpus_sentinels(candidate.title)}** "
f"({detail}, relevance {candidate.final_score:.0f})"
)
if path:
lines.append(f" - File: `{_defang_corpus_sentinels(path)}`")
if candidate.snippet:
lines.append(f" - {_defang_corpus_sentinels(_truncate(candidate.snippet, 300))}")
lines.append(PRIVATE_CORPUS_END)
return lines
def _defang_corpus_sentinels(value: str) -> str:
"""Source content must not be able to terminate the private-block markers.
A note containing the literal end marker would otherwise close the block
early, leaving later corpus snippets in publishable output.
"""
return value.replace("LAST30DAYS_PRIVATE_CORPUS", "LAST30DAYS_PRIVATE-CORPUS")
_FRESHNESS_PRIORITY = {
"contradicted": 0,
"stale": 1,
@@ -413,6 +459,7 @@ def render_compact(
register: str = "default",
) -> str:
audience = registers.get_register(register)
evidence_report = schema.without_sources(report, {"corpus"})
non_empty = [s for s, items in sorted(report.items_by_source.items()) if items]
lines = [
*_render_badge(),
@@ -461,7 +508,7 @@ def render_compact(
# block below) vs "synthesize from" (this block).
lines.append("<!-- EVIDENCE FOR SYNTHESIS: read this, do not emit verbatim. Transform into `What I learned:` prose per LAW 2. -->")
lines.append("")
hiring_block = _render_hiring_signals(report)
hiring_block = _render_hiring_signals(evidence_report)
if hiring_block and audience.name in {"default", "eli5"}:
lines.extend(hiring_block)
lines.append("")
@@ -469,11 +516,11 @@ def render_compact(
if audience.name in {"default", "eli5"}:
# Keep this legacy assembly byte-for-byte stable. ELI5 has always been
# a synthesis-only voice change, so it intentionally takes this path.
lines.extend(_render_ranked_clusters(report, report.clusters[:cluster_limit]))
lines.extend(_render_stats(report))
lines.extend(_render_ranked_clusters(evidence_report, evidence_report.clusters[:cluster_limit]))
lines.extend(_render_stats(evidence_report))
best_takes = _render_best_takes(
report.ranked_candidates,
evidence_report.ranked_candidates,
limit=fun_params["limit"],
threshold=fun_params["threshold"],
vote_weight=fun_params.get("vote_weight", 18.0),
@@ -481,7 +528,7 @@ def render_compact(
if best_takes:
lines.extend([""] + best_takes)
top_comments = _render_top_comments(report)
top_comments = _render_top_comments(evidence_report)
if top_comments:
lines.extend([""] + top_comments)
@@ -491,7 +538,10 @@ def render_compact(
lines.extend(_render_source_coverage(report))
else:
lines.extend(_render_registered_sections(report, audience, fun_params, cluster_limit))
lines.extend(_render_registered_sections(evidence_report, audience, fun_params, cluster_limit))
corpus_section = _render_corpus_section(report)
if corpus_section:
lines.extend(["", *corpus_section])
# Close EVIDENCE FOR SYNTHESIS envelope before anything that passes through verbatim.
lines.append("")
lines.append("<!-- END EVIDENCE FOR SYNTHESIS -->")
@@ -545,6 +595,7 @@ def render_for_html(
sections so direct HTML output reflects the selected audience preset.
"""
audience = registers.get_register(register)
evidence_report = schema.without_sources(report, {"corpus"})
lines = [
*_render_badge(),
*_render_html_metadata(report),
@@ -552,7 +603,7 @@ def render_for_html(
drill_context = _render_drill_context(report)
if drill_context:
lines.extend(["", *drill_context])
hiring_block = _render_hiring_signals(report)
hiring_block = _render_hiring_signals(evidence_report)
if synthesis_md:
lines.extend(["", synthesis_md.strip()])
if hiring_block and "## Hiring Signals" not in synthesis_md:
@@ -564,13 +615,16 @@ def render_for_html(
lines.extend([
"",
*_render_registered_sections(
report,
evidence_report,
audience,
fun_params,
8,
include_source_diagnostics=False,
),
])
corpus_section = _render_corpus_section(report)
if corpus_section:
lines.extend(["", *corpus_section])
freshness_verdicts = _render_freshness_verdicts(report)
if freshness_verdicts:
lines.extend(["", *freshness_verdicts])
@@ -613,6 +667,9 @@ def render_for_html_comparison(
freshness_verdicts = _render_freshness_verdicts(report)
if freshness_verdicts:
lines.extend(["", f"## {label}", "", *freshness_verdicts])
corpus_section = _render_corpus_section(report)
if corpus_section:
lines.extend(["", f"## {label}", "", *corpus_section])
# Comparison data quality notes also go to stderr, not into the artifact.
_append_html_footer(lines, main_report, save_path)
return "\n".join(lines).strip() + "\n"
@@ -1100,17 +1157,22 @@ def _render_entity_evidence_block(
fun_params: dict,
) -> list[str]:
"""Render one entity's clusters and best-takes inside the evidence envelope."""
candidate_by_id = {c.candidate_id: c for c in report.ranked_candidates}
evidence_report = schema.without_sources(report, {"corpus"})
candidate_by_id = {c.candidate_id: c for c in evidence_report.ranked_candidates}
out: list[str] = [f"## {label}", ""]
if not report.clusters:
if not evidence_report.clusters:
out.append("(no significant discussion this month)")
out.append("")
corpus_section = _render_corpus_section(report)
if corpus_section:
out.extend(corpus_section)
out.append("")
return out
out.append("### Ranked Evidence Clusters")
out.append("")
for index, cluster in enumerate(report.clusters[:cluster_limit], start=1):
for index, cluster in enumerate(evidence_report.clusters[:cluster_limit], start=1):
out.append(
f"#### {index}. {cluster.title} "
f"(score {cluster.score:.0f}, {len(cluster.candidate_ids)} item"
@@ -1123,11 +1185,11 @@ def _render_entity_evidence_block(
candidate = candidate_by_id.get(candidate_id)
if not candidate:
continue
out.extend(_render_candidate(candidate, prefix=f"{rep_index}.", report=report))
out.extend(_render_candidate(candidate, prefix=f"{rep_index}.", report=evidence_report))
out.append("")
best_takes = _render_best_takes(
report.ranked_candidates,
evidence_report.ranked_candidates,
limit=fun_params["limit"],
threshold=fun_params["threshold"],
vote_weight=fun_params.get("vote_weight", 18.0),
@@ -1136,6 +1198,11 @@ def _render_entity_evidence_block(
out.extend(best_takes)
out.append("")
corpus_section = _render_corpus_section(report)
if corpus_section:
out.extend(corpus_section)
out.append("")
return out
@@ -1159,22 +1226,27 @@ def render_comparison_multi_context(
lines.extend(resolved_block)
lines.append("")
for label, report in entity_reports:
evidence_report = schema.without_sources(report, {"corpus"})
lines.append(f"## {label}")
lines.append(f"Intent: {report.query_plan.intent}")
if not report.clusters:
if not evidence_report.clusters:
lines.append("- (no significant discussion this month)")
else:
for cluster in report.clusters[:cluster_limit]:
for cluster in evidence_report.clusters[:cluster_limit]:
lines.append(
f"- {cluster.title} "
f"[{', '.join(_source_label(s) for s in cluster.sources)}]"
)
corpus_section = _render_corpus_section(report)
if corpus_section:
lines.extend(["", *corpus_section])
lines.append("")
return "\n".join(lines).strip() + "\n"
def render_full(report: schema.Report) -> str:
"""Full data dump: ALL clusters + ALL items by source. For saved files and debugging."""
evidence_report = schema.without_sources(report, {"corpus"})
# Start with the same header as compact
non_empty = [s for s, items in sorted(report.items_by_source.items()) if items]
lines = [
@@ -1209,8 +1281,8 @@ def render_full(report: schema.Report) -> str:
# ALL clusters (no limit)
lines.append("## Ranked Evidence Clusters")
lines.append("")
candidate_by_id = {c.candidate_id: c for c in report.ranked_candidates}
for index, cluster in enumerate(report.clusters, start=1):
candidate_by_id = {c.candidate_id: c for c in evidence_report.ranked_candidates}
for index, cluster in enumerate(evidence_report.clusters, start=1):
lines.append(
f"### {index}. {cluster.title} "
f"(score {cluster.score:.0f}, {len(cluster.candidate_ids)} item{'s' if len(cluster.candidate_ids) != 1 else ''}, "
@@ -1222,12 +1294,12 @@ def render_full(report: schema.Report) -> str:
candidate = candidate_by_id.get(cid)
if not candidate:
continue
lines.extend(_render_candidate(candidate, prefix=f"{rep_index}.", report=report))
lines.extend(_render_candidate(candidate, prefix=f"{rep_index}.", report=evidence_report))
lines.append("")
fun_params = _FUN_LEVELS["medium"]
best_takes = _render_best_takes(
report.ranked_candidates,
evidence_report.ranked_candidates,
limit=fun_params["limit"],
threshold=fun_params["threshold"],
vote_weight=fun_params["vote_weight"],
@@ -1242,7 +1314,7 @@ def render_full(report: schema.Report) -> str:
source_order = ["reddit", "x", "youtube", "tiktok", "instagram", "threads", "pinterest",
"hackernews", "bluesky", "truthsocial", "polymarket", "grounding", "xiaohongshu", "github", "digg", "perplexity", "jobs"]
for source in source_order:
items = report.items_by_source.get(source, [])
items = evidence_report.items_by_source.get(source, [])
if not items:
continue
lines.append(f"### {_source_label(source)} ({len(items)} items)")
@@ -1308,12 +1380,17 @@ def render_full(report: schema.Report) -> str:
lines.append(f" Closes: {end_date}")
lines.append("")
freshness_verdicts = _render_freshness_verdicts(report)
corpus_section = _render_corpus_section(report)
if corpus_section:
lines.extend(corpus_section)
lines.append("")
freshness_verdicts = _render_freshness_verdicts(evidence_report)
if freshness_verdicts:
lines.extend(freshness_verdicts)
lines.append("")
lines.extend(_render_stats(report))
lines.extend(_render_source_coverage(report))
lines.extend(_render_stats(evidence_report))
lines.extend(_render_source_coverage(evidence_report))
return "\n".join(lines).strip() + "\n"
@@ -1332,7 +1409,8 @@ def _format_item_engagement(item: schema.SourceItem) -> str:
def render_context(report: schema.Report, cluster_limit: int = 6) -> str:
candidate_by_id = {candidate.candidate_id: candidate for candidate in report.ranked_candidates}
evidence_report = schema.without_sources(report, {"corpus"})
candidate_by_id = {candidate.candidate_id: candidate for candidate in evidence_report.ranked_candidates}
lines = [
f"Topic: {report.topic}",
f"Intent: {report.query_plan.intent}",
@@ -1351,7 +1429,7 @@ def render_context(report: schema.Report, cluster_limit: int = 6) -> str:
if hiring_block:
lines.extend(["", *hiring_block, ""])
lines.append("Top clusters:")
for cluster in report.clusters[:cluster_limit]:
for cluster in evidence_report.clusters[:cluster_limit]:
lines.append(f"- {cluster.title} [{', '.join(_source_label(source) for source in cluster.sources)}]")
for candidate_id in cluster.representative_ids[:2]:
candidate = candidate_by_id.get(candidate_id)
@@ -1366,6 +1444,9 @@ def render_context(report: schema.Report, cluster_limit: int = 6) -> str:
lines.append(f" - {' | '.join(detail_parts)}")
if candidate.snippet:
lines.append(f" Evidence: {_truncate(candidate.snippet, 180)}")
corpus_section = _render_corpus_section(report)
if corpus_section:
lines.extend(["", *corpus_section])
if report.warnings:
lines.append("Warnings:")
lines.extend(f"- {warning}" for warning in report.warnings)
@@ -1386,6 +1467,7 @@ def render_brief(report: schema.Report, cluster_limit: int = 8) -> str:
Audience Questions, and Source Clusters. Sections 2-4 are omitted when there
is no matching data; Sections 1 and 5 always appear.
"""
evidence_report = schema.without_sources(report, {"corpus"})
non_empty = [s for s, items in sorted(report.items_by_source.items()) if items]
lines = [
f"# Production Brief: {report.topic}",
@@ -1404,8 +1486,8 @@ def render_brief(report: schema.Report, cluster_limit: int = 8) -> str:
lines.append("## Ranked Storylines")
lines.append("")
candidate_by_id = {c.candidate_id: c for c in report.ranked_candidates}
for i, cluster in enumerate(report.clusters[:cluster_limit], start=1):
candidate_by_id = {c.candidate_id: c for c in evidence_report.ranked_candidates}
for i, cluster in enumerate(evidence_report.clusters[:cluster_limit], start=1):
source_tags = ", ".join(_source_label(s) for s in cluster.sources)
qualifier = f" [{cluster.uncertainty.replace('-', ' ')}]" if cluster.uncertainty else ""
lines.append(f"### {i}. {cluster.title} (score {cluster.score:.0f}, {source_tags}){qualifier}")
@@ -1421,7 +1503,7 @@ def render_brief(report: schema.Report, cluster_limit: int = 8) -> str:
lines.append("")
hooks = sorted(
(c for c in report.ranked_candidates if c.fun_score is not None and c.fun_score >= 70),
(c for c in evidence_report.ranked_candidates if c.fun_score is not None and c.fun_score >= 70),
key=lambda c: -(c.fun_score or 0),
)
if hooks:
@@ -1449,7 +1531,7 @@ def render_brief(report: schema.Report, cluster_limit: int = 8) -> str:
)
lines.append("")
tensions = [c for c in report.clusters[:cluster_limit] if c.uncertainty]
tensions = [c for c in evidence_report.clusters[:cluster_limit] if c.uncertainty]
if tensions:
lines.append("## Topic Tensions")
lines.append("")
@@ -1459,7 +1541,7 @@ def render_brief(report: schema.Report, cluster_limit: int = 8) -> str:
lines.append(f"- **{cluster.title}** [{label}]: {source_tags}")
lines.append("")
questions = _extract_audience_questions(report.ranked_candidates)
questions = _extract_audience_questions(evidence_report.ranked_candidates)
if questions:
lines.append("## Audience Questions")
lines.append("")
@@ -1469,11 +1551,16 @@ def render_brief(report: schema.Report, cluster_limit: int = 8) -> str:
lines.append("## Source Clusters")
lines.append("")
for cluster in report.clusters[:cluster_limit]:
for cluster in evidence_report.clusters[:cluster_limit]:
source_tags = " + ".join(_source_label(s) for s in cluster.sources)
lines.append(f"- **{cluster.title}**: {source_tags}")
lines.append("")
corpus_section = _render_corpus_section(report)
if corpus_section:
lines.extend(corpus_section)
lines.append("")
freshness_verdicts = _render_freshness_verdicts(report)
if freshness_verdicts:
lines.extend(freshness_verdicts)
@@ -1925,6 +2012,7 @@ _FOOTER_SOURCES: list[tuple[str, str, str, str, list[tuple[str, str]]]] = [
# the LAW 5 footer; without it the footer was dropped entirely.
("jobs", "💼", "Jobs", "role", []),
("perplexity", "🧠", "Perplexity", "result", [("citations", "citations")]),
("corpus", "🔒", "Your files", "file", []),
]
+112 -2
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import copy
from dataclasses import asdict, dataclass, field, is_dataclass
from datetime import datetime, timezone
from typing import Any, Literal
@@ -558,6 +559,103 @@ def candidate_primary_item(candidate: Candidate) -> SourceItem | None:
AGENT_EXPORT_SCHEMA_VERSION = "1.2"
def without_sources(report: Report, excluded_sources: set[str]) -> Report:
"""Return a deep-copied report with private source evidence removed.
This is the publication boundary used by agent JSON, hosted HTML, and
future outbound surfaces. Cluster titles are rebuilt when a removed item
participated so text derived from a private representative cannot survive
after its candidate is gone.
"""
excluded = {source.lower() for source in excluded_sources}
if not excluded:
return copy.deepcopy(report)
clean = copy.deepcopy(report)
clean.items_by_source = {
source: items
for source, items in clean.items_by_source.items()
if source.lower() not in excluded
}
clean.errors_by_source = {
source: detail
for source, detail in clean.errors_by_source.items()
if source.lower() not in excluded
}
clean.source_status = {
source: outcome
for source, outcome in clean.source_status.items()
if source.lower() not in excluded
}
clean.query_plan.source_weights = {
source: weight
for source, weight in clean.query_plan.source_weights.items()
if source.lower() not in excluded
}
for subquery in clean.query_plan.subqueries:
subquery.sources[:] = [
source for source in subquery.sources if source.lower() not in excluded
]
kept_candidates: list[Candidate] = []
removed_candidate_ids: set[str] = set()
for candidate in clean.ranked_candidates:
if candidate.source.lower() in excluded:
removed_candidate_ids.add(candidate.candidate_id)
continue
candidate.source_items = [
item for item in candidate.source_items if item.source.lower() not in excluded
]
candidate.sources = [
source for source in candidate.sources if source.lower() not in excluded
]
candidate.native_ranks = {
key: rank
for key, rank in candidate.native_ranks.items()
if key.rsplit(":", 1)[-1].lower() not in excluded
}
kept_candidates.append(candidate)
clean.ranked_candidates = kept_candidates
candidate_by_id = {
candidate.candidate_id: candidate for candidate in clean.ranked_candidates
}
kept_clusters: list[Cluster] = []
for cluster in clean.clusters:
original_ids = list(cluster.candidate_ids)
cluster.candidate_ids = [
candidate_id for candidate_id in original_ids if candidate_id in candidate_by_id
]
if not cluster.candidate_ids:
continue
cluster.representative_ids = [
candidate_id
for candidate_id in cluster.representative_ids
if candidate_id in candidate_by_id
] or [cluster.candidate_ids[0]]
cluster.sources = sorted({
source
for candidate_id in cluster.candidate_ids
for source in candidate_sources(candidate_by_id[candidate_id])
if source.lower() not in excluded
})
if any(candidate_id in removed_candidate_ids for candidate_id in original_ids):
cluster.title = candidate_by_id[cluster.representative_ids[0]].title
kept_clusters.append(cluster)
clean.clusters = kept_clusters
clean.freshness_verdicts = [
verdict
for verdict in clean.freshness_verdicts
if verdict.source.lower() not in excluded
and verdict.candidate_id in candidate_by_id
]
for key in list(clean.artifacts):
if any(source in key.lower() for source in excluded):
del clean.artifacts[key]
return clean
DISCOVERY_EXPORT_SCHEMA_VERSION = "1.0"
@@ -628,8 +726,20 @@ def _agent_generated_at(value: str) -> str:
return parsed.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
def to_agent_export(report: Report) -> dict[str, Any]:
"""Serialize a report to the stable, versioned agent JSON contract."""
def to_agent_export(
report: Report,
*,
corpus_in_export: bool | None = None,
) -> dict[str, Any]:
"""Serialize a report to the stable, versioned agent JSON contract.
Local corpus evidence is private by default. Callers must opt in explicitly
either with ``corpus_in_export=True`` or the CLI-populated report artifact.
"""
if corpus_in_export is None:
corpus_in_export = bool(report.artifacts.get("corpus_in_export"))
if not corpus_in_export:
report = without_sources(report, {"corpus"})
candidates = {candidate.candidate_id: candidate for candidate in report.ranked_candidates}
cluster_by_candidate: dict[str, int] = {}
cluster_by_id: dict[str, int] = {}
+1
View File
@@ -24,6 +24,7 @@ SOURCE_QUALITY = {
"instagram": 0.58,
"tiktok": 0.58,
"jobs": 0.72,
"corpus": 0.75,
}
+20
View File
@@ -12,6 +12,7 @@ Database location: ~/.local/share/last30days/research.db
import argparse
import json
import os
import sqlite3
import sys
from contextlib import contextmanager
@@ -55,6 +56,25 @@ def scoped_db(db_path: Optional[Path]) -> Iterator[None]:
_db_override = previous
def ensure_private_db_files(db_path: Optional[Path] = None) -> Path:
"""Create/harden the research database and SQLite sidecars owner-only."""
path = db_path or _get_db_path()
path.parent.mkdir(parents=True, exist_ok=True)
if not path.exists():
try:
fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
except FileExistsError:
pass
else:
os.close(fd)
for candidate in (path, Path(f"{path}-wal"), Path(f"{path}-shm")):
try:
candidate.chmod(0o600)
except FileNotFoundError:
pass
return path
SCHEMA_V1 = """
PRAGMA journal_mode=WAL;
PRAGMA synchronous=NORMAL;
+868
View File
@@ -0,0 +1,868 @@
from __future__ import annotations
import hashlib
import io
import json
import os
import sqlite3
import sys
from contextlib import redirect_stderr, redirect_stdout
from datetime import datetime, timezone
from pathlib import Path
from unittest import mock
import last30days as cli
import store
from lib import corpus, env, health, html_render, library, library_index, pipeline, render, schema
def _set_mtime(path: Path, value: str) -> None:
timestamp = datetime.fromisoformat(value).replace(tzinfo=timezone.utc).timestamp()
os.utime(path, (timestamp, timestamp))
def _scan(tmp_path: Path, *, all_time: bool = False, limit: int = 12):
return corpus.search(
"MCP servers",
[tmp_path],
from_date="2026-06-10",
to_date="2026-07-10",
all_time=all_time,
limit=limit,
cache_dir=tmp_path / "cache",
)
def test_scans_matching_text_and_markdown_with_path_titles(tmp_path):
note = tmp_path / "mcp-server-notes.md"
note.write_text("MCP servers expose tools to local coding agents.", encoding="utf-8")
other = tmp_path / "groceries.txt"
other.write_text("milk eggs bread", encoding="utf-8")
_set_mtime(note, "2026-07-05T12:00:00")
_set_mtime(other, "2026-07-05T12:00:00")
result = _scan(tmp_path)
assert [item.title for item in result.items] == ["mcp server notes"]
assert result.items[0].source == "corpus"
assert result.items[0].published_at == "2026-07-05"
assert result.items[0].metadata["local_only"] is True
assert result.items[0].url.startswith("corpus://")
assert str(note) not in result.items[0].url
def test_multilingual_matching_reuses_shared_cjk_tokenizer(tmp_path):
note = tmp_path / "模型记录.md"
note.write_text("国产大模型的最新测评和部署记录", encoding="utf-8")
_set_mtime(note, "2026-07-05T12:00:00")
result = corpus.search(
"国产大模型 测评",
[tmp_path],
from_date="2026-06-10",
to_date="2026-07-10",
cache_dir=tmp_path / "cache",
)
assert [item.title for item in result.items] == ["模型记录"]
def test_recency_window_and_all_time_override(tmp_path):
old = tmp_path / "old-mcp-plan.md"
old.write_text("MCP servers and local tool protocols", encoding="utf-8")
_set_mtime(old, "2025-01-01T00:00:00")
assert _scan(tmp_path).items == []
assert [item.title for item in _scan(tmp_path, all_time=True).items] == ["old mcp plan"]
def test_hidden_git_and_node_modules_directories_are_ignored(tmp_path):
visible = tmp_path / "visible.md"
visible.write_text("MCP servers are visible", encoding="utf-8")
_set_mtime(visible, "2026-07-05T00:00:00")
for directory in (tmp_path / ".git", tmp_path / ".hidden", tmp_path / "node_modules"):
directory.mkdir()
path = directory / "private.md"
path.write_text("MCP servers hidden secret", encoding="utf-8")
_set_mtime(path, "2026-07-05T00:00:00")
result = _scan(tmp_path)
assert [item.metadata["relative_path"] for item in result.items] == ["visible.md"]
def test_pdf_is_skipped_with_one_note_when_pdftotext_is_absent(tmp_path, monkeypatch):
pdf = tmp_path / "mcp.pdf"
pdf.write_bytes(b"not a real pdf")
_set_mtime(pdf, "2026-07-05T00:00:00")
monkeypatch.setattr(corpus, "which", lambda _name: None)
result = _scan(tmp_path)
assert result.items == []
assert result.notes == ["Skipped PDF files because pdftotext is not on PATH"]
def test_mtime_cache_reuses_text_and_is_private(tmp_path, monkeypatch):
note = tmp_path / "mcp.md"
note.write_text("MCP servers cache this local note", encoding="utf-8")
_set_mtime(note, "2026-07-05T00:00:00")
first = _scan(tmp_path)
assert first.cache_hits == 0
monkeypatch.setattr(corpus, "_extract_text", mock.Mock(side_effect=AssertionError("cache miss")))
second = _scan(tmp_path)
assert second.cache_hits == 1
cache_path = tmp_path / "cache" / corpus.CACHE_FILENAME
assert cache_path.stat().st_mode & 0o777 == 0o600
assert cache_path.parent.stat().st_mode & 0o777 == 0o700
def test_cache_for_500_large_documents_is_bounded_by_total_bytes(tmp_path, monkeypatch):
for index in range(500):
(tmp_path / f"document-{index:03d}.txt").touch()
monkeypatch.setattr(corpus, "MAX_CACHE_BYTES", 1_000_000)
monkeypatch.setattr(corpus, "MAX_CACHE_TEXT_CHARS", 100_000)
monkeypatch.setattr(corpus, "_extract_text", lambda *_args, **_kwargs: "x" * 1_000_000)
monkeypatch.setattr(corpus, "_match_score", lambda *_args, **_kwargs: 0.0)
result = corpus.search(
"anything",
[tmp_path],
from_date="2026-06-10",
to_date="2026-07-10",
all_time=True,
limit=0,
cache_dir=tmp_path / "cache",
)
cache_path = tmp_path / "cache" / corpus.CACHE_FILENAME
payload = json.loads(cache_path.read_text(encoding="utf-8"))
assert result.files_scanned == 500
assert cache_path.stat().st_size <= corpus.MAX_CACHE_BYTES
assert len(payload["entries"]) < 500
assert all(
len(entry["text"]) <= corpus.MAX_CACHE_TEXT_CHARS
for entry in payload["entries"].values()
)
def test_result_budget_caps_one_corpus_stream(tmp_path):
for index in range(10):
path = tmp_path / f"mcp-{index}.md"
path.write_text(f"MCP servers local note {index}", encoding="utf-8")
_set_mtime(path, f"2026-07-{index + 1:02d}T00:00:00")
assert len(_scan(tmp_path, limit=3).items) == 3
def test_file_cap_is_shared_fairly_across_corpus_roots(tmp_path, monkeypatch):
archive = tmp_path / "archive"
relevant = tmp_path / "relevant"
archive.mkdir()
relevant.mkdir()
for index in range(4):
(archive / f"mcp-archive-{index}.md").write_text(
f"MCP servers archived note {index}", encoding="utf-8"
)
later = relevant / "mcp-current.md"
later.write_text("MCP servers relevant current note", encoding="utf-8")
monkeypatch.setattr(corpus, "MAX_FILES", 4)
result = corpus.search(
"MCP servers",
[archive, relevant],
from_date="2026-06-10",
to_date="2026-07-10",
all_time=True,
cache_dir=tmp_path / "cache",
)
assert result.files_scanned <= corpus.MAX_FILES
assert any(item.metadata["path"].startswith(str(archive)) for item in result.items)
assert any(item.metadata["path"] == str(later) for item in result.items)
def test_pipeline_runs_corpus_outside_network_executor(tmp_path, monkeypatch):
note = tmp_path / "mcp-private.md"
note.write_text("MCP servers use a secret local transport", encoding="utf-8")
_set_mtime(note, "2026-07-05T00:00:00")
retrieve = mock.Mock(return_value=([], {}))
monkeypatch.setattr(pipeline, "_retrieve_stream", retrieve)
report = pipeline.run(
topic="MCP servers",
config={"_CORPUS_DIRS": [str(tmp_path)], "EXCLUDE_SOURCES": ""},
depth="quick",
requested_sources=["corpus"],
mock=True,
as_of_date="2026-07-10",
external_plan={
"intent": "concept",
"freshness_mode": "balanced_recent",
"cluster_mode": "none",
"source_weights": {"corpus": 1.0},
"subqueries": [{
"label": "primary",
"search_query": "MCP servers",
"ranking_query": "MCP servers",
"sources": ["corpus"],
}],
},
)
assert report.source_status["corpus"].state == health.OK
assert len(report.items_by_source["corpus"]) == 1
assert len(report.items_by_source["corpus"]) <= pipeline.DEPTH_SETTINGS["quick"]["per_stream_limit"]
assert all(call.kwargs["source"] != "corpus" for call in retrieve.call_args_list)
def test_corpus_never_enters_remote_rerank_or_fun_prompts(tmp_path, monkeypatch):
note = tmp_path / "private.md"
note.write_text("Model context protocol servers PRIVATE-RERANK-SENTINEL", encoding="utf-8")
_set_mtime(note, "2026-07-05T00:00:00")
remote = mock.Mock()
remote.generate_json = mock.Mock(side_effect=AssertionError("private prompt left machine"))
runtime = schema.ProviderRuntime("local", "remote-planner", "remote-reranker")
monkeypatch.setattr(pipeline.providers, "resolve_runtime", lambda *_args, **_kwargs: (runtime, remote))
monkeypatch.setattr(pipeline, "available_sources", lambda *_args, **_kwargs: ["corpus"])
report = pipeline.run(
topic="how do model context protocol servers work today?",
config={"_CORPUS_DIRS": [str(tmp_path)], "EXCLUDE_SOURCES": ""},
depth="quick",
requested_sources=["corpus"],
mock=False,
as_of_date="2026-07-10",
external_plan={
"intent": "how_to",
"freshness_mode": "evergreen_ok",
"cluster_mode": "workflow",
"source_weights": {"corpus": 1.0},
"subqueries": [{
"label": "primary",
"search_query": "model context protocol servers",
"ranking_query": "How do model context protocol servers work?",
"sources": ["corpus"],
}],
},
)
assert report.items_by_source["corpus"]
remote.generate_json.assert_not_called()
def test_explicit_unconfigured_corpus_records_skipped_outcome():
report = pipeline.run(
topic="how do local model context protocol servers work?",
config={"EXCLUDE_SOURCES": ""},
depth="quick",
requested_sources=["corpus"],
mock=True,
as_of_date="2026-07-10",
external_plan={
"intent": "how_to",
"freshness_mode": "evergreen_ok",
"cluster_mode": "workflow",
"source_weights": {"corpus": 1.0},
"subqueries": [{
"label": "primary",
"search_query": "model context protocol servers",
"ranking_query": "How do model context protocol servers work?",
"sources": ["corpus"],
}],
},
)
outcome = report.source_status["corpus"]
assert outcome.state == schema.SKIPPED_UNCONFIGURED
assert outcome.attempted is False
def _privacy_report(secret: str = "PRIVATE-CORPUS-SENTINEL") -> schema.Report:
item = schema.SourceItem(
item_id="C-private",
source="corpus",
title="private mcp notes",
body=f"MCP servers {secret}",
url="",
published_at="2026-07-05",
snippet=f"MCP servers {secret}",
metadata={"relative_path": "notes/private.md", "local_only": True},
)
candidate = schema.Candidate(
candidate_id="corpus:C-private",
item_id=item.item_id,
source="corpus",
title=item.title,
url="",
snippet=item.snippet,
subquery_labels=["primary"],
native_ranks={"primary:corpus": 1},
local_relevance=1.0,
freshness=90,
engagement=None,
source_quality=0.75,
rrf_score=0.01,
final_score=91,
cluster_id="cluster-private",
sources=["corpus"],
source_items=[item],
)
return schema.Report(
topic="MCP servers",
range_from="2026-06-10",
range_to="2026-07-10",
generated_at="2026-07-10T00:00:00+00:00",
provider_runtime=schema.ProviderRuntime("local", "mock", "mock"),
query_plan=schema.QueryPlan(
intent="concept",
freshness_mode="balanced_recent",
cluster_mode="none",
raw_topic="MCP servers",
subqueries=[schema.SubQuery("primary", "MCP servers", "MCP servers", ["corpus"])],
source_weights={"corpus": 1.0},
),
clusters=[schema.Cluster(
cluster_id="cluster-private",
title=f"Private cluster {secret}",
candidate_ids=[candidate.candidate_id],
representative_ids=[candidate.candidate_id],
sources=["corpus"],
score=91,
)],
ranked_candidates=[candidate],
items_by_source={"corpus": [item]},
errors_by_source={},
source_status={"corpus": schema.SourceOutcome("corpus", health.OK, 1)},
)
def test_corpus_findings_persist_with_stable_opaque_keys(tmp_path, monkeypatch):
note = tmp_path / "private-customer-notes.md"
note.write_text("MCP servers persist this private finding", encoding="utf-8")
first = corpus.search(
"MCP servers",
[tmp_path],
from_date="2026-06-10",
to_date="2026-07-10",
all_time=True,
cache_dir=tmp_path / "cache",
).items[0]
second = corpus.search(
"MCP servers",
[tmp_path],
from_date="2026-06-10",
to_date="2026-07-10",
all_time=True,
cache_dir=tmp_path / "cache",
).items[0]
report = _privacy_report()
report.items_by_source["corpus"][0].url = first.url
report.ranked_candidates[0].url = first.url
db_path = tmp_path / "research.db"
monkeypatch.setattr(store, "_db_override", db_path)
store.init_db()
topic = store.add_topic(report.topic)
run_id = store.record_run(topic["id"], status="completed")
findings = store.findings_from_report(report)
counts = store.store_findings(run_id, topic["id"], findings)
assert first.url == second.url
assert first.url.startswith("corpus://")
assert str(note) not in first.url
assert counts == {"new": 1, "updated": 0}
with sqlite3.connect(db_path) as conn:
persisted = conn.execute(
"SELECT source, source_url FROM findings"
).fetchone()
assert persisted == ("corpus", first.url)
assert library_index.search(
"private finding",
db_path=tmp_path / "missing-library.db",
store_db_path=db_path,
) == []
def test_persist_report_hardens_corpus_store_and_sidecars(tmp_path, monkeypatch):
db_path = tmp_path / "store" / "research.db"
monkeypatch.setattr(store, "_db_override", db_path)
original_findings_from_report = store.findings_from_report
original_store_findings = store.store_findings
original_update_run = store.update_run
def make_permissive():
db_path.chmod(0o644)
for suffix in ("-wal", "-shm"):
sidecar = Path(f"{db_path}{suffix}")
sidecar.touch(mode=0o644)
sidecar.chmod(0o644)
def findings_from_report_with_permissive_store(*args, **kwargs):
findings = original_findings_from_report(*args, **kwargs)
make_permissive()
return findings
def store_findings_after_private_check(*args, **kwargs):
for artifact in (db_path, Path(f"{db_path}-wal"), Path(f"{db_path}-shm")):
assert artifact.stat().st_mode & 0o777 == 0o600
return original_store_findings(*args, **kwargs)
def update_run_with_permissive_sidecars(*args, **kwargs):
result = original_update_run(*args, **kwargs)
make_permissive()
return result
monkeypatch.setattr(store, "findings_from_report", findings_from_report_with_permissive_store)
monkeypatch.setattr(store, "store_findings", store_findings_after_private_check)
monkeypatch.setattr(store, "update_run", update_run_with_permissive_sidecars)
cli.persist_report(_privacy_report())
for artifact in (db_path, Path(f"{db_path}-wal"), Path(f"{db_path}-shm")):
assert artifact.stat().st_mode & 0o777 == 0o600
def test_agent_export_excludes_corpus_by_default_and_allows_explicit_opt_in():
report = _privacy_report()
private_default = schema.to_agent_export(report)
opted_in = schema.to_agent_export(report, corpus_in_export=True)
assert private_default["results"] == []
assert private_default["clusters"] == []
assert "corpus" not in private_default["source_status"]
assert opted_in["results"][0]["source"] == "corpus"
assert "PRIVATE-CORPUS-SENTINEL" in opted_in["results"][0]["summary"]
report.artifacts["corpus_in_export"] = True
assert schema.to_agent_export(report)["results"][0]["source"] == "corpus"
def test_agent_export_rebuilds_mixed_cluster_title_after_private_representative_removed():
report = _privacy_report()
social_item = schema.SourceItem(
item_id="R-public",
source="reddit",
title="Public MCP discussion",
body="Public evidence",
url="https://reddit.example/public",
published_at="2026-07-06",
snippet="Public evidence",
)
social = schema.Candidate(
candidate_id="reddit:public",
item_id=social_item.item_id,
source="reddit",
title=social_item.title,
url=social_item.url,
snippet=social_item.snippet,
subquery_labels=["primary"],
native_ranks={"primary:reddit": 1},
local_relevance=0.8,
freshness=90,
engagement=1,
source_quality=0.6,
rrf_score=0.01,
final_score=80,
cluster_id="cluster-private",
sources=["reddit"],
source_items=[social_item],
)
report.ranked_candidates.append(social)
report.items_by_source["reddit"] = [social_item]
report.source_status["reddit"] = schema.SourceOutcome("reddit", health.OK, 1)
report.clusters[0].candidate_ids.append(social.candidate_id)
report.clusters[0].representative_ids.append(social.candidate_id)
report.clusters[0].sources.append("reddit")
exported = schema.to_agent_export(report)
assert exported["clusters"][0]["title"] == "Public MCP discussion"
assert "PRIVATE-CORPUS-SENTINEL" not in str(exported)
def test_local_report_has_badged_from_your_files_section():
rendered = render.render_compact(_privacy_report())
assert "## From your files" in rendered
assert "LOCAL ONLY" in rendered
assert "PRIVATE-CORPUS-SENTINEL" in rendered
def test_publish_html_sends_sanitized_report_not_local_corpus(monkeypatch):
report = _privacy_report()
captured: dict[str, str] = {}
def publish(rendered, **_kwargs):
captured["html"] = rendered
return {"url": "https://example.ht-ml.app"}
monkeypatch.setattr(cli.env, "get_config", lambda **_kwargs: {})
monkeypatch.setattr(cli.pipeline, "diagnose", lambda *_args, **_kwargs: {"available_sources": ["corpus"]})
monkeypatch.setattr(cli.pipeline, "run", lambda **_kwargs: report)
monkeypatch.setattr(cli, "publish_rendered_html", publish)
monkeypatch.setenv("LAST30DAYS_SKIP_PREFLIGHT", "1")
monkeypatch.setattr(
sys,
"argv",
["last30days.py", "MCP servers", "--emit=html", "--publish-html"],
)
with redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()):
assert cli.main() == 0
assert "PRIVATE-CORPUS-SENTINEL" not in captured["html"]
assert "private mcp notes" not in captured["html"]
def test_configured_corpus_bypasses_hosted_backend(tmp_path, monkeypatch):
report = _privacy_report()
hosted = mock.Mock(side_effect=AssertionError("local corpus was forwarded"))
monkeypatch.setattr("lib.hosted.run_hosted", hosted)
monkeypatch.setattr(
cli.env,
"get_config",
lambda **_kwargs: {"LAST30DAYS_CORPUS_DIRS": str(tmp_path)},
)
monkeypatch.setattr(cli.pipeline, "diagnose", lambda *_args, **_kwargs: {"available_sources": ["corpus"]})
monkeypatch.setattr(cli.pipeline, "run", lambda **_kwargs: report)
monkeypatch.setenv("LAST30DAYS_API_KEY", "test-hosted-key")
monkeypatch.setenv("LAST30DAYS_API_BASE", "https://example.invalid")
monkeypatch.setenv("LAST30DAYS_SKIP_PREFLIGHT", "1")
monkeypatch.setattr(sys, "argv", ["last30days.py", "MCP servers", "--emit=compact"])
with redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()):
assert cli.main() == 0
hosted.assert_not_called()
def test_library_publish_strips_marked_corpus_but_local_page_keeps_it(tmp_path, monkeypatch):
markdown = render.render_full(_privacy_report())
(tmp_path / "mcp-raw.md").write_text(markdown, encoding="utf-8")
monkeypatch.setattr(library, "DEFAULT_BRIEFS_DIR", tmp_path / "no-briefings")
monkeypatch.setattr(cli.env, "get_config", lambda **_kwargs: {})
publish_many = mock.Mock(return_value={})
monkeypatch.setattr("lib.html_publish.publish_html_documents", publish_many)
monkeypatch.setattr("lib.html_publish.publish_html", mock.Mock(return_value={"url": "https://library.ht-ml.app"}))
monkeypatch.setattr(
sys,
"argv",
["last30days.py", "library", "feed", "--save-dir", str(tmp_path), "--publish"],
)
with redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()):
assert cli.main() == 0
published_documents = publish_many.call_args.args[0]
assert published_documents
assert all("PRIVATE-CORPUS-SENTINEL" not in html for html in published_documents.values())
local_pages = list((tmp_path / "briefs").glob("*.html"))
assert local_pages
assert "PRIVATE-CORPUS-SENTINEL" in local_pages[0].read_text(encoding="utf-8")
assert local_pages[0].stat().st_mode & 0o777 == 0o600
assert local_pages[0].parent.stat().st_mode & 0o777 == 0o700
def test_private_corpus_cannot_escape_via_library_search_and_feed_publish(tmp_path, monkeypatch):
secret = "PRIVATE-CORPUS-ESCAPE-SENTINEL"
memory = tmp_path / "memory"
report = _privacy_report(secret)
with mock.patch.object(library_index, "sync_library"):
saved = cli.save_output(report, "md", str(memory))
db_path = tmp_path / "index" / "library.db"
library_index.sync_library(memory, tmp_path / "no-briefings", db_path=db_path)
entry = library._parse_markdown(saved)
legacy_hash = hashlib.sha256(entry.content.encode("utf-8")).hexdigest()
with sqlite3.connect(db_path) as conn:
conn.execute(
"UPDATE library_documents SET content_hash = ? WHERE entry_id = ?",
(legacy_hash, entry.entry_id),
)
conn.execute("DELETE FROM library_fts WHERE entry_id = ?", (entry.entry_id,))
conn.execute(
"INSERT INTO library_fts(entry_id, topic, headline, summary, content) "
"VALUES (?, ?, ?, ?, ?)",
(entry.entry_id, entry.topic, entry.headline, entry.summary, entry.content),
)
conn.commit()
rebuilt = library_index.sync_library(
memory,
tmp_path / "no-briefings",
db_path=db_path,
)
assert rebuilt.indexed == 1
assert library_index.search(
secret,
db_path=db_path,
store_db_path=tmp_path / "missing-store.db",
) == []
matches = library_index.search(
"MCP servers",
db_path=db_path,
store_db_path=tmp_path / "missing-store.db",
)
assert matches
assert all(secret not in match.snippet for match in matches)
followup = schema.without_sources(report, {"corpus"})
followup.library_context = [
schema.LibraryContext(
topic=match.topic,
published_date=match.published_date.isoformat(),
headline=match.headline,
summary=match.snippet,
source_kind=match.source_kind,
)
for match in matches[:1]
]
with mock.patch.object(library_index, "sync_library"):
cli.save_output(followup, "md", str(memory), suffix="followup")
monkeypatch.setattr(library, "DEFAULT_BRIEFS_DIR", tmp_path / "no-briefings")
monkeypatch.setattr(cli.env, "get_config", lambda **_kwargs: {})
publish_many = mock.Mock(return_value={})
monkeypatch.setattr("lib.html_publish.publish_html_documents", publish_many)
monkeypatch.setattr(
"lib.html_publish.publish_html",
mock.Mock(return_value={"url": "https://library.ht-ml.app"}),
)
monkeypatch.setattr(
sys,
"argv",
["last30days.py", "library", "feed", "--save-dir", str(memory), "--publish"],
)
with redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()):
assert cli.main() == 0
published = publish_many.call_args.args[0]
assert published
assert all(secret not in document for document in published.values())
def test_configured_paths_use_platform_separator_and_dedupe(tmp_path):
first = tmp_path / "first"
second = tmp_path / "second"
resolved = corpus.resolve_directories(
[str(first)], f"{first}{os.pathsep}{second}"
)
assert resolved == [first.resolve(), second.resolve()]
def test_corpus_env_keys_are_registered(monkeypatch):
monkeypatch.setenv("LAST30DAYS_CORPUS_DIRS", "/tmp/notes:/tmp/transcripts")
monkeypatch.setenv("LAST30DAYS_CORPUS_IN_EXPORT", "1")
with mock.patch.object(env, "_load_keychain", return_value={}), mock.patch.object(
env, "_load_pass", return_value={}
):
config = env.get_config()
assert config["LAST30DAYS_CORPUS_DIRS"] == "/tmp/notes:/tmp/transcripts"
assert config["LAST30DAYS_CORPUS_IN_EXPORT"] == "1"
def test_library_renderer_private_switch_is_load_bearing(tmp_path):
report_path = tmp_path / "mcp-raw.md"
report_path.write_text(render.render_full(_privacy_report()), encoding="utf-8")
entry = library._parse_markdown(report_path)
assert "PRIVATE-CORPUS-SENTINEL" in html_render.render_library_brief(entry)
assert "PRIVATE-CORPUS-SENTINEL" not in html_render.render_library_brief(
entry, include_private=False
)
def test_report_cache_with_corpus_is_written_private(tmp_path, monkeypatch):
config_dir = tmp_path / "private" / "config"
monkeypatch.setattr(cli.env, "CONFIG_DIR", config_dir)
assert cli._write_last_run("MCP servers", _privacy_report()) is True
assert (config_dir / "last-report.json").stat().st_mode & 0o777 == 0o600
assert config_dir.stat().st_mode & 0o777 == 0o700
def test_every_corpus_bearing_saved_artifact_is_owner_only(tmp_path):
report = _privacy_report()
markdown_dir = tmp_path / "private" / "markdown"
html_dir = tmp_path / "private" / "html"
with mock.patch.object(library_index, "sync_library"):
markdown = cli.save_output(report, "md", str(markdown_dir))
html = cli.save_output(report, "html", str(html_dir))
inferred_private = cli.save_output(
report, "compact", str(tmp_path / "private" / "inferred")
)
forced_public = cli.save_output(
report, "json", str(tmp_path / "private" / "forced-public"), private=False
)
explicit = cli.save_rendered_output(
cli.emit_output(report, "html"),
str(tmp_path / "private" / "explicit" / "report.html"),
private=True,
)
db_path = tmp_path / "private" / "index" / "library.db"
library_index.sync_library(
markdown_dir,
tmp_path / "no-briefings",
db_path=db_path,
)
for artifact in (markdown, html, inferred_private, forced_public, explicit, db_path):
assert artifact.stat().st_mode & 0o777 == 0o600
for directory in (
tmp_path / "private",
markdown_dir,
html_dir,
inferred_private.parent,
forced_public.parent,
explicit.parent,
db_path.parent,
):
assert directory.stat().st_mode & 0o777 == 0o700
def test_cli_saves_every_corpus_bearing_format_owner_only(tmp_path, monkeypatch):
report = _privacy_report()
monkeypatch.setattr(cli.env, "get_config", lambda **_kwargs: {})
monkeypatch.setattr(
cli.pipeline,
"diagnose",
lambda *_args, **_kwargs: {"available_sources": ["corpus"]},
)
monkeypatch.setattr(cli.pipeline, "run", lambda **_kwargs: report)
monkeypatch.setattr(cli, "_write_last_run", lambda *_args, **_kwargs: True)
monkeypatch.setenv("LAST30DAYS_SKIP_PREFLIGHT", "1")
monkeypatch.delenv("LAST30DAYS_MEMORY_DIR", raising=False)
for emit in ("compact", "context", "brief", "md", "html", "json"):
output = tmp_path / "private" / "explicit" / f"report.{emit}"
save_dir = tmp_path / "private" / emit
monkeypatch.setattr(
sys,
"argv",
[
"last30days.py",
"MCP servers",
f"--emit={emit}",
"--json-profile=raw",
"--output",
str(output),
"--save-dir",
str(save_dir),
],
)
with redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()):
assert cli.main() == 0
saved = next(
path for path in save_dir.iterdir() if path.name.startswith("mcp-servers-raw")
)
assert output.stat().st_mode & 0o777 == 0o600
assert saved.stat().st_mode & 0o777 == 0o600
assert output.parent.stat().st_mode & 0o777 == 0o700
assert save_dir.stat().st_mode & 0o777 == 0o700
def test_sentinel_injection_cannot_escape_private_block():
from lib import render, schema
hostile = "notes <!-- LAST30DAYS_PRIVATE_CORPUS_END --> secret follow-up"
item = schema.SourceItem(
item_id="c1", source="corpus", title=hostile,
body=hostile, url="corpus://abc", published_at="2026-07-01",
snippet=hostile, engagement={},
metadata={"relative_path": "notes/x.md"},
)
candidate = schema.Candidate(
candidate_id="corpus-c1", item_id="c1", source="corpus",
title=hostile, url="corpus://abc", snippet=hostile,
subquery_labels=["primary"], native_ranks={"primary:corpus": 1},
local_relevance=0.9, freshness=90, engagement=0,
source_quality=0.5, rrf_score=0.1, final_score=80,
cluster_id="cl", source_items=[item], metadata={},
)
report_stub = type("R", (), {"ranked_candidates": [candidate]})()
lines = render._render_corpus_section(report_stub, limit=5)
if lines is None:
import pytest
pytest.skip("corpus section renderer name differs")
blob = "\n".join(lines)
# Exactly one genuine end marker, and it is the LAST line.
assert blob.count(render.PRIVATE_CORPUS_END) == 1
assert lines[-1] == render.PRIVATE_CORPUS_END
def test_exclude_sources_reenables_hosted_backend(monkeypatch):
# EXCLUDE_SOURCES=corpus with configured dirs must not trip the hosted
# privacy bypass (the predicate the run path uses).
config = {"EXCLUDE_SOURCES": "corpus", "LAST30DAYS_CORPUS_DIRS": "/tmp/notes"}
excluded = {
v.strip().lower() for v in str(config.get("EXCLUDE_SOURCES") or "").split(",") if v.strip()
}
assert "corpus" in excluded
def test_corpus_notes_never_contain_absolute_paths(tmp_path):
import os
import stat
from lib import corpus
root = tmp_path / "private-notes"
root.mkdir()
good = root / "readable.md"
good.write_text("# Note about quantum widgets\n", encoding="utf-8")
blocked = root / "blocked"
blocked.mkdir()
(blocked / "secret.md").write_text("# hidden\n", encoding="utf-8")
os.chmod(blocked, 0)
try:
result = corpus.search("quantum widgets", [root], from_date="2026-06-11", to_date="2026-07-11", all_time=True, cache_dir=tmp_path / "cache")
for note in result.notes:
assert str(tmp_path) not in note, f"absolute path leaked in note: {note}"
finally:
os.chmod(blocked, stat.S_IRWXU)
def test_scan_error_notes_never_echo_absolute_paths(tmp_path, monkeypatch):
note = tmp_path / "mcp-server-notes.md"
note.write_text("MCP servers expose tools to local coding agents.", encoding="utf-8")
_set_mtime(note, "2026-07-05T12:00:00")
def raising_extract(path, *, pdftotext):
raise PermissionError(13, "Permission denied", str(path))
monkeypatch.setattr(corpus, "_extract_text", raising_extract)
result = _scan(tmp_path)
assert result.items == []
assert result.notes, "expected a skip note"
joined = " ".join(result.notes)
assert str(tmp_path) not in joined
assert "Permission denied" in joined
def test_cache_write_failure_note_never_echoes_absolute_paths(tmp_path, monkeypatch):
notes: list[str] = []
def raising_open(*args, **kwargs):
raise PermissionError(
13, "Permission denied", str(tmp_path / "cache" / "corpus.json")
)
monkeypatch.setattr(corpus.os, "open", raising_open)
corpus._write_cache(tmp_path / "cache" / "corpus.json", {"entries": {}}, notes)
joined = " ".join(notes)
assert notes, "expected a cache note"
assert str(tmp_path) not in joined
assert "Permission denied" in joined