diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index aa8585b..2347357 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -4,12 +4,12 @@ "name": "indrasvat", "url": "https://github.com/indrasvat" }, - "description": "15 Claude Code skills for dev workflows, CI/CD, Kubernetes, browser automation, and AI-assisted development", + "description": "16 Claude Code skills for dev workflows, CI/CD, Kubernetes, browser automation, cross-platform signal research, and AI-assisted development", "plugins": [ { "name": "indrasvat-skills", "source": "./", - "description": "15 Claude Code skills for dev workflows, CI/CD, Kubernetes, browser automation, and AI-assisted development", + "description": "16 Claude Code skills for dev workflows, CI/CD, Kubernetes, browser automation, cross-platform signal research, and AI-assisted development", "category": "productivity" }, { diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 36a67f8..aa1d9c1 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "indrasvat-skills", - "description": "15 Claude Code skills: CI gating, exec plans, PR shipping, code cleanup, PR triage, K8s diffing, migration safety, API compat, rollout health, CRD impact analysis, PRD generation, iTerm2 automation, Cloudflare deployment, CodeRabbit reviews, and browsing-as-you (shared authenticated Chrome over CDP)", - "version": "1.5.0", + "description": "16 Claude Code skills: CI gating, exec plans, PR shipping, code cleanup, PR triage, K8s diffing, migration safety, API compat, rollout health, CRD impact analysis, PRD generation, iTerm2 automation, Cloudflare deployment, CodeRabbit reviews, browsing-as-you (shared authenticated Chrome over CDP), and grokery (grok-powered cross-platform signal research)", + "version": "1.6.0", "commands": "./commands/", "skills": "./skills/", "author": { diff --git a/README.md b/README.md index 09d3166..702eebb 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Claude Code Skills -A marketplace of personal [Claude Code](https://claude.ai/code) plugins: 15 skills for AI-assisted dev workflows, plus `dootdashaa`, a glanceable statusline. +A marketplace of personal [Claude Code](https://claude.ai/code) plugins: 16 skills for AI-assisted dev workflows, plus `dootdashaa`, a glanceable statusline. ## ๐ŸŽฏ Overview @@ -8,7 +8,7 @@ This repository is a Claude Code marketplace exposing two independently-installa | Plugin | Install command | What it adds | |---|---|---| -| **indrasvat-skills** | `/plugin install indrasvat-skills@indrasvat-skills` | 15 skills (CI gating, exec plans, PR shipping, K8s diffing, PRD generation, iTerm2 automation, browsing-as-you, โ€ฆ) | +| **indrasvat-skills** | `/plugin install indrasvat-skills@indrasvat-skills` | 16 skills (CI gating, exec plans, PR shipping, K8s diffing, PRD generation, iTerm2 automation, browsing-as-you, grokery signal research, โ€ฆ) | | **dootdashaa** | `/plugin install dootdashaa@indrasvat-skills` | Single-line, Nerd-Font-icon statusline. `~9ms` p50 render budget. | Both share one `/plugin marketplace add` step (see [Installation](#installation)). Each can be installed, updated, and uninstalled on its own. @@ -17,7 +17,7 @@ Both share one `/plugin marketplace add` step (see [Installation](#installation) Glanceable, single-line. One carrier per signal (icon, text, or colour โ€” never two). Zero emoji. Pre-computed git cache keeps the hot path under the 300ms Claude Code debounce. See [`plugins/dootdashaa/README.md`](plugins/dootdashaa/README.md) for env-var options and the install / uninstall command list; design report is at [`plugins/dootdashaa/docs/DESIGN.html`](plugins/dootdashaa/docs/DESIGN.html). -### Skills (15 total) +### Skills (16 total) #### Universal Dev Workflow (5 new) @@ -39,6 +39,12 @@ Glanceable, single-line. One carrier per signal (icon, text, or colour โ€” never | **rollout-check** | `/rollout-check` | Verify K8s deployment health โ€” pods, events, HPA, logs. | | **crd-impact** | `/crd-impact` | Find all controllers, webhooks, RBAC, and manifests affected by a CRD change. | +#### Research & Signals (1 new) + +| Skill | Command | Description | +|-------|---------|-------------| +| **grokery** ๐Ÿ›’ | `/grokery` | Grok-powered cross-platform signal scan (X + HN + GitHub + Reddit + Polymarket + web), engagement-ranked. A `last30days`-style brief with **no API keys or browser login** โ€” X/Reddit/web run through the `grok` CLI. | + #### Original Skills (4) #### ๐Ÿ“‹ **prd-generator** @@ -127,7 +133,7 @@ Inside a `claude` session, register the marketplace once and install whichever p ``` /plugin marketplace add indrasvat/claude-code-skills # once per machine -# 15 skills (CI gating, exec plans, PR shipping, K8s diffing, PRD generation, browsing-as-you, ...) +# 16 skills (CI gating, exec plans, PR shipping, K8s diffing, PRD generation, browsing-as-you, grokery, ...) /plugin install indrasvat-skills@indrasvat-skills # Statusline diff --git a/skills/grokery/SKILL.md b/skills/grokery/SKILL.md new file mode 100644 index 0000000..1c7fbc7 --- /dev/null +++ b/skills/grokery/SKILL.md @@ -0,0 +1,72 @@ +--- +name: grokery +description: > + Scanning the live internet for what people are actually saying about a topic, + then synthesizing an engagement-ranked brief โ€” grok-powered first-party X + search plus Hacker News, GitHub, Reddit, Polymarket, YouTube and the open web, + with no API keys or browser login. Use when the user says "grokery", "what's + the buzz on", "what is X / the internet saying about", "scan the discourse", + "vibe check", "trend check", "social signals on", "what are people saying + about ", "research the chatter on", or wants real-time cross-platform + sentiment, a comparison ("A vs B"), or a shareable HTML signal brief on a + tech / culture / product / person topic. +allowed-tools: Bash, Read, Write, WebFetch +argument-hint: [--days N] [--mode hiring|competitors] [--github-user H] [--html] +--- + +# grokery ๐Ÿ›’ + +Grok-powered cross-platform signal scan. Like `last30days`, but the auth-walled, +key-hungry sources (X, Reddit, grounded web search) all run through the **`grok` +CLI on your X subscription** โ€” so there is **no Bird client, no browser login, +no Brave/Perplexity keys**. _"Searches people, not editors. Ranked by upvotes, +likes, and real money."_ + +## Prerequisites +- **`grok`** CLI, logged in (`grok login`) โ€” powers X + Reddit + web. Required for those sources; everything else still runs without it. +- **`curl`** + **`jq`** โ€” for Hacker News, GitHub, Polymarket (keyless). +- Optional: **`yt-dlp`** (YouTube), **`GITHUB_TOKEN`** (higher GitHub limits), **`python3`** (`--store` trend DB + HTML render). + +## Quickstart +Run the orchestrator โ€” it fans out every source in parallel and prints combined +raw signal (also saved to `~/Documents/Grokery/-raw.md`): + +```bash +bash skills/grokery/scripts/grokery.sh "grok cli" --days 30 +``` +> The grok pass is one combined session over X + Reddit + web and is the slow part โ€” typically **3โ€“7 min** (default `--timeout 420`s; after that it degrades gracefully and every other source still returns). The keyless sources (HN/GitHub/Polymarket) return in seconds. Run it and wait; do not abort. + +Then **you synthesize** the raw output into the final brief (this is the agent's +job, mirroring last30days' "scripts gather, agent synthesizes" split): +read [`references/ranking.md`](references/ranking.md) and +[`references/modes.md`](references/modes.md), then produce the brief. + +## Pipeline (what to do) +1. **Resolve** โ€” grok identifies key handles / subreddits / repos / hashtags internally (entity resolution). +2. **Gather** โ€” `grokery.sh` queries all enabled sources in parallel. +3. **Dedup** โ€” merge the same story across platforms into one cluster ([ranking.md](references/ranking.md)). +4. **Rank** โ€” order by real engagement (likes, upvotes, views, $ volume), not SEO. +5. **Synthesize** โ€” write a tight narrative with citations; surface genuinely witty/viral one-liners verbatim ([modes.md](references/modes.md)). +6. **Emit** โ€” markdown by default; `--html` โ†’ self-contained dark brief ([html-brief.md](references/html-brief.md)). + +## Modes & options (detail โ†’ [references/modes.md](references/modes.md)) +- **`A vs B vs C`** โ€” run `grokery.sh` once per side, then compare side-by-side. +- **`--mode competitors`** โ€” auto-discover top peers and run the comparison. +- **`--mode hiring`** / `--github-user ` โ€” focus/career signals; person mode. +- **`--days N`**, **`--max N`**, **`--save-suffix`**, **`--store`** (SQLite trend log), **`--no-save`**. +- **`EXCLUDE_SOURCES=youtube,polymarket`** env to suppress sources. +- **`eli5`** โ€” after a run, ask to rewrite the synthesis jargon-free. + +## References (load on demand) +| File | When to read | +|---|---| +| [`references/sources.md`](references/sources.md) | Per-source detail, grok mapping, optional deps, honest gaps vs last30days | +| [`references/modes.md`](references/modes.md) | Comparison / competitors / hiring / person / eli5 recipes + synthesis & wit guidance | +| [`references/ranking.md`](references/ranking.md) | Cross-platform engagement normalization + dedup/clustering | +| [`references/html-brief.md`](references/html-brief.md) | HTML emit: `render-html.sh` or hand-craft from `brief-template.html` | + +## Honest gaps vs last30days +- **Paid-only sources:** TikTok / Instagram / Pinterest / Threads **native** metrics and Digg's curated leaderboard need the paid ScrapeCreators API and are **out of scope** โ€” grok's web/X pass surfaces mentions, not first-party engagement numbers. +- **Data fidelity trade-off:** last30days reads X/Reddit through deterministic API clients (exact counts). grokery reads them through the grok LLM, which is faster to set up but can **approximate or misreport** engagement numbers. Treat grok-sourced metrics as indicative, prefer the cited URL as ground truth, and spot-check any number you'll quote. The curl sources (HN / GitHub / Polymarket) are exact. + +Everything else is at or near parity, with far less setup. diff --git a/skills/grokery/references/brief-template.html b/skills/grokery/references/brief-template.html new file mode 100644 index 0000000..0f9b342 --- /dev/null +++ b/skills/grokery/references/brief-template.html @@ -0,0 +1,51 @@ + + + +grokery โ€” TOPIC + + +
+ grokery ยท signal brief +

TOPIC

+
Window: last WINDOW days ยท DATE ยท sources: SOURCES
+ +
Verdict โ€” one-to-three-sentence dominant take + strongest counter-take.
+ + + + +
Generated by grokery โ€” grok-powered cross-platform signal scan. ยท Ranked by engagement, not SEO. ยท Sources: SOURCES.
+
diff --git a/skills/grokery/references/html-brief.md b/skills/grokery/references/html-brief.md new file mode 100644 index 0000000..b58f16b --- /dev/null +++ b/skills/grokery/references/html-brief.md @@ -0,0 +1,28 @@ +# grokery HTML brief + +Two ways to produce the shareable, self-contained, dark-mode brief +(`--html` / "give me an HTML brief" / "export as html"). Both are offline: +inline CSS, no JavaScript, system fonts, print-friendly. + +## A. Deterministic (CLI) โ€” `render-html.sh` +Write your synthesized markdown to a file, then convert: +```bash +bash skills/grokery/scripts/render-html.sh ~/Documents/Grokery/-synthesis.md \ + --title 'grokery โ€” ' --out ~/Documents/Grokery/-brief.html +``` +The script handles headings, bold/italic, links, autolinks (``), +bullets, code, and `---` rules, and wraps them in the styled shell with a badge +and the `โœ… All sources reported back.` footer. + +## B. Hand-crafted (richer) +For a higher-touch brief (callouts, a verdict hero, simple inline-SVG bars for +engagement), copy [`brief-template.html`](brief-template.html) and fill the +`` slot. Keep it self-contained: no external fonts, scripts, or +images. Reuse the template's CSS variables so it stays on-brand. + +## Conventions +- **Filename:** `-brief.html` in the memory dir (default `~/Documents/Grokery/`). +- **Top:** badge โ†’ topic `

` โ†’ metadata line (window, date, sources that reported). +- **Body:** the synthesis โ€” themes as `

`, citations as inline links, engagement numbers in ``. +- **Footer:** a one-line colophon naming grokery and "ranked by engagement, not SEO", plus the sources that **actually returned** (e.g. "Sources: X, HN, GitHub, Polymarket"). Never claim a source that failed or was skipped. +- Verify it opens offline (double-click) before declaring done. diff --git a/skills/grokery/references/modes.md b/skills/grokery/references/modes.md new file mode 100644 index 0000000..d372ee9 --- /dev/null +++ b/skills/grokery/references/modes.md @@ -0,0 +1,41 @@ +# grokery modes & synthesis + +## Synthesis (always) +After `grokery.sh` returns raw signal, write the brief: +- **Lead with the verdict** โ€” 2โ€“3 sentences: the dominant take + the strongest counter-take. +- **Group by theme**, not by platform. Each theme cites its strongest 1โ€“2 sources with engagement numbers. +- **Quote wit verbatim.** Surface genuinely funny / viral one-liners โ€” they carry signal and are why people read these. (last30days calls this "humor scoring".) +- **Numbers over adjectives** โ€” "365K views ยท 117โ™ฅ" beats "went viral". +- **Cite everything** โ€” every claim links to a concrete URL. +- **Flag confidence** โ€” call out when a take is one loud account vs broad consensus. + +## Research (default) +Single topic. Run: +```bash +bash skills/grokery/scripts/grokery.sh "" --days 30 +``` + +## Comparison โ€” `A vs B [vs C]` +Detect `vs` in the request. Run the orchestrator **once per side**, then synthesize a side-by-side: +```bash +bash skills/grokery/scripts/grokery.sh "OpenClaw" --save-suffix cmp --days 30 +bash skills/grokery/scripts/grokery.sh "Hermes" --save-suffix cmp --days 30 +``` +Compare on the axes people actually argue about (architecture, reliability, price, momentum) and include live GitHub stars per side. + +## `--mode competitors` +Pass `--mode competitors`; grok names the top ~3 peers in its `### Competitors` +subsection. Then run a comparison pass on those names. + +## `--mode hiring` / `--github-user ` (person mode) +- `--mode hiring` adds a `### Hiring signals` subsection (open roles, careers-page shifts) โ€” reveals where a person/company is focusing. +- `--github-user steipete` switches GitHub to person mode: recent PRs/pushes + top repos. Combine with the topic for a full "what is this person up to" brief. + +## `eli5` (post-run) +If the user says `eli5 on`, rewrite the existing synthesis in plain language โ€” +short sentences, no jargon, concrete analogies โ€” without re-running sources. +`eli5 off` restores the standard brief. + +## Persistence +- Snapshots auto-save to `~/Documents/Grokery/-raw.md` (override `--save-dir`, disable `--no-save`). +- `--store` appends the run to `grokery.sqlite3` for trend monitoring; diff successive `-raw.md` snapshots to see what changed since last time. diff --git a/skills/grokery/references/ranking.md b/skills/grokery/references/ranking.md new file mode 100644 index 0000000..5d13dc8 --- /dev/null +++ b/skills/grokery/references/ranking.md @@ -0,0 +1,37 @@ +# grokery ranking & dedup + +The fetchers pre-sort within each source. Cross-source ranking and clustering is +the agent's job โ€” engagement is the currency, not SEO. + +## 1. Normalize engagement across platforms +Different platforms, different scales. Judge by **reach within a platform's norms**, not raw counts: + +| Platform | Primary signal | "High signal" rule of thumb | +|---|---|---| +| X | likes + reposts + views | 4-figure+ likes, or high views relative to author size | +| Reddit | upvotes + comments | 3-figure+ upvotes, or top comment of a big thread | +| Hacker News | points + comments | 100+ pts; comment count โ‰ˆ controversy | +| YouTube | views + recency | high views for the topic's niche | +| GitHub | stars + push recency | stars signal adoption; recent pushes signal momentum | +| Polymarket | $ volume (odds on event page) | high volume = real conviction; open the event link for the crowd's number | + +Weight by **independence**: 5 unrelated accounts > 1 account quoted 5 times. + +## 2. Dedup / cluster +Merge the same story across sources into one cluster: +- Same URL, or same event/claim described by multiple platforms โ†’ one bullet, list the corroborating sources. +- Quote-reposts and reaction threads collapse into the original. +- A claim appearing on X **and** HN **and** Reddit is *stronger* โ€” say so; don't list it three times. + +## 3. Recency +Within the `--days` window, prefer fresher items on fast-moving topics; for +evergreen topics, let engagement dominate. + +## 4. Confidence labels +- **Consensus** โ€” corroborated across โ‰ฅ3 independent sources/platforms. +- **Contested** โ€” strong takes on both sides; present both. +- **Single loud voice** โ€” one high-reach account; flag as not-yet-corroborated. + +## 5. Final order +Lead with the highest-confidence, highest-engagement cluster. Bury low-signal +chatter or drop it. A short, sharp brief beats an exhaustive dump. diff --git a/skills/grokery/references/sources.md b/skills/grokery/references/sources.md new file mode 100644 index 0000000..c4744ba --- /dev/null +++ b/skills/grokery/references/sources.md @@ -0,0 +1,37 @@ +# grokery sources + +How each last30days source maps onto grokery, and what each script emits. + +## Core โ€” zero setup + +| Source | grokery path | Signal | Script | +|---|---|---|---| +| **X / Twitter** | `grok` first-party search | Real-time expert threads, breaking reactions, engagement | `fetch-social.sh` | +| **Reddit** | `grok` (web/Reddit tools) | Unfiltered community takes + upvotes (avoids the keyless `.json` 403) | `fetch-social.sh` | +| **Web** | `grok` grounded search | Editorial coverage, blogs โ€” replaces last30days' Brave **and** Perplexity Sonar | `fetch-social.sh` | +| **Hacker News** | Algolia public API | Developer consensus (high points / high comments) | `fetch-hn.sh` | +| **GitHub** | REST search + events | Repos by stars; person mode = recent PRs/pushes + top repos | `fetch-github.sh` | +| **Polymarket** | Gamma `public-search` | Market question + real-money $ volume (the honest sentiment gauge); per-outcome odds live on the linked event page | `fetch-polymarket.sh` | + +Why this is the whole point: last30days needs a vendored Bird client + browser +session for X, a Brave key for web, and an OpenRouter key for Perplexity. grokery +folds **all three** into the one `grok` call you already pay for. + +## Optional โ€” free, if the tool is present +- **YouTube** (`fetch-youtube.sh`) โ€” needs `yt-dlp`. Lists top videos by views; `--transcripts` pulls the top video's auto-captions for quote distillation. Skips with a notice if `yt-dlp` is absent. +- **GitHub auth** โ€” export `GITHUB_TOKEN` to lift the 10 req/min unauthenticated ceiling. + +## Source control +- `EXCLUDE_SOURCES=youtube,polymarket` โ€” suppress sources (comma-separated), honored by `grokery.sh`. +- Individual fetchers can also be run alone for debugging, e.g. `bash scripts/fetch-hn.sh "topic" --days 7`. + +## Out of scope (paid-only in last30days) +TikTok, Instagram Reels, Pinterest, Threads **native** engagement, and Digg's +~1000-account AI leaderboard all require the paid **ScrapeCreators** API. grokery +deliberately omits them to stay keyless โ€” grok's web/X pass will still surface +*mentions* and cross-posts, just not first-party metrics. Bluesky (AT Protocol, +free app password) is a candidate for a future `fetch-bluesky.sh`. + +## Reliability notes +- Every fetcher **self-degrades and exits 0** โ€” one dead source never blocks a run. +- The `grok` pass is the slow one (~2โ€“3 min for live X). `fetch-social.sh --timeout` bounds it; default 300s. diff --git a/skills/grokery/scripts/fetch-github.sh b/skills/grokery/scripts/fetch-github.sh new file mode 100755 index 0000000..6ab258c --- /dev/null +++ b/skills/grokery/scripts/fetch-github.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +# +# Fetching GitHub signal for a topic (repos by stars) or a person (--user: +# recent PRs/pushes + top repos). Keyless; honors GITHUB_TOKEN if exported +# (raises the 10 req/min unauthenticated limit). Emits markdown; always exits 0. +# +# Usage: +# fetch-github.sh [--max N] [--pushed-days N] +# fetch-github.sh --user [--max N] +set -euo pipefail + +usage() { echo "usage: fetch-github.sh [--max N] [--pushed-days N] | --user [--max N]" >&2; exit 2; } + +topic=""; user=""; max=5; pushed_days=180 +while [ $# -gt 0 ]; do + case "$1" in + --user) user="${2:?"$1 needs a value"}"; shift 2 ;; + --max) max="${2:?"$1 needs a value"}"; shift 2 ;; + --pushed-days) pushed_days="${2:?"$1 needs a value"}"; shift 2 ;; + -h|--help) usage ;; + --*) echo "fetch-github: unknown flag: $1" >&2; usage ;; + *) if [ -z "$topic" ]; then topic="$1"; shift; else echo "fetch-github: unexpected arg: $1" >&2; usage; fi ;; + esac +done +for v in "$max:--max" "$pushed_days:--pushed-days"; do + case "${v%%:*}" in ''|*[!0-9]*) echo "fetch-github: ${v#*:} must be an integer" >&2; exit 2 ;; esac +done + +# GET that appends the HTTP status as a trailing line, so callers can tell a +# rate-limit / error apart from a genuinely empty result. bash 3.2 + set -u safe +# (no empty-array expansion). On total network failure, status is 000. +gh_get() { + if [ -n "${GITHUB_TOKEN:-}" ]; then + curl -sS --max-time 20 -w '\n%{http_code}' -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer ${GITHUB_TOKEN}" "$1" 2>/dev/null || printf '\n000' + else + curl -sS --max-time 20 -w '\n%{http_code}' -H "Accept: application/vnd.github+json" "$1" 2>/dev/null || printf '\n000' + fi +} + +# Human message for a non-2xx status; empty string for 2xx (so callers proceed). +gh_msg() { + case "$1" in + 2*) echo "" ;; + 000) echo "_unavailable (network error / timeout)_" ;; + 403) echo "_GitHub rate-limited or forbidden โ€” export GITHUB_TOKEN to raise limits_" ;; + *) echo "_GitHub API error (HTTP $1)_" ;; + esac +} + +if [ -n "$user" ]; then + # Reject anything that isn't a valid GitHub handle โ€” stops path traversal + # like `--user ../orgs/google` from re-pointing the API URL. + case "$user" in + *[!A-Za-z0-9-]*|-*|*-) echo "## GitHub โ€” @${user}"; echo "_invalid GitHub handle_"; exit 0 ;; + esac + echo "## GitHub โ€” @${user}" + raw=$(gh_get "https://api.github.com/users/${user}/repos?sort=pushed&per_page=${max}") + code="${raw##*$'\n'}"; repos="${raw%$'\n'*}" + msg=$(gh_msg "$code") + if [ -n "$msg" ]; then echo "$msg"; exit 0; fi + rout=$(printf '%s' "$repos" | jq -r --argjson max "$max" ' + if type=="array" then (.[:$max][] | "- **\(.stargazers_count)** โ˜… โ€” [\(.full_name)](\(.html_url)) โ€” \(.description // "no description") _(pushed \(.pushed_at[0:10]))_") + else empty end' 2>/dev/null || true) + [ -n "$rout" ] && { echo "**Recently pushed repos**"; printf '%s\n' "$rout"; } || echo "_no public repos found_" + + eraw=$(gh_get "https://api.github.com/users/${user}/events/public?per_page=30") + events="${eraw%$'\n'*}" + eout=$(printf '%s' "$events" | jq -r ' + if type=="array" then + ([.[] | select(.type=="PullRequestEvent")] | .[:5][] + | "- PR \(.payload.action) โ€” [\(.repo.name)](https://github.com/\(.repo.name)): \(.payload.pull_request.title // "") _(\(.created_at[0:10]))_") + else empty end' 2>/dev/null || true) + [ -n "$eout" ] && { echo; echo "**Recent pull requests**"; printf '%s\n' "$eout"; } + exit 0 +fi + +[ -n "$topic" ] || usage + +enc=$(jq -rn --arg q "$topic" '$q|@uri') +if date -v-1d >/dev/null 2>&1; then + since=$(date -v-"${pushed_days}"d +%Y-%m-%d) +else + since=$(date -d "-${pushed_days} days" +%Y-%m-%d) +fi + +echo "## GitHub" +raw=$(gh_get "https://api.github.com/search/repositories?q=${enc}+pushed:>=${since}&sort=stars&order=desc&per_page=${max}") +code="${raw##*$'\n'}"; resp="${raw%$'\n'*}" +msg=$(gh_msg "$code") +if [ -n "$msg" ]; then echo "$msg"; exit 0; fi +out=$(printf '%s' "$resp" | jq -r --argjson max "$max" ' + (.items // [])[:$max][] + | "- **\(.stargazers_count)** โ˜… โ€” [\(.full_name)](\(.html_url)) โ€” \(.description // "no description")" +' 2>/dev/null || true) +if [ -z "$out" ]; then + echo "_no active repos (pushed since ${since})_" +else + printf '%s\n' "$out" +fi diff --git a/skills/grokery/scripts/fetch-hn.sh b/skills/grokery/scripts/fetch-hn.sh new file mode 100755 index 0000000..8302e9f --- /dev/null +++ b/skills/grokery/scripts/fetch-hn.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# +# Fetching top Hacker News stories for a topic via the public Algolia API. +# Keyless. Filters by points + recency client-side (robust against the API's +# fragile numericFilters parsing). Emits a markdown section on stdout and always +# exits 0 so an orchestrator is never blocked by a single dead source. +# +# Usage: fetch-hn.sh [--days N] [--max N] [--min-points N] +set -euo pipefail + +usage() { echo "usage: fetch-hn.sh [--days N] [--max N] [--min-points N]" >&2; exit 2; } + +topic=""; days=30; max=5; min_points=20 +while [ $# -gt 0 ]; do + case "$1" in + --days) days="${2:?"$1 needs a value"}"; shift 2 ;; + --max) max="${2:?"$1 needs a value"}"; shift 2 ;; + --min-points) min_points="${2:?"$1 needs a value"}"; shift 2 ;; + -h|--help) usage ;; + --*) echo "fetch-hn: unknown flag: $1" >&2; usage ;; + *) if [ -z "$topic" ]; then topic="$1"; shift; else echo "fetch-hn: unexpected arg: $1" >&2; usage; fi ;; + esac +done +[ -n "$topic" ] || usage +for v in "$days:--days" "$max:--max" "$min_points:--min-points"; do + case "${v%%:*}" in ''|*[!0-9]*) echo "fetch-hn: ${v#*:} must be an integer" >&2; exit 2 ;; esac +done + +enc=$(jq -rn --arg q "$topic" '$q|@uri') +if date -v-1d >/dev/null 2>&1; then + cutoff=$(date -v-"${days}"d +%s) # BSD/macOS +else + cutoff=$(date -d "-${days} days" +%s) # GNU/Linux +fi + +echo "## Hacker News" +resp=$(curl -fsS --max-time 20 \ + "https://hn.algolia.com/api/v1/search_by_date?query=${enc}&tags=story&numericFilters=points%3E${min_points}" \ + 2>/dev/null || true) +if [ -z "$resp" ]; then + echo "_unavailable (network or API error)_" + exit 0 +fi + +out=$(printf '%s' "$resp" | jq -r --argjson cut "$cutoff" --argjson max "$max" ' + [.hits[]? | select(.created_at_i > $cut)] + | sort_by(-.points) | .[:$max][] + | "- **\(.points)** pts ยท \(.num_comments) comments โ€” \(.title)\n " +' 2>/dev/null || true) + +if [ -z "$out" ]; then + echo "_no stories above ${min_points} pts in the last ${days}d_" +else + printf '%s\n' "$out" +fi diff --git a/skills/grokery/scripts/fetch-polymarket.sh b/skills/grokery/scripts/fetch-polymarket.sh new file mode 100755 index 0000000..3f4aee1 --- /dev/null +++ b/skills/grokery/scripts/fetch-polymarket.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# +# Fetching prediction-market signal for a topic from Polymarket's public Gamma +# API (keyless). Real-money odds/volume are a uniquely honest sentiment gauge. +# Emits markdown; always exits 0. +# +# Usage: fetch-polymarket.sh [--max N] +set -euo pipefail + +usage() { echo "usage: fetch-polymarket.sh [--max N]" >&2; exit 2; } + +topic=""; max=5 +while [ $# -gt 0 ]; do + case "$1" in + --max) max="${2:?"$1 needs a value"}"; shift 2 ;; + -h|--help) usage ;; + --*) echo "fetch-polymarket: unknown flag: $1" >&2; usage ;; + *) if [ -z "$topic" ]; then topic="$1"; shift; else echo "fetch-polymarket: unexpected arg: $1" >&2; usage; fi ;; + esac +done +[ -n "$topic" ] || usage +case "${max}" in ''|*[!0-9]*) echo "fetch-polymarket: --max must be an integer (got '$max')" >&2; exit 2 ;; esac + +enc=$(jq -rn --arg q "$topic" '$q|@uri') + +echo "## Prediction markets (Polymarket)" +resp=$(curl -fsS --max-time 20 \ + "https://gamma-api.polymarket.com/public-search?q=${enc}&limit_per_type=${max}" \ + 2>/dev/null || true) +if [ -z "$resp" ]; then + echo "_unavailable (network or API error)_" + exit 0 +fi +out=$(printf '%s' "$resp" | jq -r --argjson max "$max" ' + def num: if type=="string" then (tonumber? // 0) elif type=="number" then . else 0 end; + (.events // [])[:$max][] + | "- **\(.title)** โ€” $\(((.volume // .volume24hr) | num | floor)) volume โ€” " +' 2>/dev/null || true) +if [ -z "$out" ]; then + echo "_no active markets for this topic_" +else + printf '%s\n' "$out" +fi diff --git a/skills/grokery/scripts/fetch-social.sh b/skills/grokery/scripts/fetch-social.sh new file mode 100755 index 0000000..08612fc --- /dev/null +++ b/skills/grokery/scripts/fetch-social.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +# +# Fetching the auth-walled / messy half of the web through the `grok` CLI in ONE +# session: first-party X (no browser login), Reddit (no 403), and grounded web +# search (no Brave/Perplexity key). This is what makes grokery zero-setup โ€” it +# replaces last30days' vendored Bird client + Brave + Perplexity with the grok +# subscription. +# +# Why one call, not parallel per-source calls: grok routes through a shared +# leader process (~/.grok/leader.sock), so concurrent `grok -p` invocations +# contend and serialize โ€” slower, not faster. A single agent session searches +# all three sources in one loop, sharing context. It is the slow part of a run +# (typically 3-7 min); the keyless sources return in seconds regardless. +# +# Reports the ACTUAL failure cause (timeout / auth / empty) โ€” never a blanket +# "timed out". Emits markdown; always exits 0. +# +# Usage: fetch-social.sh [--days N] [--mode research|hiring|competitors] +# [--timeout SECONDS] [--model NAME] +set -euo pipefail + +usage() { echo "usage: fetch-social.sh [--days N] [--mode research|hiring|competitors] [--timeout S] [--model NAME]" >&2; exit 2; } + +topic=""; days=30; mode="research"; timeout_s=420; model="" +while [ $# -gt 0 ]; do + case "$1" in + --days) days="${2:?"$1 needs a value"}"; shift 2 ;; + --mode) mode="${2:?"$1 needs a value"}"; shift 2 ;; + --timeout) timeout_s="${2:?"$1 needs a value"}"; shift 2 ;; + --model) model="${2:?"$1 needs a value"}"; shift 2 ;; + -h|--help) usage ;; + --*) echo "fetch-social: unknown flag: $1" >&2; usage ;; + *) if [ -z "$topic" ]; then topic="$1"; shift; else echo "fetch-social: unexpected arg: $1" >&2; usage; fi ;; + esac +done +[ -n "$topic" ] || usage +for v in "$days:--days" "$timeout_s:--timeout"; do + case "${v%%:*}" in ''|*[!0-9]*) echo "fetch-social: ${v#*:} must be an integer" >&2; exit 2 ;; esac +done +case "$mode" in research|hiring|competitors) ;; *) echo "fetch-social: --mode must be research|hiring|competitors" >&2; exit 2 ;; esac + +echo "## X ยท Reddit ยท Web (via grok)" +if ! command -v grok >/dev/null 2>&1; then + echo "_grok CLI not found โ€” install from https://x.ai/cli and run \`grok login\`_" + exit 0 +fi + +prompt="You are a social-signal researcher. Topic: \"${topic}\". Window: the last ${days} days. +First, internally resolve the key entities (X handles, subreddits, hashtags, project names). Then, in ONE pass, use your X search, Reddit, and web tools to find the most SIGNIFICANT, high-engagement discussion. +Return ONLY markdown with these subsections in order, each with up to 4 bullets ranked by engagement (skip a subsection entirely if nothing notable): +### X (Twitter) +### Reddit +### Web +Each bullet: a one-line takeaway, the author/source, engagement metrics if available (likes/reposts/views, upvotes), and a cited URL. Prefer concrete numbers and quote genuinely witty or viral lines verbatim. Be concise. No preamble, no closing summary." +case "$mode" in + hiring) prompt="${prompt} +Also add '### Hiring signals': open roles / careers-page changes that reveal where this person/company is focusing." ;; + competitors) prompt="${prompt} +Also add '### Competitors': the top 3 peers people compare this against, each with one line on how sentiment differs." ;; + *) : ;; +esac + +timeout_bin="" +if command -v timeout >/dev/null 2>&1; then timeout_bin="timeout" +elif command -v gtimeout >/dev/null 2>&1; then timeout_bin="gtimeout"; fi + +run_grok() { + if [ -n "$model" ]; then + if [ -n "$timeout_bin" ]; then "$timeout_bin" "${timeout_s}s" grok -p "$prompt" --model "$model" --output-format json + else grok -p "$prompt" --model "$model" --output-format json; fi + else + if [ -n "$timeout_bin" ]; then "$timeout_bin" "${timeout_s}s" grok -p "$prompt" --output-format json + else grok -p "$prompt" --output-format json; fi + fi +} + +errlog=$(mktemp -t grokery-grokerr) +json=$(run_grok 2>"$errlog") && rc=0 || rc=$? + +if [ "$rc" -eq 124 ] || [ "$rc" -eq 137 ]; then + echo "_grok exceeded ${timeout_s}s โ€” narrow the topic or raise --timeout; the keyless sources above/below still returned_" +elif [ "$rc" -ne 0 ]; then + # Classify precisely. Note: "Transport channel closed ... Auth(AuthorizationRequired)" + # is grok's transient worker-restart noise, NOT a real login failure โ€” don't + # mislabel it as one. + if grep -iqE 'spending.?limit|out of credits|run out of credits|need a grok subscription|insufficient.*credit|quota' "$errlog" 2>/dev/null; then + echo "_grok is out of credits / hit its usage limit โ€” check https://grok.com/?_s=usage or wait for the quota window to reset; the keyless sources still returned_" + elif grep -iqE 'not logged in|please log ?in|run .?grok login|sign in|no credentials|authentication required' "$errlog" 2>/dev/null; then + # shellcheck disable=SC2016 # backticks are literal markdown, not a subshell + echo "_grok is not authenticated โ€” run \`grok login\`_" + elif grep -iqE 'transport channel closed|connection refused|connectionrefused|worker quit|rate.?limit|429' "$errlog" 2>/dev/null; then + echo "_grok hit a transient worker/connection error (often a flaky session or rate-limit) โ€” retry; the keyless sources still returned_" + else + echo "_grok failed (exit ${rc}): $(grep -iE 'error|fatal' "$errlog" 2>/dev/null | tail -1 | cut -c1-160 || true)_" + fi +else + out=$(printf '%s' "$json" | jq -r '.text // empty' 2>/dev/null || true) + [ -z "$out" ] && out=$(printf '%s' "$json") + if [ -z "$out" ]; then echo "_grok returned no content_"; else printf '%s\n' "$out"; fi +fi +rm -f "$errlog" diff --git a/skills/grokery/scripts/fetch-youtube.sh b/skills/grokery/scripts/fetch-youtube.sh new file mode 100755 index 0000000..53d8abf --- /dev/null +++ b/skills/grokery/scripts/fetch-youtube.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# +# Fetching YouTube videos for a topic via yt-dlp (optional, keyless). Results are +# in YouTube's search-relevance order (flat-playlist mode does not return view +# counts to sort by); view counts are shown per item when available. With +# --transcripts, also pulls the top result's auto-captions (distillable into +# quotes by the calling agent). Degrades to a notice if yt-dlp is absent. Exits 0. +# +# Usage: fetch-youtube.sh [--max N] [--transcripts] +set -euo pipefail + +usage() { echo "usage: fetch-youtube.sh [--max N] [--transcripts]" >&2; exit 2; } + +topic=""; max=5; want_tx=0 +while [ $# -gt 0 ]; do + case "$1" in + --max) max="${2:?"$1 needs a value"}"; shift 2 ;; + --transcripts) want_tx=1; shift ;; + -h|--help) usage ;; + --*) echo "fetch-youtube: unknown flag: $1" >&2; usage ;; + *) if [ -z "$topic" ]; then topic="$1"; shift; else echo "fetch-youtube: unexpected arg: $1" >&2; usage; fi ;; + esac +done +[ -n "$topic" ] || usage +case "${max}" in ''|*[!0-9]*) echo "fetch-youtube: --max must be an integer (got '$max')" >&2; exit 2 ;; esac + +echo "## YouTube" +if ! command -v yt-dlp >/dev/null 2>&1; then + echo "_skipped โ€” yt-dlp not installed (\`brew install yt-dlp\` to enable)_" + exit 0 +fi + +# One flat-playlist search call; tab-separated fields parsed line by line. +listing=$(yt-dlp "ytsearch${max}:${topic}" --flat-playlist --skip-download \ + --print "%(view_count)s\t%(title)s\t%(webpage_url)s" 2>/dev/null || true) +if [ -z "$listing" ]; then + echo "_no results_" + exit 0 +fi +printf '%s\n' "$listing" | while IFS=$'\t' read -r views title url; do + [ -n "$url" ] || continue + printf -- "- **%s** views โ€” %s\n <%s>\n" "${views:-?}" "$title" "$url" +done + +if [ "$want_tx" -eq 1 ]; then + top_url=$(printf '%s\n' "$listing" | head -1 | cut -f3 || true) + if [ -n "$top_url" ]; then + tmpsub=$(mktemp -t grokery-ytsub) + yt-dlp --skip-download --write-auto-subs --sub-format vtt --sub-langs en \ + -o "$tmpsub" "$top_url" >/dev/null 2>&1 || true + sub="" + for cand in "${tmpsub}"*.vtt; do [ -e "$cand" ] && { sub="$cand"; break; }; done + if [ -n "$sub" ]; then + echo + echo "**Top-video transcript excerpt**" + grep -vE '^(WEBVTT|[0-9]|$|.*-->)' "$sub" 2>/dev/null | awk '!seen[$0]++' | head -25 || true + rm -f "${tmpsub}"*.vtt + fi + rm -f "$tmpsub" + fi +fi diff --git a/skills/grokery/scripts/grokery.sh b/skills/grokery/scripts/grokery.sh new file mode 100755 index 0000000..624f0b9 --- /dev/null +++ b/skills/grokery/scripts/grokery.sh @@ -0,0 +1,129 @@ +#!/usr/bin/env bash +# +# Orchestrating one grokery run: fan out every enabled source in parallel, +# assemble a combined raw-signal markdown doc, save a snapshot, and optionally +# render an HTML brief. The calling agent then dedupes, ranks, and synthesizes +# per the references/. +# +# This script only GATHERS. Synthesis, wit-scoring, comparison, and the *rich* +# HTML are the agent's job (see SKILL.md). `--html` here renders the raw doc as a +# deterministic fallback brief. +# +# Usage: +# grokery.sh [--days N] [--max N] [--mode research|hiring|competitors] +# [--timeout SECONDS_PER_CHANNEL] [--github-user ] +# [--save-dir ] [--save-suffix ] [--store] [--no-save] +# [--html] [--model ] +# +# Env: EXCLUDE_SOURCES=youtube,polymarket GROKERY_MEMORY_DIR=~/Documents/Grokery +set -euo pipefail + +here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) + +usage() { echo "usage: grokery.sh [--days N] [--max N] [--mode M] [--timeout S] [--github-user H] [--save-dir P] [--save-suffix S] [--store] [--no-save] [--html] [--model NAME]" >&2; exit 2; } + +topic=""; days=30; max=5; mode="research"; ghuser=""; suffix=""; social_timeout=420 +save_dir="${GROKERY_MEMORY_DIR:-$HOME/Documents/Grokery}"; do_save=1; do_store=0; do_html=0; model="" +while [ $# -gt 0 ]; do + case "$1" in + --days) days="${2:?"$1 needs a value"}"; shift 2 ;; + --max) max="${2:?"$1 needs a value"}"; shift 2 ;; + --mode) mode="${2:?"$1 needs a value"}"; shift 2 ;; + --timeout) social_timeout="${2:?"$1 needs a value"}"; shift 2 ;; + --github-user) ghuser="${2:?"$1 needs a value"}"; shift 2 ;; + --save-dir) save_dir="${2:?"$1 needs a value"}"; shift 2 ;; + --save-suffix) suffix="${2:?"$1 needs a value"}"; shift 2 ;; + --store) do_store=1; shift ;; + --no-save) do_save=0; shift ;; + --html) do_html=1; shift ;; + --model) model="${2:?"$1 needs a value"}"; shift 2 ;; + -h|--help) usage ;; + --*) echo "grokery: unknown flag: $1" >&2; usage ;; + *) if [ -z "$topic" ]; then topic="$1"; shift; else topic="$topic $1"; shift; fi ;; + esac +done +[ -n "$topic" ] || usage + +# --- validate inputs (fail loud, not silently mislabelled) ------------------ +is_uint() { case "${1:-x}" in ''|*[!0-9]*) return 1 ;; *) return 0 ;; esac; } +is_uint "$days" || { echo "grokery: --days must be a non-negative integer (got '$days')" >&2; exit 2; } +is_uint "$max" || { echo "grokery: --max must be a non-negative integer (got '$max')" >&2; exit 2; } +is_uint "$social_timeout" || { echo "grokery: --timeout must be a non-negative integer (got '$social_timeout')" >&2; exit 2; } +case "$mode" in research|hiring|competitors) ;; *) echo "grokery: --mode must be research|hiring|competitors (got '$mode')" >&2; exit 2 ;; esac + +# Is a source enabled? (suppressed via EXCLUDE_SOURCES=a,b,c) +enabled() { + case ",${EXCLUDE_SOURCES:-}," in + *",$1,"*) return 1 ;; + *) return 0 ;; + esac +} + +soc=$(mktemp -t grokery-soc); hn=$(mktemp -t grokery-hn) +gh=$(mktemp -t grokery-gh); pm=$(mktemp -t grokery-pm); yt=$(mktemp -t grokery-yt) +cleanup() { rm -f "$soc" "$hn" "$gh" "$pm" "$yt"; } +trap cleanup EXIT + +# Fan out. Each fetcher self-degrades and exits 0, printing its section header +# first, so a crash still leaves a marker. stderr is NOT suppressed here โ€” a real +# fetcher-level failure should be visible, not masked. +if enabled social; then + if [ -n "$model" ]; then + bash "$here/fetch-social.sh" "$topic" --days "$days" --mode "$mode" --timeout "$social_timeout" --model "$model" >"$soc" & + else + bash "$here/fetch-social.sh" "$topic" --days "$days" --mode "$mode" --timeout "$social_timeout" >"$soc" & + fi +fi +enabled hackernews && bash "$here/fetch-hn.sh" "$topic" --days "$days" --max "$max" >"$hn" & +if enabled github; then + if [ -n "$ghuser" ]; then bash "$here/fetch-github.sh" --user "$ghuser" --max "$max" >"$gh" & + else bash "$here/fetch-github.sh" "$topic" --max "$max" --pushed-days "$days" >"$gh" & fi +fi +enabled polymarket && bash "$here/fetch-polymarket.sh" "$topic" --max "$max" >"$pm" & +enabled youtube && bash "$here/fetch-youtube.sh" "$topic" --max "$max" >"$yt" & +wait + +stamp=$(date -u +%Y-%m-%dT%H:%MZ) +doc=$( + printf '# grokery โ€” "%s"\n' "$topic" + printf '_generated %s ยท mode: %s ยท window: X/Reddit/Web/HN/GitHub โ‰ค %sd; Polymarket & YouTube show current top results_\n\n' "$stamp" "$mode" "$days" + for f in "$soc" "$hn" "$gh" "$pm" "$yt"; do + if [ -s "$f" ]; then cat "$f"; printf '\n\n'; fi + done +) +printf '%s\n' "$doc" + +# Persist a snapshot (markdown), mirroring last30days' per-run memory file. +if [ "$do_save" -eq 1 ]; then + slug=$(printf '%s' "$topic" | tr '[:upper:]' '[:lower:]' | tr -cs 'a-z0-9' '-' | sed 's/^-//;s/-$//') + [ -n "$slug" ] || slug="topic" + if [ -n "$suffix" ]; then + cleansuffix=$(printf '%s' "$suffix" | tr '[:upper:]' '[:lower:]' | tr -cs 'a-z0-9' '-' | sed 's/^-//;s/-$//') + [ -n "$cleansuffix" ] && slug="${slug}-${cleansuffix}" + fi + mkdir -p "$save_dir" + out="${save_dir}/${slug}-raw.md" + printf '%s\n' "$doc" >"$out" + echo "[grokery] snapshot saved: $out" >&2 + if [ "$do_store" -eq 1 ] && command -v python3 >/dev/null 2>&1; then + if python3 "$here/store.py" --db "${save_dir}/grokery.sqlite3" --topic "$topic" --slug "$slug" --file "$out"; then + echo "[grokery] stored run in ${save_dir}/grokery.sqlite3" >&2 + else + echo "[grokery] WARNING: --store failed to write SQLite" >&2 + fi + fi + if [ "$do_html" -eq 1 ]; then + htmlout="${out%.md}.html" + if bash "$here/render-html.sh" "$out" --title "grokery โ€” ${topic}" --out "$htmlout" >/dev/null; then + echo "[grokery] HTML brief: $htmlout" >&2 + else + echo "[grokery] WARNING: --html render failed" >&2 + fi + fi +elif [ "$do_html" -eq 1 ]; then + # --html with --no-save: render from a throwaway markdown file into the cwd. + tmpmd=$(mktemp -t grokery-md); printf '%s\n' "$doc" >"$tmpmd" + bash "$here/render-html.sh" "$tmpmd" --title "grokery โ€” ${topic}" --out "./grokery-brief.html" >/dev/null \ + && echo "[grokery] HTML brief: ./grokery-brief.html" >&2 || echo "[grokery] WARNING: --html render failed" >&2 + rm -f "$tmpmd" +fi diff --git a/skills/grokery/scripts/render-html.sh b/skills/grokery/scripts/render-html.sh new file mode 100755 index 0000000..f245989 --- /dev/null +++ b/skills/grokery/scripts/render-html.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +# +# Rendering a synthesized markdown brief into a self-contained, dark-mode, +# offline (no-JS, inline-CSS, system-font) HTML file โ€” the grokery equivalent of +# last30days' --emit=html / --synthesis-file. Deterministic fallback; the agent +# may instead hand-craft richer HTML from references/brief-template.html. +# +# Usage: render-html.sh [--title "..."] [--out ] +set -euo pipefail + +usage() { echo "usage: render-html.sh [--title STR] [--out PATH]" >&2; exit 2; } + +src=""; title=""; out="" +while [ $# -gt 0 ]; do + case "$1" in + --title) title="${2:-}"; shift 2 ;; + --out) out="${2:-}"; shift 2 ;; + -h|--help) usage ;; + --*) echo "render-html: unknown flag: $1" >&2; usage ;; + *) if [ -z "$src" ]; then src="$1"; shift; else echo "render-html: unexpected arg: $1" >&2; usage; fi ;; + esac +done +[ -n "$src" ] && [ -f "$src" ] || { echo "render-html: markdown file required" >&2; usage; } +[ -n "$title" ] || title="grokery brief" +[ -n "$out" ] || out="${src%.md}.html" + +GROKERY_TITLE="$title" python3 - "$src" "$out" <<'PY' +import html, os, re, sys + +src, out = sys.argv[1], sys.argv[2] +title = os.environ.get("GROKERY_TITLE", "grokery brief") +with open(src, encoding="utf-8") as fh: + lines = fh.read().splitlines() + +def inline(s: str) -> str: + s = html.escape(s) + s = re.sub(r'`([^`]+)`', r'\1', s) + s = re.sub(r'\*\*([^*]+)\*\*', r'\1', s) + s = re.sub(r'\*([^*]+)\*', r'\1', s) + s = re.sub(r'\[([^\]]+)\]\((https?://(?:[^()\s]|\([^()]*\))+)\)', r'\1', s) + s = re.sub(r'<(https?://[^&]+)>', r'\1', s) + return s + +body, in_list, in_code = [], False, False +def close_list(): + global in_list + if in_list: + body.append(""); in_list = False + +for ln in lines: + raw = ln.rstrip() + if raw.lstrip().startswith("```"): + close_list() + if in_code: + body.append(""); in_code = False + else: + body.append("
"); in_code = True
+        continue
+    if in_code:
+        body.append(html.escape(raw)); continue
+    if not raw.strip():
+        close_list(); continue
+    if raw.startswith("### "):
+        close_list(); body.append(f"

{inline(raw[4:])}

") + elif raw.startswith("## "): + close_list(); body.append(f"

{inline(raw[3:])}

") + elif raw.startswith("# "): + close_list(); body.append(f"

{inline(raw[2:])}

") + elif raw.strip() in ("---", "***"): + close_list(); body.append("
") + elif re.match(r'^\s*[-*]\s+', raw): + if not in_list: + body.append("
    "); in_list = True + body.append(f"
  • {inline(re.sub(r'^\s*[-*]\s+', '', raw))}
  • ") + else: + close_list(); body.append(f"

    {inline(raw)}

    ") +close_list() +if in_code: + body.append("
") + +doc = f""" + + +{html.escape(title)} + +
+grokery ยท signal brief +{os.linesep.join(body)} +
Generated by grokery โ€” grok-powered cross-platform signal scan. ยท Ranked by engagement, not SEO.
+
+""" +with open(out, "w", encoding="utf-8") as fh: + fh.write(doc) +print(out) +PY diff --git a/skills/grokery/scripts/store.py b/skills/grokery/scripts/store.py new file mode 100755 index 0000000..9c84797 --- /dev/null +++ b/skills/grokery/scripts/store.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +"""Storing a grokery run in SQLite for cross-run trend monitoring. + +Mirrors last30days' ``--store``: append-only log of (topic, slug, timestamp, +raw-markdown path, byte size) so later runs can diff what changed. Stdlib only. + +Usage: store.py --db PATH --topic STR --slug STR --file PATH +""" +from __future__ import annotations + +import argparse +import datetime as _dt +import os +import sqlite3 +import sys + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--db", required=True) + ap.add_argument("--topic", required=True) + ap.add_argument("--slug", required=True) + ap.add_argument("--file", required=True) + args = ap.parse_args() + + size = os.path.getsize(args.file) if os.path.exists(args.file) else 0 + ts = _dt.datetime.now(_dt.timezone.utc).isoformat(timespec="seconds") + + con = sqlite3.connect(args.db) + try: + con.execute( + "CREATE TABLE IF NOT EXISTS runs (" + "id INTEGER PRIMARY KEY AUTOINCREMENT, ts TEXT, topic TEXT, " + "slug TEXT, path TEXT, bytes INTEGER)" + ) + con.execute( + "INSERT INTO runs (ts, topic, slug, path, bytes) VALUES (?,?,?,?,?)", + (ts, args.topic, args.slug, args.file, size), + ) + con.commit() + finally: + con.close() + print(f"stored: {args.slug} @ {ts}", file=sys.stderr) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())