askaipods 0.2.4 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -60,12 +60,12 @@ Then copy or symlink the `skill/askaipods/` directory into your agent's skills f
60
60
  | Runtime | Skill folder | Install guide |
61
61
  |---|---|---|
62
62
  | Claude Code | `~/.claude/skills/askaipods/` | [examples/claude-code-install.md](examples/claude-code-install.md) |
63
- | OpenAI Codex CLI | `~/.agents/skills/askaipods/` | [examples/codex-install.md](examples/codex-install.md) |
64
- | OpenClaw | `~/.agents/skills/askaipods/` or `~/.openclaw/skills/askaipods/` | [examples/openclaw-install.md](examples/openclaw-install.md) |
63
+ | OpenAI Codex CLI | `~/.codex/skills/askaipods/` (or `$CODEX_HOME/skills/askaipods/`; project-scoped: `.agents/skills/askaipods/`) | [examples/codex-install.md](examples/codex-install.md) |
64
+ | OpenClaw | `~/.agents/skills/askaipods/` or `~/.openclaw/skills/askaipods/` | [examples/openclaw-install.md](examples/openclaw-install.md) |
65
65
  | Hermes Agent | `~/.hermes/skills/askaipods/` | [examples/hermes-install.md](examples/hermes-install.md) |
66
66
  | Any other agentskills.io-compatible runtime | per runtime docs | follow the agentskills.io standard — copy `skill/askaipods/` into your agent's skills directory |
67
67
 
68
- **Two-for-one tip**: Codex CLI and OpenClaw both read from `~/.agents/skills/`, so a single install at `~/.agents/skills/askaipods/` covers both runtimes simultaneously.
68
+ **Per-runtime paths matter**: Codex CLI loads user-level skills from `~/.codex/skills/` (per the [official Codex skills docs](https://developers.openai.com/codex/skills)); project-scoped skills live under `.agents/skills/` in the repository and are discovered via workspace walk. OpenClaw typically reads from `~/.agents/skills/` or `~/.openclaw/skills/`. These paths are NOT interchangeable — install into each runtime's expected location.
69
69
 
70
70
  The skill folder is self-contained: it tells the host agent how to invoke `askaipods` (via `npx`), how to parse the JSON, and how to render the response with an **Insights** section. The section layout is tier-dependent — member tier renders **Latest 5** + **Top 5 Most Relevant** + **Insights**; anonymous tier renders **Recent Quotes** + **Insights** (the "Top Relevant" section is suppressed for anonymous because the API returns results sorted by date, not by semantic relevance).
71
71
 
@@ -101,14 +101,14 @@ Your agent will recognize the trigger phrase, invoke `askaipods`, and present th
101
101
  | | Anonymous (default) | Member |
102
102
  |---|---|---|
103
103
  | **Daily quota** | 20 searches per IP | 100 searches per user |
104
- | **Results returned** | 20 (deterministic top 20, sorted newest-first) | 20 (deterministic top 20, sorted by relevance) |
104
+ | **Results returned** | Top 20 newest (API returns newest-first; `api_rank` = temporal order) | Top 20 by semantic relevance (structured output is emitted newest-first; semantic rank preserved in `api_rank`) |
105
105
  | **Text length** | Full text | Full text |
106
106
  | **Date precision** | Month only (`2025-10`) | Full date (`2025-10-15`) |
107
107
  | **`--days` cap (when specified)** | 90 days | Unlimited |
108
108
  | **Setup** | Nothing | `ASKAIPODS_API_KEY` env var |
109
- | **Sign up** | n/a | https://podlens.net |
109
+ | **Access** | n/a | invite-only · request at https://podlens.net |
110
110
 
111
- The anonymous tier exists so you can try the skill end-to-end with zero setup. Sign up for member access only when you outgrow the 20/day quota or need full dates and unlimited lookback.
111
+ The anonymous tier exists so you can try the skill end-to-end with zero setup. Member access is currently invite-only — request access at https://podlens.net (you'll be added to the waitlist for review) only if you outgrow the 20/day quota or need full dates and unlimited lookback.
112
112
 
113
113
  ## Honest limitations
114
114
 
@@ -39,13 +39,13 @@ Or trigger it organically:
39
39
 
40
40
  > What are people saying about test-time compute on AI podcasts?
41
41
 
42
- Claude Code should recognize the trigger phrase, run `npx askaipods search "..." --format json`, parse the response, and render the structured results per the SKILL.md template (layout is tier-dependent — member tier shows Latest + Top Relevant + Insights; anonymous tier shows Recent Quotes + Insights).
42
+ Claude Code should recognize the trigger phrase, run `npx -y askaipods search --format json -- "..."` (argv-style per SKILL.md's invocation rule), parse the response, and render the structured results per the SKILL.md template (layout is tier-dependent — member tier shows Latest + Top Relevant + Insights; anonymous tier shows Recent Quotes + Insights).
43
43
 
44
44
  ## Troubleshooting
45
45
 
46
46
  - **Skill not appearing**: Make sure the parent directory name matches the `name` field in `SKILL.md` (both must be `askaipods`).
47
47
  - **`npx askaipods` fails**: Check that Node.js 18.3.0+ is installed: `node --version`. The CLI uses zero dependencies so there are no other prereqs.
48
- - **Anonymous quota exhausted**: Sign up at https://podlens.net for 100/day, then `export ASKAIPODS_API_KEY=pk_xxx`.
48
+ - **Anonymous quota exhausted**: Member tier is invite-only — request access at https://podlens.net, then once invited `export ASKAIPODS_API_KEY=pk_xxx` for 100/day.
49
49
  - **Skill triggers too rarely**: Front-load your prompt with the trigger phrases in `SKILL.md` description, or invoke directly with `/askaipods <query>`.
50
50
 
51
51
  ## Reference
@@ -1,6 +1,6 @@
1
1
  # Install askaipods in OpenAI Codex CLI
2
2
 
3
- Codex CLI typically looks in `~/.agents/skills/` (user-level) and `.agents/skills/` (project / repo level). Project-scoped skills win over user-scoped when both exist with the same name. For the authoritative scope list and any system-level paths your installed version supports, consult the [official Codex skills documentation](https://developers.openai.com/codex/skills/).
3
+ Codex CLI loads user-level skills from `~/.codex/skills/` (or `$CODEX_HOME/skills/` if the env var is set) and project-scoped skills from `.agents/skills/` within the repository workspace. When both scopes carry the same skill name, repository-scoped wins. For the authoritative scope list and any system-level paths your installed version supports, consult the [official Codex skills documentation](https://developers.openai.com/codex/skills).
4
4
 
5
5
  For most users, the **user-level** install is what you want — it makes `askaipods` available across every project.
6
6
 
@@ -8,14 +8,14 @@ For most users, the **user-level** install is what you want — it makes `askaip
8
8
 
9
9
  ```bash
10
10
  git clone https://github.com/Delibread0601/askaipods.git ~/Code/askaipods
11
- mkdir -p ~/.agents/skills
12
- ln -s ~/Code/askaipods/skill/askaipods ~/.agents/skills/askaipods
11
+ mkdir -p ~/.codex/skills
12
+ ln -s ~/Code/askaipods/skill/askaipods ~/.codex/skills/askaipods
13
13
  ```
14
14
 
15
15
  Symlink (above) is recommended so `git pull` updates flow through automatically. Or copy:
16
16
 
17
17
  ```bash
18
- cp -r ~/Code/askaipods/skill/askaipods ~/.agents/skills/askaipods
18
+ cp -r ~/Code/askaipods/skill/askaipods ~/.codex/skills/askaipods
19
19
  ```
20
20
 
21
21
  ## Project-only install
@@ -33,7 +33,7 @@ In Codex, ask:
33
33
 
34
34
  > What are people saying about reasoning models on AI podcasts?
35
35
 
36
- Codex should detect the trigger, run `npx askaipods search "..." --format json`, and render the structured response per `SKILL.md`.
36
+ Codex should detect the trigger, run `npx -y askaipods search --format json -- "..."`, and render the structured response per `SKILL.md`.
37
37
 
38
38
  ## Troubleshooting
39
39
 
@@ -22,7 +22,7 @@ In a Hermes session, ask:
22
22
 
23
23
  > Find what AI podcasts are saying about test-time compute
24
24
 
25
- Hermes should pick up the skill from `~/.hermes/skills/askaipods/`, shell out to `npx askaipods`, and present the structured results.
25
+ Hermes should pick up the skill from `~/.hermes/skills/askaipods/`, shell out to `npx -y askaipods ...` (argv-style per SKILL.md's invocation rule), and present the structured results.
26
26
 
27
27
  ## Troubleshooting
28
28
 
@@ -4,24 +4,26 @@
4
4
 
5
5
  1. `<workspace>/skills/` — workspace skills (highest)
6
6
  2. `<workspace>/.agents/skills/` — project agent skills
7
- 3. `~/.agents/skills/` — personal agent skills (shared with OpenAI Codex CLI)
7
+ 3. `~/.agents/skills/` — personal agent skills
8
8
  4. `~/.openclaw/skills/` — managed/local skills (shared across all agents on the machine)
9
9
 
10
- For most users, **option 3 is the best choice** because the same `~/.agents/skills/askaipods/` install also makes the skill available in OpenAI Codex CLI one install, two runtimes.
10
+ > **Note**: An earlier version of this guide claimed `~/.agents/skills/` was shared with OpenAI Codex CLI. That was incorrect — Codex CLI reads user-level skills from `~/.codex/skills/` per the [official Codex skills docs](https://developers.openai.com/codex/skills). If you also use Codex CLI, install askaipods into `~/.codex/skills/askaipods/` separately (see [examples/codex-install.md](codex-install.md)).
11
11
 
12
- ## Recommended install (shared with Codex)
12
+ ## Recommended install
13
+
14
+ Install into the OpenClaw-native location (option 4 — lowest precedence, but stable across agent versions):
13
15
 
14
16
  ```bash
15
17
  git clone https://github.com/Delibread0601/askaipods.git ~/Code/askaipods
16
- mkdir -p ~/.agents/skills
17
- ln -s ~/Code/askaipods/skill/askaipods ~/.agents/skills/askaipods
18
+ mkdir -p ~/.openclaw/skills
19
+ ln -s ~/Code/askaipods/skill/askaipods ~/.openclaw/skills/askaipods
18
20
  ```
19
21
 
20
- If you don't already use Codex and prefer to keep OpenClaw skills separate, install into the OpenClaw-native location instead:
22
+ Or use the shared personal-skills location (option 3) if you want the skill visible to every agentskills.io-compatible runtime that respects the `~/.agents/skills/` convention:
21
23
 
22
24
  ```bash
23
- mkdir -p ~/.openclaw/skills
24
- ln -s ~/Code/askaipods/skill/askaipods ~/.openclaw/skills/askaipods
25
+ mkdir -p ~/.agents/skills
26
+ ln -s ~/Code/askaipods/skill/askaipods ~/.agents/skills/askaipods
25
27
  ```
26
28
 
27
29
  Or use the OpenClaw CLI once askaipods is published to the ClawHub registry (not yet — check back, or open an issue to track):
@@ -47,7 +49,7 @@ In OpenClaw, ask:
47
49
 
48
50
  > What are AI podcasts saying about reasoning models?
49
51
 
50
- OpenClaw should recognize the trigger phrase, shell out to `npx askaipods search "..." --format json`, and render the structured response per the SKILL.md template.
52
+ OpenClaw should recognize the trigger phrase, shell out to `npx -y askaipods search --format json -- "..."` (argv-style per SKILL.md's invocation rule), and render the structured response per the SKILL.md template.
51
53
 
52
54
  ## Troubleshooting
53
55
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "askaipods",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
4
4
  "description": "Search AI podcast quotes by topic — recent episode excerpts from Lex Fridman, Dwarkesh Patel, No Priors, Latent Space, and dozens more. Universal agentskills.io skill compatible with Claude Code, OpenAI Codex, Hermes Agent, OpenClaw, and any other agent that supports the open skill standard. Powered by podlens.net.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -23,7 +23,7 @@
23
23
  },
24
24
  "scripts": {
25
25
  "start": "node bin/askaipods.js",
26
- "test": "echo 'no automated tests yet — see CONTRIBUTING for the test plan' && exit 0"
26
+ "test": "node --test tests/*.test.mjs"
27
27
  },
28
28
  "keywords": [
29
29
  "ai",
@@ -1,8 +1,8 @@
1
1
  ---
2
2
  name: askaipods
3
- description: Search AI podcast quotes about a topic. Use whenever the user asks "what are people saying about X", "latest takes on Y", "find AI podcast quotes about Z", "who is discussing <model/concept>", or wants to know how AI researchers, founders, or VCs are publicly discussing any AI topic — even when they don't say "podcast". Returns recent excerpts from real episodes of Lex Fridman, Dwarkesh Patel, No Priors, Latent Space, and dozens more, sorted newest-first via the podlens.net semantic search API. Trigger eagerly on AI-research, ML-engineering, AI-investing, or AI-policy questions where real-human commentary beats a web search summary. Do not use for general web search, full transcript reading, or non-AI topics.
3
+ description: Search AI podcast quotes about a topic. Use whenever the user asks "what are people saying about X", "latest takes on Y", "find AI podcast quotes about Z", "who is discussing <model/concept>", or wants to know how AI researchers, founders, or VCs are publicly discussing any AI topic — even when they don't say "podcast". Returns recent excerpts from Lex Fridman, Dwarkesh Patel, No Priors, Latent Space, and dozens more via podlens.net. Optional ASKAIPODS_API_KEY unlocks invite-only member tier; anonymous works out of the box.
4
4
  license: MIT
5
- requirements: Node.js 18.3.0+ on PATH (the CLI uses `node:util.parseArgs`, which was added in 18.3.0), internet access to podlens.net. Optional ASKAIPODS_API_KEY env var unlocks the 100/day member tier with full dates and unlimited lookback; without it the skill works on the 20/day anonymous tier (per-IP, month-precision dates, `--days` capped at 90 when specified).
5
+ requirements: Node.js 18.3.0+ on PATH (the CLI uses `node:util.parseArgs`, which was added in 18.3.0), internet access to podlens.net. Optional ASKAIPODS_API_KEY env var unlocks the 100/day member tier with full dates and unlimited lookback (member tier is invite-only — request access at https://podlens.net); without the key the skill works on the 20/day anonymous tier (per-IP, month-precision dates, `--days` capped at 90 when specified).
6
6
  ---
7
7
 
8
8
  # askaipods — AI podcast quote search
@@ -37,24 +37,34 @@ You may invoke even when the user does not say "podcast" — if the question is
37
37
 
38
38
  Run the bundled CLI and pass `--format json`. The flag matters because without it the CLI auto-detects the output format from `isTTY`, and an agent calling via shell may or may not get a TTY depending on the runtime — explicit `--format json` removes that variability.
39
39
 
40
+ > **CRITICAL — argv-safety rule**: pass the user's query as a **separate argv argument**, never splice it directly into a shell command string. A query like `"; rm -rf ~` spliced into `bash -c "npx askaipods \"$QUERY\""` is a command-injection path. Runtimes that support argv-array execution (Claude Code's `Bash` tool, Codex CLI shell invocations, most SDK-based agents) must use that form. Runtimes that only support shell strings must apply proper shell-quoting (e.g., Node's `shell-quote` or `printf %q`) before interpolation. The `--` separator also guarantees the query is treated as a positional argument regardless of leading characters.
41
+
42
+ Argv-array form (preferred for agent use):
43
+
44
+ ```
45
+ ["npx", "-y", "askaipods", "search", "--format", "json", "--", "<USER QUERY>"]
46
+ ```
47
+
48
+ Shell-string form (only when argv is unavailable AND the query has been properly shell-quoted — do NOT use raw string interpolation):
49
+
40
50
  ```bash
41
- npx askaipods search "<USER QUERY>" --format json
51
+ npx -y askaipods search --format json -- "<USER QUERY>"
42
52
  ```
43
53
 
44
- The `search` subcommand is optional `npx askaipods "<USER QUERY>" --format json` works identically. Both forms are supported; use whichever reads better in context.
54
+ The `-y` flag bypasses npm's first-run confirmation prompt; without it, a non-interactive runtime may hang on first use waiting for a TTY response that never comes (audit R7-03). The package is published on npm as `askaipods`, so `npx -y` resolves it regardless of whether the user has it installed globally. If `npx` is unavailable in the host environment, the user can install once with `npm install -g askaipods` and run `askaipods` directly.
45
55
 
46
- The package is published on npm as `askaipods`, so `npx` will resolve it regardless of whether the user has it installed globally. If `npx` is unavailable in the host environment, the user can install globally once with `npm install -g askaipods` and the skill will run the same command.
56
+ The `search` subcommand is optional `npx -y askaipods --format json -- "<USER QUERY>"` works identically. Both forms are supported; use whichever reads better in context.
47
57
 
48
- To restrict to recent episodes only, add `--days N`. When `--days` is passed, the API clamps the value to a maximum of 90 for anonymous tier (member tier accepts any value). When `--days` is omitted entirely, there is no time filter — the API returns all-time results.
58
+ To restrict to recent episodes only, add `--days N`. When `--days` is passed, the server caps the value at 90 for anonymous tier (member tier accepts any value). When `--days` is omitted entirely, there is no time filter — the server returns all-time results.
49
59
 
50
60
  ```bash
51
- npx askaipods search "<USER QUERY>" --days 90 --format json
61
+ npx -y askaipods search --days 90 --format json -- "<USER QUERY>"
52
62
  ```
53
63
 
54
64
  To authenticate with a PodLens API key (member tier), pass `--api-key <key>` or set the `ASKAIPODS_API_KEY` environment variable. The flag takes priority over the env var when both are present.
55
65
 
56
66
  ```bash
57
- npx askaipods search "<USER QUERY>" --api-key pk_abc123... --format json
67
+ npx -y askaipods search --api-key pk_abc123... --format json -- "<USER QUERY>"
58
68
  ```
59
69
 
60
70
  The query must be 1–300 characters after trimming. Longer queries are rejected locally (exit code 1) before reaching the API.
@@ -96,10 +106,19 @@ Do NOT silently default every query to `--days 90` — omitting `--days` on broa
96
106
  ],
97
107
  "meta": {
98
108
  "total_returned": 20,
99
- "quota": { "used": 3, "limit": 100, "period": "daily" },
109
+ "quota": { "used": 3, "limit": 100, "period": "daily", "next_reset": "2026-04-21T00:00:00Z" },
100
110
  "restrictions": null,
101
111
  "query_hash": "...",
102
- "window": { "requested_days": 7, "served_days": 30, "expanded": true, "reason_code": "expanded_on_empty_window" }
112
+ "window": {
113
+ "requested_days": 7,
114
+ "served_days": 30,
115
+ "expanded": true,
116
+ "attempted_days": [7, 30],
117
+ "reason_code": "expanded_topk_filled"
118
+ },
119
+ "corpus_freshness": { "newest_date": "2026-04-18" },
120
+ "warning": null,
121
+ "cta": null
103
122
  }
104
123
  }
105
124
  ```
@@ -112,9 +131,27 @@ Field notes that affect how you render:
112
131
  - **`results[]`** — already sorted **newest first** by the CLI. Each result carries `api_rank` (1 = most semantically relevant in API order) so you can derive a "Top Relevant" sub-view without re-querying.
113
132
  - **`results[].podcast` / `episode` / `date`** — any of these may be `null` if the upstream record is incomplete. Render `Unknown podcast` / `Untitled episode` / `date unknown` rather than dropping the result. The CLI's own markdown renderer falls back the same way.
114
133
  - **`results[].date` format** — `YYYY-MM-DD` (or full ISO timestamp) for member tier; `YYYY-MM` only for anonymous tier (deliberately fuzzed by the API). Display whatever you got — don't guess a day.
115
- - **`meta.quota`** — passed through from the podlens.net API. Sub-fields like `used`, `limit`, `period` are reliably present; other sub-fields (e.g., a reset timestamp) may or may not appear depending on the server version. Treat all sub-fields as optional and degrade gracefully.
134
+ - **`meta.quota`** — passed through from the podlens.net API. `used` and `limit` are guaranteed present (the CLI validates them as part of the success envelope); `period` is typically `"daily"`, `next_reset` is an ISO-8601 timestamp, and **`refunded`** (optional boolean) — when present and `true`, the server refunded this request's quota slot under its P1-b narrow-refund rule (triggered when the corpus is stale for the requested window AND zero results were delivered). The field is often absent; check with `quota.refunded === true` rather than `typeof quota.refunded === "boolean"`. When set, mention in the response that the search didn't count against the user's quota — it's a transparency signal worth surfacing.
116
135
  - **`meta.restrictions`** — `null` for member tier; for anonymous tier, an object describing the cap (e.g., `{ max_results: 20, text_truncated: false, results_randomized: false, date_precision: "month", max_days: 90, order: "published_at_desc" }`). If non-null, the closing anonymous-tier note (templated below) is the right way to surface it; do not parse the object field-by-field.
117
- - **`meta.window`** — present when the API includes window expansion metadata (may be `null` for older server versions). When the user passes `--days` and the requested window has no results, the API automatically retries with wider windows (`[30, 60, 90]` days). The `window` object contains: `requested_days` (what was asked), `served_days` (what actually returned results), `expanded` (boolean — `true` when the window was widened), `reason_code` (`"expanded_on_empty_window"` when expanded), and optionally `truncated` (`true` when a fallback query errored mid-expansion). **When `expanded` is `true`**, tell the user: "No results in the requested N-day window; showing results from the last M days" (using `requested_days` and `served_days`). When `expanded` is `false` and results are empty, the API tried all available windows and genuinely found nothing.
136
+ - **`meta.window`** — present when the API includes window expansion metadata (may be `null` for older server versions). When the user passes `--days` and the requested window has no results, the API automatically retries with wider windows (`[30, 60, 90]` days). The `window` object contains:
137
+ - `requested_days` — what the client asked for.
138
+ - `served_days` — the last window actually attempted. When `expanded` is `true`, results came from that wider window.
139
+ - `expanded` — `true` when more than one window was attempted (regardless of whether any succeeded).
140
+ - `attempted_days` — array of every window queried, in order (e.g. `[7, 30, 60]`); diagnostic, safe to ignore for rendering.
141
+ - `reason_code` — one of three values, present only when `expanded` is `true` AND `truncated` is absent:
142
+ - `"expanded_topk_filled"` — wider windows tried AND delivered the full topK (20).
143
+ - `"expanded_partial_fill"` — wider windows tried AND delivered some but fewer than topK results.
144
+ - `"exhausted_windows_empty"` — wider windows tried AND delivered nothing.
145
+ - `truncated` — `true` when a fallback query errored mid-expansion, aborting further windows. When present, `reason_code` is suppressed.
146
+
147
+ **When `expanded` is `true` AND results were delivered**, tell the user: "No results in the requested N-day window; showing results from the last M days" (using `requested_days` and `served_days`). When `expanded` is `true` AND results are empty, the API tried all available windows and genuinely found nothing — prefer checking `meta.warning` first for a freshness-aware message before falling back to generic "no results" copy.
148
+ - **`meta.corpus_freshness`** — `{ "newest_date": "YYYY-MM-DD" | null }`. The latest `published_at` indexed in the corpus. Use it as an honest "data as of X" signal, especially when results are empty and `meta.warning` indicates a stale corpus. `null` when the corpus-freshness probe failed server-side — render "date unknown" rather than omitting.
149
+ - **`meta.warning`** — `null` in the common case. When present, an object `{ "code": "..." }` signalling an honest server-side explanation for an empty or partial response:
150
+ - `"corpus_stale_for_requested_window"` — the user bounded the search with `--days` but the newest indexed episode predates that window's cutoff. Tell the user: "No episodes indexed in the requested window (newest indexed episode: `<corpus_freshness.newest_date>`). Try a longer `--days` value or omit it." **Do NOT** suggest rephrasing the query — the cause is freshness, not semantics.
151
+ - `"index_metadata_stale"` — fresh episodes exist in the corpus but haven't propagated to the Vectorize index yet. Tell the user: "Recently indexed episodes are still propagating — retry in a few minutes."
152
+
153
+ These warnings mean an empty or near-empty result is an infrastructure signal, not a semantic-relevance signal — they take precedence over `window.expanded`/`truncated` messaging when both apply.
154
+ - **`meta.cta`** — anonymous-tier call-to-action from the server (e.g., `{ follow: "https://x.com/..." }`) or `null`. Optional context for the closing anonymous-tier note; safe to ignore if you're already rendering the standard closing note.
118
155
  - **No speaker name and no episode URL.** The corpus is indexed at the key-point level without per-speaker attribution (the upstream pipeline intentionally avoids attributing quotes to individuals because automatic speaker diarization is unreliable). Episode URLs are also not exposed by the public API. Render `Podcast — Episode` only; do not fabricate "Dario said" if the text doesn't already attribute itself.
119
156
 
120
157
  ## How to render the response
@@ -123,6 +160,14 @@ Output exactly this structure. It is required for consistency across runtimes
123
160
 
124
161
  (Note: the CLI's own `--format markdown` output uses a different layout — `### N. Podcast — Episode` headings — because that mode targets humans running `askaipods` directly in a terminal. As an agent you should always pass `--format json` and reformat the parsed payload yourself per the templates below; do not copy the CLI's markdown.)
125
162
 
163
+ **Freshness banner rule (applies to every render path below, regardless of result count)**: before the first section heading, if `meta.warning` is non-null, emit a one-line italicized note:
164
+
165
+ - `meta.warning.code === "corpus_stale_for_requested_window"` AND results are present → `*Note: The indexed corpus has no episodes in the requested window (newest indexed episode: <meta.corpus_freshness.newest_date>) — results below may come from an expanded window. Consider omitting --days for broader coverage.*`
166
+ - `meta.warning.code === "index_metadata_stale"` AND results are present → `*Note: Recently indexed episodes are still propagating to the search index — some relevant matches may be missing. Retry in a few minutes for complete coverage.*`
167
+ - `meta.warning.code` is set to any other value AND results are present (forward-compat for future server codes) → `*Note: Server flagged a freshness concern with this search (code: <meta.warning.code>) — results may be incomplete.*`
168
+
169
+ The banner is mandatory whenever `meta.warning` is non-null and the response is rendered — an unannotated partial result misrepresents the corpus state as authoritative. For empty-result renders, the equivalent freshness copy replaces the "no results" message per the §Error handling priority ladder (do not stack both).
170
+
126
171
  ### For `render_hint: "dual_view"` (member tier)
127
172
 
128
173
  ```markdown
@@ -142,7 +187,9 @@ Output exactly this structure. It is required for consistency across runtimes
142
187
 
143
188
  2. ...
144
189
 
145
- (these 5 are the results with `api_rank` 1 through 5, regardless of date pull them from the `results` array by filtering on `api_rank`, **then sort ascending by `api_rank`** so rank 1 appears first. The `results` array is sorted newest-first, so a naive filter would leave these in date order instead of rank order.)
190
+ (these 5 are the results with `api_rank` 1 through 5, regardless of date. Pull them from the `results` array by filtering on `api_rank`.
191
+
192
+ **Then sort ascending by `api_rank`** so rank 1 appears first — the `results` array is sorted newest-first, so a naive filter without re-sorting would leave these in date order instead of rank order.)
146
193
 
147
194
  ## 💡 Insights
148
195
 
@@ -174,7 +221,7 @@ If the same result appears in both Latest and Top Relevant sections, that's fine
174
221
 
175
222
  ---
176
223
 
177
- *Anonymous tier: 20 results sorted newest-first, dates fuzzed to month, `--days` capped at 90 when specified. Set `ASKAIPODS_API_KEY` for 100 searches/day with full dates and unlimited lookback — sign up at https://podlens.net.*
224
+ *Anonymous tier: 20 results sorted newest-first, dates fuzzed to month, `--days` capped at 90 when specified. Set `ASKAIPODS_API_KEY` for 100 searches/day with full dates and unlimited lookback — member tier is invite-only, request access at https://podlens.net.*
178
225
  ```
179
226
 
180
227
  The closing note about the anonymous tier matters because it tells the user (a) why the dates are coarse, (b) what the lookback cap is, and (c) what the upgrade path is. Skipping it leaves the user wondering why dates lack day precision.
@@ -209,10 +256,17 @@ The CLI uses stable exit codes so you can branch on the failure mode:
209
256
  | `2` | Daily quota exhausted | Surface the CLI's stderr message verbatim — it is already tier-aware (distinct copy for member vs anonymous) and includes the correct reset time and upgrade path. |
210
257
  | `3` | Transient or unexpected failure (network error, rate-limit burst, service 503, protocol/shape error, or internal exception) | Retry once after a brief pause. If it fails again, surface the CLI's stderr message verbatim — it distinguishes "rate limited, retry in a minute" from "podlens.net temporarily unavailable" from "unexpected response shape" from internal exceptions, so the user sees the actionable detail. |
211
258
 
212
- If the `results` array is empty (zero matches above the similarity threshold), check `meta.window` first:
213
- - If `meta.window.expanded` is `true`: the API already widened the search window (e.g., from 7 to 30 days) and still found nothing — tell the user: "No quotes found. The API expanded the search from N to M days but found no matches. Try rephrasing or broadening the query."
214
- - If `meta.window.truncated` is `true`: the expansion was interrupted by a transient error tell the user to retry in a moment.
215
- - Otherwise (no expansion, or `meta.window` is `null`): say "No quotes found for that topic. The corpus is AI-focused for non-AI topics, try a web search instead. For AI topics, try rephrasing or broadening the query."
259
+ If the `results` array is empty (zero matches above the similarity threshold), check the honesty signals in this priority order — freshness warnings dominate because they tell the user something stronger than "rephrase your query":
260
+
261
+ 1. **`meta.warning.code === "corpus_stale_for_requested_window"`** corpus has no indexed episodes in the requested window. Tell the user: "No episodes indexed in the requested window (newest indexed episode: `<meta.corpus_freshness.newest_date>`). Try a longer `--days` value or omit it." Do NOT suggest rephrasing the query.
262
+ 2. **`meta.warning.code === "index_metadata_stale"`** fresh episodes exist but haven't propagated to the vector index. Tell the user: "Recently indexed episodes are still propagating to the search index retry in a few minutes."
263
+ 3. **`meta.warning.code` is set to any other value** (forward-compat for future server codes) — preserve the signal rather than falling through. Tell the user: "No results. The server flagged a freshness issue with this search (code: `<meta.warning.code>`) — results may be incomplete or the requested window may be stale. Try omitting `--days` or retry in a few minutes."
264
+ 4. **`meta.window.truncated === true`** — the expansion was interrupted by a transient error. Tell the user to retry in a moment.
265
+ 5. **`meta.window.expanded === true`** — the API widened the window (e.g., 7→30 days) and still found nothing. Tell the user: "No quotes found. The API expanded the search from N to M days but found no matches. Try rephrasing or broadening the query." If `meta.corpus_freshness.newest_date` is present, append "(corpus indexed through `<newest_date>`)" as an honest data-freshness signal.
266
+ 6. **Otherwise** (no warning, no expansion): say "No quotes found for that topic. The corpus is AI-focused — for non-AI topics, try a web search instead. For AI topics, try rephrasing or broadening the query."
267
+
268
+ If `meta.quota.refunded === true`, add a one-line note at the end: "_This search was refunded — it did not count against your daily quota._" (The server's P1-b narrow-refund rule fires when the corpus is stale for the requested window AND zero results are delivered.)
269
+
216
270
  Do not invent quotes to fill the gap.
217
271
 
218
272
  Never silently swallow an error. Never fabricate quotes when the API returns nothing.
package/src/cli.js CHANGED
@@ -14,7 +14,7 @@ import { parseArgs } from "node:util";
14
14
  import { search, AskaipodsError } from "./client.js";
15
15
  import { renderJson, renderMarkdown } from "./format.js";
16
16
 
17
- const VERSION = "0.2.4";
17
+ const VERSION = "0.2.6";
18
18
 
19
19
  const HELP_TEXT = `askaipods ${VERSION} — search AI podcast quotes by topic
20
20
 
@@ -32,7 +32,7 @@ OPTIONS:
32
32
  ENVIRONMENT:
33
33
  ASKAIPODS_API_KEY PodLens API key. Without it: 20 searches/day per IP (anonymous).
34
34
  With it: 100 searches/day per user (member).
35
- Sign up at https://podlens.net to get one.
35
+ Member tier is invite-only — request access at https://podlens.net.
36
36
 
37
37
  EXIT CODES:
38
38
  0 success
@@ -87,8 +87,22 @@ export async function run(argv) {
87
87
  // operation today and adding it as a flag-free first positional means
88
88
  // future subcommands (e.g., `askaipods quota`) won't break the v0
89
89
  // muscle memory.
90
+ //
91
+ // Only strip "search" as a subcommand when there are additional
92
+ // positionals after it (audit R6-02). Treating a lone `search`
93
+ // positional as a subcommand would produce a misleading "missing
94
+ // query" error for the plausible case `askaipods search` (user
95
+ // typed the subcommand name expecting an interactive prompt); and
96
+ // stripping a leading `search` from a multi-word unquoted query
97
+ // like `askaipods search engines and AI` would silently drop the
98
+ // first word without the user noticing. The refined rule: if the
99
+ // user types exactly one positional equal to "search", treat it as
100
+ // the literal one-word query "search" rather than a subcommand
101
+ // with missing argument. For multi-word queries beginning with the
102
+ // literal word "search", the user should quote:
103
+ // `askaipods "search engines and AI"`.
90
104
  let query;
91
- if (positionals[0] === "search") {
105
+ if (positionals[0] === "search" && positionals.length > 1) {
92
106
  query = positionals.slice(1).join(" ").trim();
93
107
  } else {
94
108
  query = positionals.join(" ").trim();
package/src/client.js CHANGED
@@ -97,10 +97,24 @@ function isValidPublishedAt(v) {
97
97
  // data.meta.quota.used : finite number
98
98
  // data.meta.quota.limit : finite number
99
99
  //
100
- // Optional (kept loose on purpose):
100
+ // Optional (kept loose on purpose unless they affect render logic):
101
101
  // data.meta.quota.period, data.meta.quota.next_reset,
102
- // data.meta.query_hash, data.meta.restrictions, data.meta.cta,
103
- // data.meta.window
102
+ // data.meta.query_hash, data.meta.restrictions, data.meta.window
103
+ //
104
+ // Optional but shape-checked because they drive conditional render
105
+ // branches in format.js — a malformed value would let the bad state
106
+ // silently cross the exit-0 / exit-3 boundary:
107
+ // data.meta.quota.refunded — boolean iff present (drives header
108
+ // "· refunded" tag in renderMarkdown)
109
+ // data.meta.warning — {code: string} iff present (drives
110
+ // empty-result priority ladder and the
111
+ // freshness banner in non-empty render)
112
+ // data.meta.corpus_freshness — {newest_date: string|null} iff present
113
+ // (the "data as of X" signal)
114
+ // data.meta.cta — object iff present (passthrough only,
115
+ // no render logic depends on its shape,
116
+ // but reject non-object so downstream
117
+ // type assumptions hold)
104
118
  function isValidSuccessEnvelope(data) {
105
119
  if (!isPlainObject(data)) return false;
106
120
  if (typeof data.total !== "number" || !Number.isFinite(data.total)) return false;
@@ -124,6 +138,98 @@ function isValidSuccessEnvelope(data) {
124
138
  if (!isPlainObject(q)) return false;
125
139
  if (typeof q.used !== "number" || !Number.isFinite(q.used)) return false;
126
140
  if (typeof q.limit !== "number" || !Number.isFinite(q.limit)) return false;
141
+ // quota.refunded is optional; when present it MUST be boolean. A
142
+ // string like "yes" would truthy-trigger the "· refunded" tag in
143
+ // format.js renderMarkdown header, claiming a refund that didn't
144
+ // happen.
145
+ if (q.refunded !== undefined && typeof q.refunded !== "boolean") return false;
146
+ // warning is optional; when present it MUST be a plain object with a
147
+ // string `code`. A non-string code would bypass the equality checks
148
+ // in format.js empty-result ladder and non-empty banner, producing
149
+ // no user-facing message when one was intended.
150
+ if (m.warning != null) {
151
+ if (!isPlainObject(m.warning)) return false;
152
+ if (typeof m.warning.code !== "string") return false;
153
+ }
154
+ // corpus_freshness is optional. The server contract is **best-effort
155
+ // metadata**: when the upstream freshness probe fails, semantic.ts
156
+ // emits `console.warn` and continues with `newest_date: null` rather
157
+ // than aborting the request. The client must mirror that philosophy
158
+ // — a malformed `newest_date` should NOT turn an otherwise-successful
159
+ // search (valid results + tier + quota) into an exit-3 error.
160
+ //
161
+ // Split into two levels:
162
+ // - Structural (object shape, type of newest_date): envelope-fatal
163
+ // via `return false`. A non-string newest_date means the server
164
+ // broke contract shape-wise.
165
+ // - Content (non-ISO / non-calendar-valid YYYY-MM-DD): coerce
166
+ // `newest_date` to null in place. format.js banner treats null
167
+ // as "no freshness data" and omits the "newest indexed episode:
168
+ // X" suffix cleanly.
169
+ if (m.corpus_freshness != null) {
170
+ if (!isPlainObject(m.corpus_freshness)) return false;
171
+ // `newest_date` is a required sub-field when corpus_freshness is
172
+ // present (SKILL.md contract: always present as string|null). An
173
+ // absent property is a protocol break — without this presence
174
+ // check, the `nd != null` guard below would early-exit for
175
+ // undefined after R4's structural/content split, letting a {}-
176
+ // shaped corpus_freshness leak through (R5-01).
177
+ if (!("newest_date" in m.corpus_freshness)) return false;
178
+ const nd = m.corpus_freshness.newest_date;
179
+ if (nd != null) {
180
+ if (typeof nd !== "string") return false;
181
+ let isoValid = /^\d{4}-\d{2}-\d{2}$/.test(nd);
182
+ if (isoValid) {
183
+ const [y, mo, d] = nd.split("-").map(Number);
184
+ if (mo < 1 || mo > 12 || d < 1 || d > 31) {
185
+ isoValid = false;
186
+ } else {
187
+ const dt = new Date(Date.UTC(y, mo - 1, d));
188
+ if (
189
+ dt.getUTCFullYear() !== y ||
190
+ dt.getUTCMonth() !== mo - 1 ||
191
+ dt.getUTCDate() !== d
192
+ ) {
193
+ isoValid = false;
194
+ }
195
+ }
196
+ }
197
+ if (!isoValid) {
198
+ // Coerce malformed ISO content to null and continue validation.
199
+ // Downstream format.js sees null and skips the freshness banner
200
+ // suffix, matching the server's own probe-failure degradation.
201
+ m.corpus_freshness.newest_date = null;
202
+ }
203
+ }
204
+ }
205
+ // cta is passthrough-only (no render logic reads its fields today),
206
+ // but reject non-object so future render paths don't crash on a
207
+ // primitive where they expect an object.
208
+ if (m.cta != null && !isPlainObject(m.cta)) return false;
209
+ // window shape-check (R3-01): format.js renderMarkdown reads
210
+ // `window.truncated`, `window.expanded`, `window.requested_days`,
211
+ // `window.served_days` to drive the empty-result priority ladder.
212
+ // A malformed window (e.g., `expanded: "true"` string, missing
213
+ // requested_days) would silently misroute rendering — e.g., a
214
+ // string "false" is truthy and would trigger the expanded branch
215
+ // when the server meant the opposite. Required fields are validated
216
+ // strictly; truncated/reason_code/attempted_days are optional and
217
+ // only checked when present.
218
+ if (m.window != null) {
219
+ if (!isPlainObject(m.window)) return false;
220
+ const w = m.window;
221
+ if (typeof w.requested_days !== "number" || !Number.isFinite(w.requested_days)) return false;
222
+ if (typeof w.served_days !== "number" || !Number.isFinite(w.served_days)) return false;
223
+ if (typeof w.expanded !== "boolean") return false;
224
+ if (w.truncated !== undefined && typeof w.truncated !== "boolean") return false;
225
+ if (w.reason_code !== undefined && typeof w.reason_code !== "string") return false;
226
+ if (w.attempted_days !== undefined) {
227
+ if (!Array.isArray(w.attempted_days)) return false;
228
+ for (const n of w.attempted_days) {
229
+ if (typeof n !== "number" || !Number.isFinite(n)) return false;
230
+ }
231
+ }
232
+ }
127
233
  return true;
128
234
  }
129
235
 
@@ -137,7 +243,7 @@ export async function search({ query, days, apiKey, endpoint = PODLENS_ENDPOINT
137
243
 
138
244
  const headers = {
139
245
  "Content-Type": "application/json",
140
- "User-Agent": "askaipods/0.2.4 (+https://github.com/Delibread0601/askaipods)",
246
+ "User-Agent": "askaipods/0.2.6 (+https://github.com/Delibread0601/askaipods)",
141
247
  };
142
248
  if (apiKey) {
143
249
  headers["X-PodLens-API-Key"] = apiKey;
@@ -148,28 +254,70 @@ export async function search({ query, days, apiKey, endpoint = PODLENS_ENDPOINT
148
254
  body.days = days;
149
255
  }
150
256
 
257
+ // 30s total budget covers both connection setup and body consumption
258
+ // (audit R7-02). podlens.net edge workers may take 5-10s for the
259
+ // Vectorize + Gemini embed round-trip on cold starts; 30s leaves
260
+ // comfortable headroom while still producing a deterministic
261
+ // exit-3 instead of hanging a CLI invocation indefinitely on a
262
+ // half-open socket or unresponsive upstream. Applied via
263
+ // AbortSignal.timeout() so the signal covers the downstream
264
+ // `response.json()` body read as well — the same controller
265
+ // aborts whichever phase happens to be pending.
266
+ const TIMEOUT_MS = 30_000;
151
267
  let response;
152
268
  try {
153
269
  response = await fetch(endpoint, {
154
270
  method: "POST",
155
271
  headers,
156
272
  body: JSON.stringify(body),
273
+ signal: AbortSignal.timeout(TIMEOUT_MS),
157
274
  });
158
275
  } catch (err) {
159
- // fetch() throws TypeError on DNS / connection failure / abort.
160
- // Treat all of these as exit code 3 (transient/network) so the
161
- // SKILL.md can advise "retry in a moment" instead of looking like
162
- // a usage error.
276
+ // AbortSignal.timeout fires with DOMException(name: "TimeoutError")
277
+ // in modern runtimes; some Node 18 versions use AbortError. Treat
278
+ // both as distinct, user-actionable exit-3 failures with the
279
+ // concrete timeout budget in the message so the user/agent knows
280
+ // the failure mode (stalled network vs. DNS failure vs. 404).
281
+ const name = err?.name;
282
+ if (name === "TimeoutError" || name === "AbortError") {
283
+ throw exitErr(
284
+ 3,
285
+ `request to podlens.net timed out after ${TIMEOUT_MS / 1000}s (possible network stall or slow upstream). Retry in a moment.`,
286
+ );
287
+ }
288
+ // fetch() throws TypeError on DNS / connection failure. Treat as
289
+ // exit code 3 (transient/network) so the SKILL.md can advise
290
+ // "retry in a moment" instead of looking like a usage error.
163
291
  throw exitErr(3, `network error contacting podlens.net: ${err?.message ?? err}`);
164
292
  }
165
293
 
166
294
  // The server always responds with JSON for both success and error
167
295
  // paths (see jsonResponse() in functions/api/search/semantic.ts), so
168
296
  // a non-JSON body means an upstream proxy/CDN is in the way.
297
+ //
298
+ // Two distinct failure classes in this catch (audit R8-01):
299
+ // 1. TimeoutError/AbortError — headers arrived in time but the
300
+ // body read stalled past the signal's 30s budget. The same
301
+ // AbortSignal attached at fetch() propagates to the response
302
+ // body stream, so timeouts during `response.json()` surface
303
+ // here rather than at the fetch() catch above.
304
+ // 2. Anything else — real JSON parse failure or truncated body
305
+ // (e.g., an upstream proxy returned HTML or closed the
306
+ // connection mid-stream).
307
+ // Must distinguish because the user-actionable advice differs:
308
+ // "retry, your network stalled" vs. "retry, an upstream proxy
309
+ // mangled the response."
169
310
  let data;
170
311
  try {
171
312
  data = await response.json();
172
- } catch {
313
+ } catch (err) {
314
+ const name = err?.name;
315
+ if (name === "TimeoutError" || name === "AbortError") {
316
+ throw exitErr(
317
+ 3,
318
+ `request to podlens.net timed out after ${TIMEOUT_MS / 1000}s while reading response body (possible network stall or slow upstream). Retry in a moment.`,
319
+ );
320
+ }
173
321
  throw exitErr(
174
322
  3,
175
323
  `unexpected non-JSON response from podlens.net (HTTP ${response.status}). ` +
@@ -198,7 +346,7 @@ export async function search({ query, days, apiKey, endpoint = PODLENS_ENDPOINT
198
346
  const quotaMsg = apiKey
199
347
  ? "daily search quota exhausted (member tier: 100/day). Quota resets at 00:00 UTC."
200
348
  : "daily search quota exhausted (anonymous tier: 20/day). Quota resets at 00:00 UTC. " +
201
- "For 100 searches/day, set ASKAIPODS_API_KEY (sign up at https://podlens.net).";
349
+ "For 100 searches/day, set ASKAIPODS_API_KEY member tier is invite-only, request access at https://podlens.net.";
202
350
  throw exitErr(2, quotaMsg);
203
351
  }
204
352
  throw exitErr(3, "rate limited by podlens.net (too many requests in a short window). Retry in a minute.");
package/src/format.js CHANGED
@@ -13,7 +13,8 @@
13
13
 
14
14
  const ANONYMOUS_NOTE =
15
15
  "Anonymous tier: 20 results sorted newest-first, dates fuzzed to month, " +
16
- "--days capped at 90 when specified. Set ASKAIPODS_API_KEY for 100 searches/day with full dates and unlimited lookback.";
16
+ "--days capped at 90 when specified. Set ASKAIPODS_API_KEY for 100 searches/day with full dates and unlimited lookback " +
17
+ "(member tier is invite-only — request access at https://podlens.net).";
17
18
 
18
19
  // Sort results newest-first by parsing each `published_at` to a UTC
19
20
  // millisecond timestamp and comparing numerically. Pure lexical compare
@@ -92,6 +93,20 @@ export function toStructured(query, response) {
92
93
  restrictions: response.meta.restrictions ?? null,
93
94
  query_hash: response.meta.query_hash ?? null,
94
95
  window: response.meta.window ?? null,
96
+ // New honesty signals (server audit 2026-04-17 → 2026-04-19):
97
+ // warning.code — "corpus_stale_for_requested_window" or
98
+ // "index_metadata_stale"; tells the agent
99
+ // the empty/partial result is a freshness
100
+ // issue rather than a semantic mismatch.
101
+ // corpus_freshness — { newest_date: "YYYY-MM-DD" | null };
102
+ // lets the agent render "data as of X".
103
+ // cta — anonymous-tier call-to-action (e.g.
104
+ // follow URL) passed through unchanged.
105
+ // All three are optional — defaulted to null so the structured
106
+ // output shape is stable across server versions.
107
+ warning: response.meta.warning ?? null,
108
+ corpus_freshness: response.meta.corpus_freshness ?? null,
109
+ cta: response.meta.cta ?? null,
95
110
  },
96
111
  };
97
112
  }
@@ -112,19 +127,65 @@ export function renderMarkdown(query, response) {
112
127
  const quotaLabel = quota
113
128
  ? `${quota.used}/${quota.limit} ${quota.period ?? "daily"}`
114
129
  : "unknown";
115
- lines.push(`*Tier: ${tierLabel} · Results: ${data.results.length} · Quota: ${quotaLabel}*`);
130
+ // Server's P1-b narrow refund: when corpus is stale AND delivered
131
+ // results are empty, the quota slot is refunded (see server CLAUDE.md
132
+ // §Two-Tier Search Access). Surface as a trailing tag so the user
133
+ // knows the search was free — `quota.used` is already decremented
134
+ // upstream, so we only need the marker, not a separate count.
135
+ const refundedTag = data.meta.quota?.refunded ? " · refunded" : "";
136
+ lines.push(`*Tier: ${tierLabel} · Results: ${data.results.length} · Quota: ${quotaLabel}${refundedTag}*`);
116
137
  lines.push("");
117
138
 
118
139
  if (data.results.length === 0) {
119
140
  const win = data.meta.window;
120
- if (win && win.expanded) {
141
+ const warningCode = data.meta.warning?.code;
142
+ const newest = data.meta.corpus_freshness?.newest_date;
143
+ // Priority: freshness warnings take precedence over window/expansion
144
+ // messaging because they tell the user something stronger — the
145
+ // corpus (not just their query phrasing) is the reason for the
146
+ // empty response.
147
+ // Priority ladder (must match SKILL.md §Error handling):
148
+ // 1. warning.code = corpus_stale_for_requested_window
149
+ // 2. warning.code = index_metadata_stale
150
+ // 3. warning.code = any other value (forward-compat, R6-01)
151
+ // 4. window.truncated (transient fallback error — retry)
152
+ // 5. window.expanded (API widened the window, still empty)
153
+ // 6. generic (no signal — likely semantic mismatch)
154
+ // truncated MUST be checked before expanded: when a fallback
155
+ // Vectorize query errors mid-expansion, both flags are true, and
156
+ // the truncated copy ("retry in a moment") is strictly more
157
+ // actionable than the expanded copy ("rephrase").
158
+ if (warningCode === "corpus_stale_for_requested_window") {
159
+ const asOf = newest ? ` (newest indexed episode: ${newest})` : "";
121
160
  lines.push(
122
- `No results found. The API expanded the search window from ${win.requested_days} to ${win.served_days} days but still found no matches. Try a different phrasing or broader topic.`,
161
+ `No results in the requested window${asOf}. The indexed corpus has no episodes matching that window — try a longer \`--days\` value or omit it.`,
162
+ );
163
+ } else if (warningCode === "index_metadata_stale") {
164
+ lines.push(
165
+ "No results. Recently indexed episodes are still propagating to the search index — retry in a few minutes.",
166
+ );
167
+ } else if (warningCode) {
168
+ // Forward-compat: server may introduce new warning codes (audit
169
+ // R6-01). Preserve the signal rather than silently falling
170
+ // through to generic "rephrase" copy, which would mislead the
171
+ // user into thinking the empty result is their fault.
172
+ lines.push(
173
+ `No results. The server flagged a freshness issue with this search (code: ${warningCode}) — results may be incomplete or the requested window may be stale. Try omitting \`--days\` or retry in a few minutes.`,
123
174
  );
124
175
  } else if (win && win.truncated) {
125
176
  lines.push(
126
177
  "No results found (search window expansion was interrupted by a transient error). Try again in a moment, or try a different phrasing.",
127
178
  );
179
+ } else if (win && win.expanded) {
180
+ // SKILL.md §Error handling step 4 mandates appending the
181
+ // corpus-indexed-through suffix when newest_date is present —
182
+ // an honest freshness signal distinct from the freshness warning
183
+ // (which would have landed on the warning branches above). Keeps
184
+ // CLI markdown and SKILL.md's agent render instructions aligned.
185
+ const indexedThrough = newest ? ` (corpus indexed through ${newest})` : "";
186
+ lines.push(
187
+ `No results found. The API expanded the search window from ${win.requested_days} to ${win.served_days} days but still found no matches${indexedThrough}. Try a different phrasing or broader topic.`,
188
+ );
128
189
  } else {
129
190
  lines.push("No results found. Try a different phrasing or broader topic.");
130
191
  }
@@ -135,6 +196,37 @@ export function renderMarkdown(query, response) {
135
196
  return lines.join("\n");
136
197
  }
137
198
 
199
+ // Freshness warning (partial-results case): SKILL.md's meta.warning
200
+ // contract covers both empty AND partial responses. When results are
201
+ // present but the server still flags a freshness issue (e.g.,
202
+ // index_metadata_stale: fresh episodes exist but some haven't
203
+ // propagated to Vectorize yet), emit a banner above the results so
204
+ // the user knows the set is incomplete rather than authoritative.
205
+ // Placed before the expansion note so freshness signals dominate —
206
+ // same priority intent as the empty-branch ladder.
207
+ const warningCode = data.meta.warning?.code;
208
+ const newest = data.meta.corpus_freshness?.newest_date;
209
+ if (warningCode === "corpus_stale_for_requested_window") {
210
+ const asOf = newest ? ` (newest indexed episode: ${newest})` : "";
211
+ lines.push(
212
+ `*Note: The indexed corpus has no episodes in the requested window${asOf} — results below may come from an expanded window. Try omitting \`--days\` for broader coverage.*`,
213
+ );
214
+ lines.push("");
215
+ } else if (warningCode === "index_metadata_stale") {
216
+ lines.push(
217
+ "*Note: Recently indexed episodes are still propagating to the search index — some relevant matches may be missing. Retry in a few minutes for complete coverage.*",
218
+ );
219
+ lines.push("");
220
+ } else if (warningCode) {
221
+ // Unknown server warning code (forward-compat, audit R6-01).
222
+ // Surface the raw code so the signal reaches the user rather
223
+ // than being silently dropped.
224
+ lines.push(
225
+ `*Note: Server flagged a freshness concern with this search (code: ${warningCode}) — results may be incomplete.*`,
226
+ );
227
+ lines.push("");
228
+ }
229
+
138
230
  // Surface window expansion so the user knows the actual time range
139
231
  const win = data.meta.window;
140
232
  if (win && win.expanded) {