From 68e923a16fec4257bcde943352cb631e40738727 Mon Sep 17 00:00:00 2001 From: pa0 Date: Tue, 17 Feb 2026 20:02:01 -0800 Subject: [PATCH 01/14] chg: ignore dev files --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index b7faf40..19aaee7 100644 --- a/.gitignore +++ b/.gitignore @@ -205,3 +205,7 @@ cython_debug/ marimo/_static/ marimo/_lsp/ __marimo__/ + +# uv +uv.lock +pyproject.toml From 76b6334b0b624c1a293fdee2c4c485504d7b6908 Mon Sep 17 00:00:00 2001 From: pa0 Date: Tue, 17 Feb 2026 20:02:38 -0800 Subject: [PATCH 02/14] new: freeze python version 3.11 --- .python-version | 1 + 1 file changed, 1 insertion(+) create mode 100644 .python-version diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..2c07333 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.11 From b5fb58beea49282e7556e0ce45fb0d716619c257 Mon Sep 17 00:00:00 2001 From: pa0 Date: Tue, 17 Feb 2026 20:09:04 -0800 Subject: [PATCH 03/14] new: dot env file --- .env.example | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .env.example diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9a7bece --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +# LLM provider: openai or cursor +TOCIFY_LLM=cursor + +# API key for the selected provider (use TOCIFY_API_KEY or, for Cursor only, CURSOR_API_KEY) +# Get Cursor key from Cursor settings; OpenAI key from platform.openai.com +TOCIFY_API_KEY= + +# Optional: smaller/faster local run +# MAX_ITEMS_PER_FEED=5 +# PREFILTER_KEEP_TOP=20 +# BATCH_SIZE=10 From 4497eb8c39c3809a3ba0b464c955715f720081bd Mon Sep 17 00:00:00 2001 From: pa0 Date: Tue, 17 Feb 2026 20:48:22 -0800 Subject: [PATCH 04/14] new: add dotenv and dateutil --- requirements.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 25acdaf..d11ed76 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ openai>=1.0.0 feedparser>=6.0.0 -python-dateutil>=2.9.0 \ No newline at end of file +python-dateutil>=2.9.0 +python-dotenv>=1.0.0 \ No newline at end of file From a778609aa7bf9c0057650686baecab94bf004ae9 Mon Sep 17 00:00:00 2001 From: pa0 Date: Tue, 17 Feb 2026 22:22:58 -0800 Subject: [PATCH 05/14] chg: switch from openai to cursor cli --- .env.example | 13 ++----------- requirements.txt | 1 - 2 files changed, 2 insertions(+), 12 deletions(-) diff --git a/.env.example b/.env.example index 9a7bece..17d41f8 100644 --- a/.env.example +++ b/.env.example @@ -1,11 +1,2 @@ -# LLM provider: openai or cursor -TOCIFY_LLM=cursor - -# API key for the selected provider (use TOCIFY_API_KEY or, for Cursor only, CURSOR_API_KEY) -# Get Cursor key from Cursor settings; OpenAI key from platform.openai.com -TOCIFY_API_KEY= - -# Optional: smaller/faster local run -# MAX_ITEMS_PER_FEED=5 -# PREFILTER_KEEP_TOP=20 -# BATCH_SIZE=10 +# Cursor API key (from Cursor settings). +CURSOR_API_KEY= diff --git a/requirements.txt b/requirements.txt index d11ed76..c826da5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,3 @@ -openai>=1.0.0 feedparser>=6.0.0 python-dateutil>=2.9.0 python-dotenv>=1.0.0 \ No newline at end of file From e4ae018513662b830d74c7402d11d154fd3037ff Mon Sep 17 00:00:00 2001 From: pa0 Date: Tue, 17 Feb 2026 22:23:29 -0800 Subject: [PATCH 06/14] chg: workflow installs cursor cli --- .github/workflows/weekly-digest.yml | 20 +++++--------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/.github/workflows/weekly-digest.yml b/.github/workflows/weekly-digest.yml index b97c0cf..479207d 100644 --- a/.github/workflows/weekly-digest.yml +++ b/.github/workflows/weekly-digest.yml @@ -25,32 +25,22 @@ jobs: run: | python -m pip install --upgrade pip pip install -r requirements.txt - pip install --upgrade openai httpx certifi - - - name: Network check (OpenAI) - run: | - python - << 'PY' - import socket - host = "api.openai.com" - print("Resolving:", host) - print(socket.gethostbyname(host)) - print("OK: DNS resolve") - PY - curl -I https://api.openai.com/v1/models --max-time 20 - name: Run digest env: - OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }} HTTP_PROXY: "" HTTPS_PROXY: "" ALL_PROXY: "" - NO_PROXY: "api.openai.com" MIN_SCORE_READ: "0.35" LOOKBACK_DAYS: "7" SUMMARY_MAX_CHARS: "500" PREFILTER_KEEP_TOP: "200" BATCH_SIZE: "50" - run: python digest.py + run: | + curl https://cursor.com/install -fsS | bash + export PATH="$HOME/.cursor/bin:$PATH" + python digest.py - name: Commit digest.md run: | From 79da47eee2c3b63452c4c13e2b44c27d01adb173 Mon Sep 17 00:00:00 2001 From: pa0 Date: Tue, 17 Feb 2026 22:23:59 -0800 Subject: [PATCH 07/14] chg: switch codebase to cursor cli --- README.md | 26 ++++++-------- digest.py | 105 ++++++++++++++++++++++++++---------------------------- 2 files changed, 60 insertions(+), 71 deletions(-) diff --git a/README.md b/README.md index 9d9a305..32563ef 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ -# tocify — Weekly Journal ToC Digest (RSS → OpenAI → `digest.md`) +# tocify — Weekly Journal ToC Digest (RSS → Cursor → `digest.md`) This repo runs a GitHub Action once a week (or on-demand) that: 1. pulls new items from a list of journal RSS feeds -2. uses OpenAI to triage which items match your research interests +2. uses the Cursor CLI to triage which items match your research interests 3. writes a ranked digest to `digest.md` and commits it back to the repo It’s meant to be forked and customized. @@ -14,7 +14,7 @@ This was almost entirely vibe-coded as an exercise (I'm pleased at how well it w ## What’s in this repo -- **`digest.py`** — the pipeline (fetch RSS → filter → OpenAI triage → render markdown) +- **`digest.py`** — the pipeline (fetch RSS → filter → Cursor triage → render markdown) - **`feeds.txt`** — RSS feed list (supports comments; optionally supports `Name | URL`) - **`interests.md`** — your keywords + narrative seed (used for relevance) - **`prompt.txt`** — the prompt template (easy to tune without editing Python) @@ -29,27 +29,21 @@ This was almost entirely vibe-coded as an exercise (I'm pleased at how well it w ### 1) Fork the repo - Click **Fork** on GitHub to copy this repo into your account. -### 2) Enable OpenAI billing / credits -The OpenAI API requires an active billing setup or credits. -- Go to the OpenAI Platform and ensure billing is enabled and/or credits are available. -- If you see errors like `insufficient_quota` or `You exceeded your current quota`, this is the cause. -- I recommend putting in spending limits. This uses very little compute, but it's nice to be careful. - -### 3) Create an OpenAI API key -Create an API key in the OpenAI Platform and copy it. +### 2) Cursor CLI and API key +Ensure the **Cursor CLI** (`cursor` or `agent`) is installed and on `PATH` where the digest runs (e.g. your machine for local runs; for GitHub Actions you must use a runner or step that provides it). Get your API key from Cursor settings. **Important:** never commit this key to the repo. -### 4) Add the API key as a GitHub Actions secret +### 3) Add the API key as a GitHub Actions secret In your forked repo: - Go to **Settings → Secrets and variables → Actions** - Click **New repository secret** -- Name: `OPENAI_API_KEY` -- Value: paste your OpenAI API key +- Name: `CURSOR_API_KEY` +- Value: paste your Cursor API key -That’s it—GitHub will inject it into the workflow at runtime. +GitHub will inject it into the workflow at runtime. -### 5) Configure your feeds +### 4) Configure your feeds Edit **`feeds.txt`**. You can use comments: diff --git a/digest.py b/digest.py index d1a6655..bf06139 100644 --- a/digest.py +++ b/digest.py @@ -1,14 +1,13 @@ -import os, re, json, time, math, hashlib +import os, re, json, time, math, hashlib, subprocess from datetime import datetime, timezone, timedelta import feedparser -import httpx from dateutil import parser as dtparser -from openai import OpenAI, APITimeoutError, APIConnectionError, RateLimitError +from dotenv import load_dotenv +load_dotenv() # ---- config (env-tweakable) ---- -MODEL = os.getenv("OPENAI_MODEL", "gpt-4o") MAX_ITEMS_PER_FEED = int(os.getenv("MAX_ITEMS_PER_FEED", "50")) MAX_TOTAL_ITEMS = int(os.getenv("MAX_TOTAL_ITEMS", "400")) LOOKBACK_DAYS = int(os.getenv("LOOKBACK_DAYS", "7")) @@ -19,33 +18,12 @@ MIN_SCORE_READ = float(os.getenv("MIN_SCORE_READ", "0.65")) MAX_RETURNED = int(os.getenv("MAX_RETURNED", "40")) -SCHEMA = { - "type": "object", - "additionalProperties": False, - "properties": { - "week_of": {"type": "string"}, - "notes": {"type": "string"}, - "ranked": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": False, - "properties": { - "id": {"type": "string"}, - "title": {"type": "string"}, - "link": {"type": "string"}, - "source": {"type": "string"}, - "published_utc": {"type": ["string", "null"]}, - "score": {"type": "number"}, - "why": {"type": "string"}, - "tags": {"type": "array", "items": {"type": "string"}}, - }, - "required": ["id", "title", "link", "source", "published_utc", "score", "why", "tags"], - }, - }, - }, - "required": ["week_of", "notes", "ranked"], -} +# Cursor CLI: no structured output; append schema + strict JSON instruction to prompt +CURSOR_PROMPT_SUFFIX = """ + +Return **only** a single JSON object, no markdown code fences, no commentary. Schema: +{"week_of": "", "notes": "", "ranked": [{"id": "", "title": "", "link": "", "source": "", "published_utc": "", "score": <0-1>, "why": "", "tags": [""]}]} +""" # ---- tiny helpers ---- @@ -181,20 +159,19 @@ def hits(it): return matched[:keep_top] -# ---- openai ---- -def make_openai_client() -> OpenAI: - key = os.environ.get("OPENAI_API_KEY", "").strip() - if not key.startswith("sk-"): - raise RuntimeError("OPENAI_API_KEY missing/invalid (expected to start with 'sk-').") - http_client = httpx.Client( - timeout=httpx.Timeout(connect=30.0, read=300.0, write=30.0, pool=30.0), - http2=False, - trust_env=False, - headers={"Connection": "close", "Accept-Encoding": "gzip"}, +# ---- cursor ---- +def _cursor_cli() -> str: + return os.getenv("TOCIFY_CURSOR_CLI", "cursor").strip() or "cursor" + + +def _cursor_api_key() -> str: + return ( + os.environ.get("CURSOR_API_KEY", "").strip() + or os.environ.get("TOCIFY_API_KEY", "").strip() ) - return OpenAI(api_key=key, http_client=http_client) -def call_openai_triage(client: OpenAI, interests: dict, items: list[dict]) -> dict: + +def call_cursor_triage(interests: dict, items: list[dict]) -> dict: lean_items = [{ "id": it["id"], "source": it["source"], @@ -204,8 +181,7 @@ def call_openai_triage(client: OpenAI, interests: dict, items: list[dict]) -> di "summary": (it.get("summary") or "")[:SUMMARY_MAX_CHARS], } for it in items] - template = load_prompt_template() - + template = load_prompt_template() + CURSOR_PROMPT_SUFFIX prompt = ( template .replace("{{KEYWORDS}}", json.dumps(interests["keywords"], ensure_ascii=False)) @@ -213,21 +189,37 @@ def call_openai_triage(client: OpenAI, interests: dict, items: list[dict]) -> di .replace("{{ITEMS}}", json.dumps(lean_items, ensure_ascii=False)) ) + cli = _cursor_cli() last = None + result = None for attempt in range(6): try: - resp = client.responses.create( - model=MODEL, - input=prompt, - text={"format": {"type": "json_schema", "name": "weekly_toc_digest", "schema": SCHEMA, "strict": True}}, + result = subprocess.run( + [cli, "-p", "--output-format", "text", prompt], + capture_output=True, + text=True, + env=os.environ, ) - return json.loads(resp.output_text) - except (APITimeoutError, APIConnectionError, RateLimitError) as e: + if result.returncode != 0: + raise RuntimeError( + f"cursor CLI exit {result.returncode}: {result.stderr or result.stdout or 'no output'}" + ) + response_text = (result.stdout or "").strip() + start = response_text.find("{") + end = response_text.rfind("}") + 1 + if start < 0 or end <= start: + raise ValueError("No JSON object found in Cursor output") + parsed = json.loads(response_text[start:end]) + if not isinstance(parsed, dict) or "ranked" not in parsed: + raise ValueError("Cursor output missing required 'ranked' field") + return parsed + except (ValueError, json.JSONDecodeError, RuntimeError) as e: last = e time.sleep(min(60, 2 ** attempt)) raise last -def triage_in_batches(client: OpenAI, interests: dict, items: list[dict], batch_size: int) -> dict: + +def triage_in_batches(interests: dict, items: list[dict], batch_size: int) -> dict: week_of = datetime.now(timezone.utc).date().isoformat() total = math.ceil(len(items) / batch_size) all_ranked, notes_parts = [], [] @@ -235,7 +227,7 @@ def triage_in_batches(client: OpenAI, interests: dict, items: list[dict], batch_ for i in range(0, len(items), batch_size): batch = items[i:i + batch_size] print(f"Triage batch {i // batch_size + 1}/{total} ({len(batch)} items)") - res = call_openai_triage(client, interests, batch) + res = call_cursor_triage(interests, batch) if res.get("notes", "").strip(): notes_parts.append(res["notes"].strip()) all_ranked.extend(res.get("ranked", [])) @@ -308,9 +300,12 @@ def main(): print(f"Sending {len(items)} RSS items to model (post-filter)") items_by_id = {it["id"]: it for it in items} - client = make_openai_client() - result = triage_in_batches(client, interests, items, batch_size=BATCH_SIZE) + if not _cursor_api_key(): + raise RuntimeError( + "CURSOR_API_KEY or TOCIFY_API_KEY must be set (get key from Cursor settings)." + ) + result = triage_in_batches(interests, items, BATCH_SIZE) md = render_digest_md(result, items_by_id) with open("digest.md", "w", encoding="utf-8") as f: From 3d32e4ceb423b59742438c5437d5655aafc3351b Mon Sep 17 00:00:00 2001 From: pa0 Date: Tue, 17 Feb 2026 22:31:00 -0800 Subject: [PATCH 08/14] fix: hard-code cli command --- digest.py | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/digest.py b/digest.py index bf06139..dfa7bcd 100644 --- a/digest.py +++ b/digest.py @@ -160,15 +160,8 @@ def hits(it): # ---- cursor ---- -def _cursor_cli() -> str: - return os.getenv("TOCIFY_CURSOR_CLI", "cursor").strip() or "cursor" - - def _cursor_api_key() -> str: - return ( - os.environ.get("CURSOR_API_KEY", "").strip() - or os.environ.get("TOCIFY_API_KEY", "").strip() - ) + return os.environ.get("CURSOR_API_KEY", "").strip() def call_cursor_triage(interests: dict, items: list[dict]) -> dict: @@ -189,13 +182,13 @@ def call_cursor_triage(interests: dict, items: list[dict]) -> dict: .replace("{{ITEMS}}", json.dumps(lean_items, ensure_ascii=False)) ) - cli = _cursor_cli() + args = ["agent", "-p", "--output-format", "text", "--trust", prompt] last = None result = None for attempt in range(6): try: result = subprocess.run( - [cli, "-p", "--output-format", "text", prompt], + args, capture_output=True, text=True, env=os.environ, @@ -303,7 +296,7 @@ def main(): if not _cursor_api_key(): raise RuntimeError( - "CURSOR_API_KEY or TOCIFY_API_KEY must be set (get key from Cursor settings)." + "CURSOR_API_KEY must be set (get key from Cursor settings)." ) result = triage_in_batches(interests, items, BATCH_SIZE) md = render_digest_md(result, items_by_id) From d4ccde9ac20f417ab229d16cfb4a35860b9a560e Mon Sep 17 00:00:00 2001 From: pa0 Date: Thu, 19 Feb 2026 18:21:11 -0800 Subject: [PATCH 09/14] chg: simplify env file, restore openai requirements --- .env.example | 11 ++++++++++- requirements.txt | 4 +++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index 17d41f8..d11268e 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,11 @@ -# Cursor API key (from Cursor settings). +# Use one backend (or set TOCIFY_BACKEND=openai|cursor to force). + +# OpenAI: easiest for most users — just set this and run. +OPENAI_API_KEY= +OPENAI_MODEL=gpt-4o-mini + +# Cursor CLI: needs `agent` on PATH and this key. CURSOR_API_KEY= + +# Optional: openai | cursor (default: auto from which key is set) +# TOCIFY_BACKEND= diff --git a/requirements.txt b/requirements.txt index c826da5..5842168 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,5 @@ feedparser>=6.0.0 python-dateutil>=2.9.0 -python-dotenv>=1.0.0 \ No newline at end of file +python-dotenv>=1.0.0 +openai>=1.0.0 +httpx>=0.27.0 \ No newline at end of file From 6acf49e18831f1051d31667b4884b3fcbffd2205 Mon Sep 17 00:00:00 2001 From: pa0 Date: Thu, 19 Feb 2026 18:21:58 -0800 Subject: [PATCH 10/14] new: add functions for cursor, restore openai --- digest.py | 117 ++++++++++++++++++++++++++++++++++-------------------- 1 file changed, 75 insertions(+), 42 deletions(-) diff --git a/digest.py b/digest.py index dfa7bcd..e70bf76 100644 --- a/digest.py +++ b/digest.py @@ -1,13 +1,22 @@ -import os, re, json, time, math, hashlib, subprocess +import os, re, json, time, math, hashlib from datetime import datetime, timezone, timedelta import feedparser +import httpx from dateutil import parser as dtparser from dotenv import load_dotenv +from openai import OpenAI, APITimeoutError, APIConnectionError, RateLimitError load_dotenv() +# Optional Cursor backend (only imported when Cursor path is chosen) +def _get_triage_backend(): + from integrations import get_triage_backend + return get_triage_backend() + + # ---- config (env-tweakable) ---- +MODEL = os.getenv("OPENAI_MODEL", "gpt-4o") MAX_ITEMS_PER_FEED = int(os.getenv("MAX_ITEMS_PER_FEED", "50")) MAX_TOTAL_ITEMS = int(os.getenv("MAX_TOTAL_ITEMS", "400")) LOOKBACK_DAYS = int(os.getenv("LOOKBACK_DAYS", "7")) @@ -18,12 +27,33 @@ MIN_SCORE_READ = float(os.getenv("MIN_SCORE_READ", "0.65")) MAX_RETURNED = int(os.getenv("MAX_RETURNED", "40")) -# Cursor CLI: no structured output; append schema + strict JSON instruction to prompt -CURSOR_PROMPT_SUFFIX = """ - -Return **only** a single JSON object, no markdown code fences, no commentary. Schema: -{"week_of": "", "notes": "", "ranked": [{"id": "", "title": "", "link": "", "source": "", "published_utc": "", "score": <0-1>, "why": "", "tags": [""]}]} -""" +SCHEMA = { + "type": "object", + "additionalProperties": False, + "properties": { + "week_of": {"type": "string"}, + "notes": {"type": "string"}, + "ranked": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": False, + "properties": { + "id": {"type": "string"}, + "title": {"type": "string"}, + "link": {"type": "string"}, + "source": {"type": "string"}, + "published_utc": {"type": ["string", "null"]}, + "score": {"type": "number"}, + "why": {"type": "string"}, + "tags": {"type": "array", "items": {"type": "string"}}, + }, + "required": ["id", "title", "link", "source", "published_utc", "score", "why", "tags"], + }, + }, + }, + "required": ["week_of", "notes", "ranked"], +} # ---- tiny helpers ---- @@ -61,7 +91,7 @@ def load_feeds(path: str) -> list[dict]: def read_text(path: str) -> str: with open(path, "r", encoding="utf-8") as f: return f.read() - + def load_prompt_template(path: str = "prompt.txt") -> str: if not os.path.exists(path): raise RuntimeError("prompt.txt not found in repo root") @@ -159,12 +189,20 @@ def hits(it): return matched[:keep_top] -# ---- cursor ---- -def _cursor_api_key() -> str: - return os.environ.get("CURSOR_API_KEY", "").strip() - +# ---- openai (default backend) ---- +def make_openai_client() -> OpenAI: + key = os.environ.get("OPENAI_API_KEY", "").strip() + if not key.startswith("sk-"): + raise RuntimeError("OPENAI_API_KEY missing/invalid (expected to start with 'sk-').") + http_client = httpx.Client( + timeout=httpx.Timeout(connect=30.0, read=300.0, write=30.0, pool=30.0), + http2=False, + trust_env=False, + headers={"Connection": "close", "Accept-Encoding": "gzip"}, + ) + return OpenAI(api_key=key, http_client=http_client) -def call_cursor_triage(interests: dict, items: list[dict]) -> dict: +def call_openai_triage(client: OpenAI, interests: dict, items: list[dict]) -> dict: lean_items = [{ "id": it["id"], "source": it["source"], @@ -174,7 +212,8 @@ def call_cursor_triage(interests: dict, items: list[dict]) -> dict: "summary": (it.get("summary") or "")[:SUMMARY_MAX_CHARS], } for it in items] - template = load_prompt_template() + CURSOR_PROMPT_SUFFIX + template = load_prompt_template() + prompt = ( template .replace("{{KEYWORDS}}", json.dumps(interests["keywords"], ensure_ascii=False)) @@ -182,37 +221,24 @@ def call_cursor_triage(interests: dict, items: list[dict]) -> dict: .replace("{{ITEMS}}", json.dumps(lean_items, ensure_ascii=False)) ) - args = ["agent", "-p", "--output-format", "text", "--trust", prompt] last = None - result = None for attempt in range(6): try: - result = subprocess.run( - args, - capture_output=True, - text=True, - env=os.environ, + resp = client.responses.create( + model=MODEL, + input=prompt, + text={"format": {"type": "json_schema", "name": "weekly_toc_digest", "schema": SCHEMA, "strict": True}}, ) - if result.returncode != 0: - raise RuntimeError( - f"cursor CLI exit {result.returncode}: {result.stderr or result.stdout or 'no output'}" - ) - response_text = (result.stdout or "").strip() - start = response_text.find("{") - end = response_text.rfind("}") + 1 - if start < 0 or end <= start: - raise ValueError("No JSON object found in Cursor output") - parsed = json.loads(response_text[start:end]) - if not isinstance(parsed, dict) or "ranked" not in parsed: - raise ValueError("Cursor output missing required 'ranked' field") - return parsed - except (ValueError, json.JSONDecodeError, RuntimeError) as e: + return json.loads(resp.output_text) + except (APITimeoutError, APIConnectionError, RateLimitError) as e: last = e time.sleep(min(60, 2 ** attempt)) raise last -def triage_in_batches(interests: dict, items: list[dict], batch_size: int) -> dict: +# ---- triage (backend-agnostic batch loop) ---- +def triage_in_batches(interests: dict, items: list[dict], batch_size: int, triage_fn) -> dict: + """triage_fn(interests, batch) -> dict with keys notes, ranked (and optionally week_of).""" week_of = datetime.now(timezone.utc).date().isoformat() total = math.ceil(len(items) / batch_size) all_ranked, notes_parts = [], [] @@ -220,7 +246,7 @@ def triage_in_batches(interests: dict, items: list[dict], batch_size: int) -> di for i in range(0, len(items), batch_size): batch = items[i:i + batch_size] print(f"Triage batch {i // batch_size + 1}/{total} ({len(batch)} items)") - res = call_cursor_triage(interests, batch) + res = triage_fn(interests, batch) if res.get("notes", "").strip(): notes_parts.append(res["notes"].strip()) all_ranked.extend(res.get("ranked", [])) @@ -294,11 +320,18 @@ def main(): items_by_id = {it["id"]: it for it in items} - if not _cursor_api_key(): - raise RuntimeError( - "CURSOR_API_KEY must be set (get key from Cursor settings)." - ) - result = triage_in_batches(interests, items, BATCH_SIZE) + # Backend: Cursor if requested, else in-file OpenAI + use_cursor = ( + os.getenv("TOCIFY_BACKEND", "").strip().lower() == "cursor" + or bool(os.getenv("CURSOR_API_KEY", "").strip()) + ) + if use_cursor: + triage_fn = _get_triage_backend() + else: + client = make_openai_client() + triage_fn = lambda i, b: call_openai_triage(client, i, b) + + result = triage_in_batches(interests, items, BATCH_SIZE, triage_fn) md = render_digest_md(result, items_by_id) with open("digest.md", "w", encoding="utf-8") as f: From bea598edabcebadf5f8fa22e9317e308491c31dd Mon Sep 17 00:00:00 2001 From: pa0 Date: Thu, 19 Feb 2026 18:22:32 -0800 Subject: [PATCH 11/14] chg: readme for either usage --- README.md | 59 +++++++++++++++++++++++++++++++++---------------------- 1 file changed, 36 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 32563ef..d882596 100644 --- a/README.md +++ b/README.md @@ -1,49 +1,62 @@ -# tocify — Weekly Journal ToC Digest (RSS → Cursor → `digest.md`) +# tocify — Weekly Journal ToC Digest (RSS → triage → `digest.md`) This repo runs a GitHub Action once a week (or on-demand) that: 1. pulls new items from a list of journal RSS feeds -2. uses the Cursor CLI to triage which items match your research interests +2. triages items against your research interests (OpenAI API or Cursor CLI) 3. writes a ranked digest to `digest.md` and commits it back to the repo It’s meant to be forked and customized. -This was almost entirely vibe-coded as an exercise (I'm pleased at how well it works!) - --- ## What’s in this repo -- **`digest.py`** — the pipeline (fetch RSS → filter → Cursor triage → render markdown) -- **`feeds.txt`** — RSS feed list (supports comments; optionally supports `Name | URL`) -- **`interests.md`** — your keywords + narrative seed (used for relevance) -- **`prompt.txt`** — the prompt template (easy to tune without editing Python) +- **`digest.py`** — pipeline (fetch RSS → filter → triage → render markdown) +- **`integrations/`** — optional Cursor CLI triage backend (default: in-file OpenAI in digest.py) +- **`feeds.txt`** — RSS feed list (comments; optional `Name | URL`) +- **`interests.md`** — keywords + narrative (used for relevance) +- **`prompt.txt`** — prompt template (used by OpenAI and Cursor backends) - **`digest.md`** — generated output (auto-updated) -- **`.github/workflows/weekly-digest.yml`** — scheduled GitHub Action runner +- **`.github/workflows/weekly-digest.yml`** — scheduled GitHub Action - **`requirements.txt`** — Python dependencies +- **`.python-version`** — pinned Python version (used by uv, pyenv, etc.) + +--- + +## Environment + +Python version is pinned in **`.python-version`** (e.g. `3.11`). The repo supports **[uv](https://docs.astral.sh/uv/)** for fast, reproducible installs: + +```bash +# Install uv (https://docs.astral.sh/uv/getting-started/installation/), then: +uv venv +uv pip install -r requirements.txt +uv run python digest.py +``` + +Alternatively use pip and a venv as usual; the GitHub workflow uses uv and reads `.python-version`. --- -## Quick start (fork + run) +## Quick start (layperson: OpenAI) -### 1) Fork the repo -- Click **Fork** on GitHub to copy this repo into your account. +1. **Fork** the repo. +2. Set **`OPENAI_API_KEY`** (get one from platform.openai.com). Never commit it. +3. Locally: copy `.env.example` to `.env`, add your key, run `python digest.py`. +4. For GitHub Actions: add secret **`OPENAI_API_KEY`** in Settings → Secrets. The workflow will use it; no CLI needed. -### 2) Cursor CLI and API key -Ensure the **Cursor CLI** (`cursor` or `agent`) is installed and on `PATH` where the digest runs (e.g. your machine for local runs; for GitHub Actions you must use a runner or step that provides it). Get your API key from Cursor settings. +## Quick start (Cursor CLI) -**Important:** never commit this key to the repo. +1. **Fork** the repo. +2. Install the Cursor CLI and set **`CURSOR_API_KEY`** (Cursor settings). +3. For GitHub Actions: add secret **`CURSOR_API_KEY`** and keep the workflow’s Cursor install step. -### 3) Add the API key as a GitHub Actions secret -In your forked repo: -- Go to **Settings → Secrets and variables → Actions** -- Click **New repository secret** -- Name: `CURSOR_API_KEY` -- Value: paste your Cursor API key +Backend is auto-chosen from which key is set, or set **`TOCIFY_BACKEND=openai`** or **`cursor`** to force. -GitHub will inject it into the workflow at runtime. +--- -### 4) Configure your feeds +## Configure your feeds Edit **`feeds.txt`**. You can use comments: From d58a11fc250c01809a127d07b0f243cf944bef38 Mon Sep 17 00:00:00 2001 From: pa0 Date: Thu, 19 Feb 2026 18:55:58 -0800 Subject: [PATCH 12/14] Split backends by agent and unify structured-output - Add integrations/_shared.py: SCHEMA, load_prompt_template, build_triage_prompt, parse_structured_response - Add integrations/openai_triage.py (moved from digest.py), use shared prompt + parse helper - Refactor integrations/cursor_cli.py to use build_triage_prompt and parse_structured_response - Registry-based backend selection in integrations/__init__.py (TOCIFY_BACKEND) - Slim digest.py to orchestration only; get_triage_backend() from integrations - Single JSON Schema for OpenAI/Claude/Gemini; Cursor remains prompt-only + parse Co-authored-by: Cursor --- digest.py | 105 +--------------------------------- integrations/__init__.py | 38 ++++++++++++ integrations/_shared.py | 75 ++++++++++++++++++++++++ integrations/cursor_cli.py | 50 ++++++++++++++++ integrations/openai_triage.py | 43 ++++++++++++++ 5 files changed, 209 insertions(+), 102 deletions(-) create mode 100644 integrations/__init__.py create mode 100644 integrations/_shared.py create mode 100644 integrations/cursor_cli.py create mode 100644 integrations/openai_triage.py diff --git a/digest.py b/digest.py index e70bf76..659737c 100644 --- a/digest.py +++ b/digest.py @@ -1,22 +1,13 @@ -import os, re, json, time, math, hashlib +import os, re, math, hashlib from datetime import datetime, timezone, timedelta import feedparser -import httpx from dateutil import parser as dtparser from dotenv import load_dotenv -from openai import OpenAI, APITimeoutError, APIConnectionError, RateLimitError load_dotenv() -# Optional Cursor backend (only imported when Cursor path is chosen) -def _get_triage_backend(): - from integrations import get_triage_backend - return get_triage_backend() - - # ---- config (env-tweakable) ---- -MODEL = os.getenv("OPENAI_MODEL", "gpt-4o") MAX_ITEMS_PER_FEED = int(os.getenv("MAX_ITEMS_PER_FEED", "50")) MAX_TOTAL_ITEMS = int(os.getenv("MAX_TOTAL_ITEMS", "400")) LOOKBACK_DAYS = int(os.getenv("LOOKBACK_DAYS", "7")) @@ -27,34 +18,6 @@ def _get_triage_backend(): MIN_SCORE_READ = float(os.getenv("MIN_SCORE_READ", "0.65")) MAX_RETURNED = int(os.getenv("MAX_RETURNED", "40")) -SCHEMA = { - "type": "object", - "additionalProperties": False, - "properties": { - "week_of": {"type": "string"}, - "notes": {"type": "string"}, - "ranked": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": False, - "properties": { - "id": {"type": "string"}, - "title": {"type": "string"}, - "link": {"type": "string"}, - "source": {"type": "string"}, - "published_utc": {"type": ["string", "null"]}, - "score": {"type": "number"}, - "why": {"type": "string"}, - "tags": {"type": "array", "items": {"type": "string"}}, - }, - "required": ["id", "title", "link", "source", "published_utc", "score", "why", "tags"], - }, - }, - }, - "required": ["week_of", "notes", "ranked"], -} - # ---- tiny helpers ---- def load_feeds(path: str) -> list[dict]: @@ -92,12 +55,6 @@ def read_text(path: str) -> str: with open(path, "r", encoding="utf-8") as f: return f.read() -def load_prompt_template(path: str = "prompt.txt") -> str: - if not os.path.exists(path): - raise RuntimeError("prompt.txt not found in repo root") - with open(path, "r", encoding="utf-8") as f: - return f.read() - def sha1(s: str) -> str: return hashlib.sha1(s.encode("utf-8")).hexdigest() @@ -189,53 +146,6 @@ def hits(it): return matched[:keep_top] -# ---- openai (default backend) ---- -def make_openai_client() -> OpenAI: - key = os.environ.get("OPENAI_API_KEY", "").strip() - if not key.startswith("sk-"): - raise RuntimeError("OPENAI_API_KEY missing/invalid (expected to start with 'sk-').") - http_client = httpx.Client( - timeout=httpx.Timeout(connect=30.0, read=300.0, write=30.0, pool=30.0), - http2=False, - trust_env=False, - headers={"Connection": "close", "Accept-Encoding": "gzip"}, - ) - return OpenAI(api_key=key, http_client=http_client) - -def call_openai_triage(client: OpenAI, interests: dict, items: list[dict]) -> dict: - lean_items = [{ - "id": it["id"], - "source": it["source"], - "title": it["title"], - "link": it["link"], - "published_utc": it.get("published_utc"), - "summary": (it.get("summary") or "")[:SUMMARY_MAX_CHARS], - } for it in items] - - template = load_prompt_template() - - prompt = ( - template - .replace("{{KEYWORDS}}", json.dumps(interests["keywords"], ensure_ascii=False)) - .replace("{{NARRATIVE}}", interests["narrative"]) - .replace("{{ITEMS}}", json.dumps(lean_items, ensure_ascii=False)) - ) - - last = None - for attempt in range(6): - try: - resp = client.responses.create( - model=MODEL, - input=prompt, - text={"format": {"type": "json_schema", "name": "weekly_toc_digest", "schema": SCHEMA, "strict": True}}, - ) - return json.loads(resp.output_text) - except (APITimeoutError, APIConnectionError, RateLimitError) as e: - last = e - time.sleep(min(60, 2 ** attempt)) - raise last - - # ---- triage (backend-agnostic batch loop) ---- def triage_in_batches(interests: dict, items: list[dict], batch_size: int, triage_fn) -> dict: """triage_fn(interests, batch) -> dict with keys notes, ranked (and optionally week_of).""" @@ -320,17 +230,8 @@ def main(): items_by_id = {it["id"]: it for it in items} - # Backend: Cursor if requested, else in-file OpenAI - use_cursor = ( - os.getenv("TOCIFY_BACKEND", "").strip().lower() == "cursor" - or bool(os.getenv("CURSOR_API_KEY", "").strip()) - ) - if use_cursor: - triage_fn = _get_triage_backend() - else: - client = make_openai_client() - triage_fn = lambda i, b: call_openai_triage(client, i, b) - + from integrations import get_triage_backend + triage_fn = get_triage_backend() result = triage_in_batches(interests, items, BATCH_SIZE, triage_fn) md = render_digest_md(result, items_by_id) diff --git a/integrations/__init__.py b/integrations/__init__.py new file mode 100644 index 0000000..7bf41cf --- /dev/null +++ b/integrations/__init__.py @@ -0,0 +1,38 @@ +"""Triage backends by architecture. Dispatch via TOCIFY_BACKEND; add new backends by registering here.""" + +import os + + +def _openai_backend(): + from integrations import openai_triage + + client = openai_triage.make_openai_client() + return lambda interests, items: openai_triage.call_openai_triage(client, interests, items) + + +def _cursor_backend(): + from integrations import cursor_cli + + if not cursor_cli.is_available(): + raise RuntimeError("Cursor backend requested but CURSOR_API_KEY is not set.") + return cursor_cli.call_cursor_triage + + +# Registry: TOCIFY_BACKEND value -> callable that returns (interests, items) -> dict +_BACKENDS = { + "openai": _openai_backend, + "cursor": _cursor_backend, +} + + +def get_triage_backend(): + """Return a callable (interests, items) -> dict with keys notes, ranked (and optionally week_of).""" + backend = os.getenv("TOCIFY_BACKEND", "").strip().lower() + if not backend: + backend = "cursor" if os.getenv("CURSOR_API_KEY", "").strip() else "openai" + if backend not in _BACKENDS: + raise RuntimeError( + f"Unknown TOCIFY_BACKEND={backend!r}. Known: {list(_BACKENDS)}. " + "Set OPENAI_API_KEY or CURSOR_API_KEY for default backend." + ) + return _BACKENDS[backend]() diff --git a/integrations/_shared.py b/integrations/_shared.py new file mode 100644 index 0000000..1883e2b --- /dev/null +++ b/integrations/_shared.py @@ -0,0 +1,75 @@ +"""Shared prompt template and JSON schema for all triage backends. + +OpenAI, Claude, and Gemini all use JSON Schema for structured output; SCHEMA is the +single source of truth. Cursor has no schema API and uses prompt-only + parse. +""" + +import json +import os + +SCHEMA = { + "type": "object", + "additionalProperties": False, + "properties": { + "week_of": {"type": "string"}, + "notes": {"type": "string"}, + "ranked": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": False, + "properties": { + "id": {"type": "string"}, + "title": {"type": "string"}, + "link": {"type": "string"}, + "source": {"type": "string"}, + "published_utc": {"type": ["string", "null"]}, + "score": {"type": "number"}, + "why": {"type": "string"}, + "tags": {"type": "array", "items": {"type": "string"}}, + }, + "required": ["id", "title", "link", "source", "published_utc", "score", "why", "tags"], + }, + }, + }, + "required": ["week_of", "notes", "ranked"], +} + + +def load_prompt_template(path: str = "prompt.txt") -> str: + if not os.path.exists(path): + raise RuntimeError("prompt.txt not found in repo root") + with open(path, "r", encoding="utf-8") as f: + return f.read() + + +def build_triage_prompt( + interests: dict, items: list[dict], *, summary_max_chars: int = 500 +) -> tuple[str, list[dict]]: + """Build the triage prompt and lean items. Returns (prompt_string, lean_items).""" + lean_items = [ + { + "id": it["id"], + "source": it["source"], + "title": it["title"], + "link": it["link"], + "published_utc": it.get("published_utc"), + "summary": (it.get("summary") or "")[:summary_max_chars], + } + for it in items + ] + template = load_prompt_template() + prompt = ( + template.replace("{{KEYWORDS}}", json.dumps(interests["keywords"], ensure_ascii=False)) + .replace("{{NARRATIVE}}", interests["narrative"]) + .replace("{{ITEMS}}", json.dumps(lean_items, ensure_ascii=False)) + ) + return (prompt, lean_items) + + +def parse_structured_response(response_text: str) -> dict: + """Parse JSON from a structured-output response; validate 'ranked' exists.""" + data = json.loads(response_text) + if not isinstance(data, dict) or "ranked" not in data: + raise ValueError("Response missing required 'ranked' field") + return data diff --git a/integrations/cursor_cli.py b/integrations/cursor_cli.py new file mode 100644 index 0000000..518c13a --- /dev/null +++ b/integrations/cursor_cli.py @@ -0,0 +1,50 @@ +"""Cursor CLI triage backend. Needs CURSOR_API_KEY and `agent` on PATH.""" + +import json +import os +import subprocess +import time + +from integrations._shared import build_triage_prompt, parse_structured_response + +SUMMARY_MAX_CHARS = int(os.getenv("SUMMARY_MAX_CHARS", "500")) + +# Must match SCHEMA in _shared (Cursor has no structured-output API) +CURSOR_PROMPT_SUFFIX = """ + +Return **only** a single JSON object, no markdown code fences, no commentary. Schema: +{"week_of": "", "notes": "", "ranked": [{"id": "", "title": "", "link": "", "source": "", "published_utc": "", "score": <0-1>, "why": "", "tags": [""]}]} +""" + + +def is_available() -> bool: + return bool(os.environ.get("CURSOR_API_KEY", "").strip()) + + +def call_cursor_triage(interests: dict, items: list[dict]) -> dict: + prompt, _ = build_triage_prompt( + interests, items, summary_max_chars=SUMMARY_MAX_CHARS + ) + prompt = prompt + CURSOR_PROMPT_SUFFIX + args = ["agent", "-p", "--output-format", "text", "--trust", prompt] + last = None + for attempt in range(2): + try: + result = subprocess.run( + args, capture_output=True, text=True, env=os.environ + ) + if result.returncode != 0: + raise RuntimeError( + f"cursor CLI exit {result.returncode}: {result.stderr or result.stdout or 'no output'}" + ) + response_text = (result.stdout or "").strip() + start = response_text.find("{") + end = response_text.rfind("}") + 1 + if start < 0 or end <= start: + raise ValueError("No JSON object found in Cursor output") + return parse_structured_response(response_text[start:end]) + except (ValueError, json.JSONDecodeError, RuntimeError) as e: + last = e + if attempt == 0: + time.sleep(3) + raise last diff --git a/integrations/openai_triage.py b/integrations/openai_triage.py new file mode 100644 index 0000000..1e44485 --- /dev/null +++ b/integrations/openai_triage.py @@ -0,0 +1,43 @@ +"""OpenAI triage backend. Needs OPENAI_API_KEY. Model via OPENAI_MODEL env.""" + +import os +import time + +import httpx +from openai import OpenAI, APITimeoutError, APIConnectionError, RateLimitError + +from integrations._shared import SCHEMA, build_triage_prompt, parse_structured_response + +SUMMARY_MAX_CHARS = int(os.getenv("SUMMARY_MAX_CHARS", "500")) + + +def make_openai_client() -> OpenAI: + key = os.environ.get("OPENAI_API_KEY", "").strip() + if not key.startswith("sk-"): + raise RuntimeError("OPENAI_API_KEY missing/invalid (expected to start with 'sk-').") + http_client = httpx.Client( + timeout=httpx.Timeout(connect=30.0, read=300.0, write=30.0, pool=30.0), + http2=False, + trust_env=False, + headers={"Connection": "close", "Accept-Encoding": "gzip"}, + ) + return OpenAI(api_key=key, http_client=http_client) + + +def call_openai_triage(client: OpenAI, interests: dict, items: list[dict]) -> dict: + model = os.getenv("OPENAI_MODEL", "").strip() or "gpt-4o" + prompt, _ = build_triage_prompt(interests, items, summary_max_chars=SUMMARY_MAX_CHARS) + + last = None + for attempt in range(6): + try: + resp = client.responses.create( + model=model, + input=prompt, + text={"format": {"type": "json_schema", "name": "weekly_toc_digest", "schema": SCHEMA, "strict": True}}, + ) + return parse_structured_response(resp.output_text) + except (APITimeoutError, APIConnectionError, RateLimitError) as e: + last = e + time.sleep(min(60, 2 ** attempt)) + raise last From c2161e21f019cf51a972095a9b993587be411674 Mon Sep 17 00:00:00 2001 From: pa0 Date: Thu, 19 Feb 2026 19:06:53 -0800 Subject: [PATCH 13/14] One workflow per backend: OpenAI default + Cursor workflow - weekly-digest.yml: OpenAI only (uv sync, TOCIFY_BACKEND=openai, no Cursor CLI) - weekly-digest-cursor.yml: Cursor only (install Cursor CLI, TOCIFY_BACKEND=cursor) Co-authored-by: Cursor --- .github/workflows/weekly-digest-cursor.yml | 60 ++++++++++++++++++++++ .github/workflows/weekly-digest.yml | 42 +++++++++++---- 2 files changed, 91 insertions(+), 11 deletions(-) create mode 100644 .github/workflows/weekly-digest-cursor.yml diff --git a/.github/workflows/weekly-digest-cursor.yml b/.github/workflows/weekly-digest-cursor.yml new file mode 100644 index 0000000..2d82e73 --- /dev/null +++ b/.github/workflows/weekly-digest-cursor.yml @@ -0,0 +1,60 @@ +name: Weekly ToC Digest (Cursor) + +on: + schedule: + # Mondays 08:00 America/Los_Angeles ≈ 16:00 UTC (adjust if you like) + - cron: "00 16 * * 1" + workflow_dispatch: + +permissions: + contents: write + +jobs: + digest: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set Python version + run: echo "PYTHON_VERSION=$(cat .python-version)" >> $GITHUB_ENV + + - name: Install uv + uses: astral-sh/setup-uv@v4 + with: + python-version: ${{ env.PYTHON_VERSION }} + enable-cache: true + activate-environment: true + + - name: Install deps + run: uv sync + + - name: Install Cursor CLI + run: | + curl https://cursor.com/install -fsS | bash + echo "$HOME/.cursor/bin" >> $GITHUB_PATH + + - name: Run digest + env: + TOCIFY_BACKEND: "cursor" + CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }} + HTTP_PROXY: "" + HTTPS_PROXY: "" + ALL_PROXY: "" + NO_PROXY: "api.openai.com" + MIN_SCORE_READ: "0.35" + LOOKBACK_DAYS: "7" + SUMMARY_MAX_CHARS: "500" + PREFILTER_KEEP_TOP: "200" + BATCH_SIZE: "50" + run: | + export PATH="$HOME/.cursor/bin:$PATH" + uv run python digest.py + + - name: Commit digest.md + run: | + git config user.name "toc-digest-bot" + git config user.email "toc-digest-bot@users.noreply.github.com" + git add digest.md + git commit -m "Update weekly ToC digest" || exit 0 + git push diff --git a/.github/workflows/weekly-digest.yml b/.github/workflows/weekly-digest.yml index 479207d..f7c4563 100644 --- a/.github/workflows/weekly-digest.yml +++ b/.github/workflows/weekly-digest.yml @@ -1,4 +1,4 @@ -name: Weekly ToC Digest +name: Weekly ToC Digest (OpenAI) on: schedule: @@ -16,31 +16,51 @@ jobs: - name: Checkout uses: actions/checkout@v4 - - name: Setup Python - uses: actions/setup-python@v5 + - name: Set Python version + run: echo "PYTHON_VERSION=$(cat .python-version)" >> $GITHUB_ENV + + - name: Install uv + uses: astral-sh/setup-uv@v4 with: - python-version: "3.11" + python-version: ${{ env.PYTHON_VERSION }} + enable-cache: true + activate-environment: true - name: Install deps + run: uv sync + + - name: Network check (OpenAI) run: | - python -m pip install --upgrade pip - pip install -r requirements.txt + uv run python - << 'PY' + import socket + host = "api.openai.com" + print("Resolving:", host) + print(socket.gethostbyname(host)) + print("OK: DNS resolve") + PY + curl -I https://api.openai.com/v1/models --max-time 20 || true + + - name: Show proxy-related env (debug) + run: | + echo "HTTP_PROXY=$HTTP_PROXY" + echo "HTTPS_PROXY=$HTTPS_PROXY" + echo "ALL_PROXY=$ALL_PROXY" + echo "NO_PROXY=$NO_PROXY" - name: Run digest env: - CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }} + TOCIFY_BACKEND: "openai" + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} HTTP_PROXY: "" HTTPS_PROXY: "" ALL_PROXY: "" + NO_PROXY: "api.openai.com" MIN_SCORE_READ: "0.35" LOOKBACK_DAYS: "7" SUMMARY_MAX_CHARS: "500" PREFILTER_KEEP_TOP: "200" BATCH_SIZE: "50" - run: | - curl https://cursor.com/install -fsS | bash - export PATH="$HOME/.cursor/bin:$PATH" - python digest.py + run: uv run python digest.py - name: Commit digest.md run: | From 09b75ffd58acc9c6f240fc331eeb58a7c5ea58d9 Mon Sep 17 00:00:00 2001 From: pa0 Date: Thu, 19 Feb 2026 19:09:32 -0800 Subject: [PATCH 14/14] chg: ignore .cursor file --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 19aaee7..8e1ab69 100644 --- a/.gitignore +++ b/.gitignore @@ -200,6 +200,7 @@ cython_debug/ # refer to https://docs.cursor.com/context/ignore-files .cursorignore .cursorindexingignore +.cursor/ # Marimo marimo/_static/