When a tweet has no engagement metrics, _first_of() returns None for
every key, producing {"likes": None, "reposts": None, ...}. This
all-None dict propagates to signals.py where it is treated as "data
exists but is zero" rather than "no data available." Return None
instead when every engagement field is missing.
evaluate_search_quality.py and e2e_comparison.py both reference
fixtures/eval_topics.json with hardcoded fallbacks. Supply the
actual fixture: 8 topics spanning all intent types, selected via
MMR dispersion across domains (tech, health, sports, finance,
consumer products).
pyproject.toml declares pytest-cov as a dev dependency and configures
[tool.coverage.run], but the generated .coverage database and htmlcov/
report directory are not gitignored. Also add .venv/ (standard Python
virtualenv) and .memsearch/ (session memory) to keep the working tree
clean for contributors.
The SKILL.md prompt header still said v2.9.5 while pyproject.toml
and the rest of the codebase are on v3.0.0.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Makes the `variants/open/` sync steps in `scripts/sync.sh` conditional on
the directory actually existing in the source tree. The script is shared
between the public and private repos of last30days-skill, but the OpenClaw
variant only lives in the private repo (it's sanitized via
`strip_for_openclaw.py` and published separately to ClawhHub). When the
script runs from a checkout of the public repo, the variants/open paths
don't exist and the unconditional `rsync` and `sync_target` calls error
out immediately.
Changes:
- `sync_target()` now only creates `variants/open/references` and rsyncs
`variants/open/` when `$SRC/variants/open` exists.
- The trailing `sync_target "$OPENCLAW_TARGET" ...` call is now gated by
the same check, with an explanatory skip message when the directory is
absent.
No behavior change when running from the private repo (which has
`variants/open/`). When running from the public repo, the script now
completes its COMMON_TARGETS loop without erroring.
This also closes out the confusion from PR #211, where a contributor saw
the broken `variants/open/` reference and tried to add the variant back
to the public repo. The real fix was making the script tolerate the
absence, not recreating the directory.
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
release-notes.md:
- Drop the "What's New" section entirely. It repeated the same items
as the Headline features section above it in bulleted form, a
holdover from the old v2.9 release notes pattern. CHANGELOG.md is
the canonical Added/Changed/Fixed list; release notes is marketing
copy and shouldn't duplicate it. Added a one-line pointer to
CHANGELOG.md [3.0.0] for anyone looking for the detail.
- Rename "Credits" to "Earlier contributors" and note they are from
the v1 and v2 lineage, so readers don't confuse them with v3
contributors.
- Remove @steipete credit (did not actually contribute to this repo).
CHANGELOG.md [2.1.0] Credits:
- Remove @steipete credit (did not actually contribute to this repo).
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Rewrites release-notes.md from its stale v2.9.1 focus into the v3
story: intelligent pre-research as the killer feature, fun judge /
Best Takes, cross-source cluster merging, single-pass comparisons,
GitHub person-mode and project-mode, 13+ sources, ELI5 mode. Credits
@j-sperling as the v3 engine architect in the hero section and
updates the install instructions from `git clone` to the real install
paths for Claude Code, OpenClaw, and OpenAI Codex CLI.
Also extends the CHANGELOG [3.0.0] entry with a Fixed section covering
the two post-merge prep fixes that landed just before release:
- #214 resolved a duplicate `name: last30days` collision in
skills/last30days/SKILL.md that caused strict marketplace validators
to reject the plugin (reported by @Cody-Coyote in #204)
- #219 added the real Codex CLI integration at
.agents/skills/last30days/SKILL.md (regular file, since Codex's
loader skips symlinked files) plus .codex-plugin/plugin.json as the
namespace marker (inspired by @Jah-yee in #153 and @dannyshmueli
on X)
Bumps the [3.0.0] date from `2026-04` to `2026-04-11` to match the
actual release date, and adds @Cody-Coyote and @Jah-yee to the
[3.0.0] Contributors list.
No code changes. Pure docs prep for the v3.0.0 GitHub release.
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Adds a Codex CLI skill integration by creating the two files Codex's real
loader actually reads:
- .agents/skills/last30days/SKILL.md (real file, not a symlink - Codex's
loader skips symlinked files per codex-rs/core-skills/src/loader.rs)
- .codex-plugin/plugin.json with {"name": "last30days"} as a namespace
marker, per codex-rs/utils/plugins/src/plugin_namespace.rs
When Codex CLI runs in a checkout of this repo, it walks .agents/skills/
from CWD up to the project root, picks up .agents/skills/last30days/SKILL.md,
and walks ancestors looking for .codex-plugin/plugin.json to resolve the
plugin namespace. The skill registers as last30days:last30days.
The SKILL.md is a verbatim copy of the root SKILL.md at this point to
avoid content drift during the rollout. A future PR can slim the Codex copy
or introduce a sync mechanism.
Verified against Codex CLI's own source by running codex exec from the
repo CWD and having it trace the loader logic.
Replaces PR #153, which used a fake $schema URL
(https://openai.com/codex/plugin.schema.json returns 404) and put a
misunderstanding of Codex's plugin manifest (Codex only reads the `name`
field - all other fields like version, description, author, skills[] are
silently ignored).
This contribution was developed with AI assistance (Codex + Claude Code).
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
`stat -f '%Lp'` is BSD/macOS syntax. On Linux, `stat -f` prints
filesystem info (Block size / Inodes / ...) and still exits 0, so the
`||` fallback to `stat -c '%a'` never fires. That left `$perms` as
multi-line garbage, the `!= "600"` check was always true, and every
Linux SessionStart hook invocation printed a bogus warning plus the
whole `stat -f` filesystem dump.
Reorder to try GNU stat first, fall back to BSD for macOS. Verified on
Linux (cpython-3.12 / bash 5.x) — hook now emits the expected compact
Ready banner with no false warning.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Two SKILL.md files declared `name: last30days` with `user-invocable: true`,
which caused strict marketplace validators to reject the plugin with "Some
plugins in this marketplace have validation errors":
- ./SKILL.md (canonical, also reachable via skills/last30days-nux/ symlink)
- ./skills/last30days/SKILL.md (v3 architecture spec, real file)
In v2.9.6, skills/last30days/SKILL.md was a symlink to ../../SKILL.md so
only one skill existed. Commit 0a9ff16 (v3.0.0) added a new real file at
skills/last30days-v3/SKILL.md, and commit 9be0780 then renamed that
directory to skills/last30days/, replacing the original symlink with a
different real file. The collision has been live since v3.0.0 shipped.
This change:
- Renames skills/last30days/SKILL.md to name: last30days-v3-spec and sets
user-invocable: false. The file stays in place as internal architecture
documentation, but it no longer competes with the canonical skill.
- Fixes README.md link that pointed to the deleted skills/last30days-v3/
path (left over from the rename).
- Removes a stale variants/open/SKILL.md reference (variants/open was
deleted in v3.0.0).
After the change, only one canonical name=last30days user-invocable=true
skill exists (the root SKILL.md, also reachable via the
skills/last30days-nux/ symlink, same inode).
Closes#204.
This contribution was developed with AI assistance (Codex).
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
github.py _parse_date used naive string slicing (return iso_str[:10])
which accepted any 10+ character string as a "date." For input
"hello world" it returned "hello worl". Now delegates to
dates.parse_date() which validates the format and returns None for
non-dates.
Also migrated reddit.py and threads.py _parse_date to the shared
dates.parse_date(). Both previously reimplemented ISO-with-trailing-
offset handling (the .replace("Z", "+00:00") dance) and reddit.py
also had its own Unix timestamp branch. dates.parse_date() already
handles all of this, including the +0000 no-colon variant Reddit emits.
Preserved reddit.py's original falsy-check so 0 still returns None
(epoch 0 would otherwise parse as "1970-01-01", breaking an existing
test and changing long-standing behavior).
Added 4 new github tests for garbage rejection and offset variants.
All 1026 existing tests pass (15 pre-existing failures unchanged).
Added params kwarg to http.request()/http.get() that urlencodes a dict
into the query string. None values are dropped, ints and bools are
stringified, and params append correctly if the URL already has a
query string.
Migrated reddit.py to use this helper for all three ScrapeCreators
call sites (global search, subreddit search, post comments). Deleted
the try/import requests/except ImportError fallback and the paired
if not _requests: / else: branches. Six new http tests cover the
params-encoding behavior.
Net: reddit.py -70 lines. Behavior is identical - the existing http.py
urllib implementation already had retry logic, 429 handling, and
HTTPError types that are strictly better than the ad-hoc requests
branches we deleted.
99 reddit tests pass. Live smoke test on a real ScrapeCreators run
returned 12 threads with the same engagement data as before.
marketplace.json had stale v3 rename artifacts: plugin name was
"last30days-3" (should be "last30days" to match plugin.json) and
version was "3.0.0-alpha" (should be "3.0.0" to match the stable
release).
The module-level _cached_token was set once and never refreshed. AT
Protocol tokens expire after ~2 hours, causing silent 401 errors in
long-running watchlist cron sessions. Adds a 90-minute expiry check
using time.monotonic() and logs re-authentication.
Fixes#92
Add column whitelists to prevent SQL injection via kwargs keys in
dynamic UPDATE queries. Values were already parameterized but column
names were string-interpolated directly from kwargs.
Fixes#90
The dedup hot path recomputed normalize_text() 4 times per comparison
and recomputed item_text() on every inner-loop iteration. Pre-computing
n-gram sets and token sets into a _PreparedText cache cuts dedup time
by 6x (2.16s to 0.39s on 300 unique items).
Bird handle searches spawned one Node process per handle sequentially.
Now uses ThreadPoolExecutor so N handles run concurrently. Same pattern
applied to YouTube comment enrichment (was serial, Reddit was already
parallel) and the retry-thin-sources phase in the pipeline.
Clustering now pre-computes candidate text and uses prepared_similarity
for the O(n^2) grouping and MMR representative selection loops.
Minor: _is_wsl() cached with lru_cache, Bundle.add_items() uses
extend() instead of list concatenation.
End-to-end: 5.2s -> 3.7s (29% faster) on a typical 4-source query.
On WSL2, native Linux Firefox typically has no x.com cookies since users
browse in Windows. Chromium browsers (Edge, Chrome, Brave) encrypt cookies
with DPAPI/app-bound encryption, making them inaccessible without admin
privileges. Windows Firefox stores cookies unencrypted in SQLite, readable
directly through the /mnt/c mount.
The cookie extractor now detects WSL2 via /proc/version, locates Windows
Firefox profiles under /mnt/c/Users/*/AppData/Roaming/Mozilla/Firefox,
and falls back to them when Linux Firefox yields no results. Reports
source as "firefox-wsl" to distinguish from native.
Also fixes profile resolution priority: Install* sections (Firefox >= 67)
now take precedence over the legacy Default=1 flag, which could select a
stale profile on multi-profile installations.
gemini-extension.json still referenced v2.9.5 and
skills/last30days-v3/SKILL.md still said 3.0.0-alpha.
Both now match pyproject.toml's canonical 3.0.0 version.
Addresses items from #190. Structural decisions (SKILL.md
consolidation, SKILL-original.md cleanup) left for maintainer.
This contribution was developed with AI assistance (Claude Code).
The second assertion in test_resolve_google_judge_api_key_prefers_google_key
ran outside the mock.patch.dict context. When GOOGLE_API_KEY or GEMINI_API_KEY
is set in the real environment, os.environ takes precedence over the config
dict fallback and the test fails.
Wrap the assertion in its own mock.patch.dict scope that clears the three
relevant env vars so the test passes regardless of the developer's env.
This contribution was developed with AI assistance (Claude Code).