askaipods 0.2.3 → 0.2.5
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 +9 -9
- package/examples/claude-code-install.md +2 -2
- package/examples/codex-install.md +5 -5
- package/examples/hermes-install.md +1 -1
- package/examples/openclaw-install.md +11 -9
- package/package.json +1 -1
- package/skill/askaipods/SKILL.md +72 -18
- package/src/cli.js +19 -5
- package/src/client.js +161 -13
- package/src/format.js +96 -4
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@ $ askaipods "what are people saying about test-time compute"
|
|
|
7
7
|
|
|
8
8
|
# askaipods · "what are people saying about test-time compute"
|
|
9
9
|
|
|
10
|
-
*Tier: anonymous · Results: 20 · Quota: 1/
|
|
10
|
+
*Tier: anonymous · Results: 20 · Quota: 1/20 daily*
|
|
11
11
|
|
|
12
12
|
## Results — newest first
|
|
13
13
|
|
|
@@ -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/`
|
|
64
|
-
| OpenClaw | `~/.agents/skills/askaipods/`
|
|
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
|
-
|
|
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
|
|
|
@@ -83,7 +83,7 @@ askaipods "Anthropic safety research" --format json
|
|
|
83
83
|
# Restrict to recent episodes only (anonymous tier caps --days at 90; member tier accepts any value)
|
|
84
84
|
askaipods "GPU shortage" --days 90
|
|
85
85
|
|
|
86
|
-
# Use a member-tier API key for
|
|
86
|
+
# Use a member-tier API key for 100/day instead of 20/day
|
|
87
87
|
ASKAIPODS_API_KEY=pk_xxx askaipods "your query"
|
|
88
88
|
askaipods "your query" --api-key pk_xxx
|
|
89
89
|
```
|
|
@@ -100,15 +100,15 @@ Your agent will recognize the trigger phrase, invoke `askaipods`, and present th
|
|
|
100
100
|
|
|
101
101
|
| | Anonymous (default) | Member |
|
|
102
102
|
|---|---|---|
|
|
103
|
-
| **Daily quota** |
|
|
104
|
-
| **Results returned** | 20 (
|
|
103
|
+
| **Daily quota** | 20 searches per IP | 100 searches per user |
|
|
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
|
-
| **
|
|
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.
|
|
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 "..."
|
|
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**:
|
|
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
|
|
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 ~/.
|
|
12
|
-
ln -s ~/Code/askaipods/skill/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 ~/.
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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 ~/.
|
|
17
|
-
ln -s ~/Code/askaipods/skill/askaipods ~/.
|
|
18
|
+
mkdir -p ~/.openclaw/skills
|
|
19
|
+
ln -s ~/Code/askaipods/skill/askaipods ~/.openclaw/skills/askaipods
|
|
18
20
|
```
|
|
19
21
|
|
|
20
|
-
|
|
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 ~/.
|
|
24
|
-
ln -s ~/Code/askaipods/skill/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 "..."
|
|
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.
|
|
3
|
+
"version": "0.2.5",
|
|
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": {
|
package/skill/askaipods/SKILL.md
CHANGED
|
@@ -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
|
|
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
|
|
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>"
|
|
51
|
+
npx -y askaipods search --format json -- "<USER QUERY>"
|
|
42
52
|
```
|
|
43
53
|
|
|
44
|
-
The `
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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":
|
|
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": {
|
|
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.
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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.
|
|
17
|
+
const VERSION = "0.2.5";
|
|
18
18
|
|
|
19
19
|
const HELP_TEXT = `askaipods ${VERSION} — search AI podcast quotes by topic
|
|
20
20
|
|
|
@@ -30,9 +30,9 @@ OPTIONS:
|
|
|
30
30
|
-v, --version Show version
|
|
31
31
|
|
|
32
32
|
ENVIRONMENT:
|
|
33
|
-
ASKAIPODS_API_KEY PodLens API key. Without it:
|
|
34
|
-
With it:
|
|
35
|
-
|
|
33
|
+
ASKAIPODS_API_KEY PodLens API key. Without it: 20 searches/day per IP (anonymous).
|
|
34
|
+
With it: 100 searches/day per user (member).
|
|
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.
|
|
103
|
-
//
|
|
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.
|
|
246
|
+
"User-Agent": "askaipods/0.2.5 (+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
|
-
//
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
//
|
|
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}). ` +
|
|
@@ -190,15 +338,15 @@ export async function search({ query, days, apiKey, endpoint = PODLENS_ENDPOINT
|
|
|
190
338
|
// Distinguish 429 cases by inspecting the message: the server uses
|
|
191
339
|
// distinct strings for "burst limit hit" vs "daily quota exhausted",
|
|
192
340
|
// and only the latter warrants the "daily quota" exit code. The
|
|
193
|
-
// quota message is tier-aware: a member hitting the
|
|
341
|
+
// quota message is tier-aware: a member hitting the 100/day cap must
|
|
194
342
|
// not be told to "set ASKAIPODS_API_KEY" — they already have one.
|
|
195
343
|
if (response.status === 429) {
|
|
196
344
|
const msg = String(data?.error ?? "").toLowerCase();
|
|
197
345
|
if (msg.includes("quota")) {
|
|
198
346
|
const quotaMsg = apiKey
|
|
199
|
-
? "daily search quota exhausted (member tier:
|
|
200
|
-
: "daily search quota exhausted (anonymous tier:
|
|
201
|
-
"For
|
|
347
|
+
? "daily search quota exhausted (member tier: 100/day). Quota resets at 00:00 UTC."
|
|
348
|
+
: "daily search quota exhausted (anonymous tier: 20/day). Quota resets at 00:00 UTC. " +
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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) {
|