askaipods 0.1.0 → 0.2.0
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 +18 -17
- package/bin/askaipods.js +6 -1
- package/examples/claude-code-install.md +2 -2
- package/examples/codex-install.md +1 -1
- package/examples/hermes-install.md +1 -1
- package/examples/openclaw-install.md +1 -1
- package/package.json +3 -3
- package/skill/askaipods/SKILL.md +34 -20
- package/src/cli.js +66 -8
- package/src/client.js +115 -7
- package/src/format.js +50 -26
package/README.md
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
# askaipods
|
|
2
2
|
|
|
3
|
-
> Search AI podcast quotes about a topic —
|
|
3
|
+
> Search AI podcast quotes about a topic — recent episode excerpts from Lex Fridman, Dwarkesh Patel, No Priors, Latent Space, and dozens of other AI podcasts, surfaced as short indexed quotes (no per-speaker attribution). A universal [agentskills.io](https://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](https://podlens.net).
|
|
4
4
|
|
|
5
5
|
```
|
|
6
6
|
$ 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:
|
|
10
|
+
*Tier: anonymous · Results: 20 · Quota: 1/5 daily*
|
|
11
11
|
|
|
12
12
|
## Results — newest first
|
|
13
13
|
|
|
@@ -23,7 +23,7 @@ $ askaipods "what are people saying about test-time compute"
|
|
|
23
23
|
> Test-time compute as a paradigm pushes toward smaller base models because
|
|
24
24
|
> the cost of solving a prob...
|
|
25
25
|
|
|
26
|
-
(...
|
|
26
|
+
(...18 more results, newest-first...)
|
|
27
27
|
```
|
|
28
28
|
|
|
29
29
|
## Why this exists
|
|
@@ -67,7 +67,7 @@ Then copy or symlink the `skill/askaipods/` directory into your agent's skills f
|
|
|
67
67
|
|
|
68
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.
|
|
69
69
|
|
|
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
|
|
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
|
|
|
72
72
|
## Usage
|
|
73
73
|
|
|
@@ -80,10 +80,10 @@ askaipods "what are VCs saying about reasoning models"
|
|
|
80
80
|
# JSON output (for scripts and agents)
|
|
81
81
|
askaipods "Anthropic safety research" --format json
|
|
82
82
|
|
|
83
|
-
# Restrict to recent episodes only (
|
|
84
|
-
askaipods "GPU shortage" --days
|
|
83
|
+
# Restrict to recent episodes only (anonymous tier caps --days at 90; member tier accepts any value)
|
|
84
|
+
askaipods "GPU shortage" --days 90
|
|
85
85
|
|
|
86
|
-
# Use a member-tier API key for 50/day instead of
|
|
86
|
+
# Use a member-tier API key for 50/day instead of 5/day
|
|
87
87
|
ASKAIPODS_API_KEY=pk_xxx askaipods "your query"
|
|
88
88
|
askaipods "your query" --api-key pk_xxx
|
|
89
89
|
```
|
|
@@ -94,27 +94,28 @@ Once the skill is installed in your agent's skills directory, simply ask:
|
|
|
94
94
|
|
|
95
95
|
> What are people saying about test-time compute on AI podcasts?
|
|
96
96
|
|
|
97
|
-
Your agent will recognize the trigger phrase, invoke `askaipods`, and present the results with
|
|
97
|
+
Your agent will recognize the trigger phrase, invoke `askaipods`, and present the results with an AI-generated Insights summary. The exact layout is tier-dependent: **member tier** renders dual sections (Latest 5 + Top 5 Most Relevant + Insights); **anonymous tier** renders a single section (Recent Quotes + Insights), because anonymous results are sorted by date (not semantic relevance) and showing a "Top Relevant" view would be misleading. No CLI knowledge required from the user either way.
|
|
98
98
|
|
|
99
99
|
## Tier comparison
|
|
100
100
|
|
|
101
101
|
| | Anonymous (default) | Member |
|
|
102
102
|
|---|---|---|
|
|
103
|
-
| **Daily quota** |
|
|
104
|
-
| **Results returned** |
|
|
105
|
-
| **Text length** |
|
|
103
|
+
| **Daily quota** | 5 searches per IP | 50 searches per user |
|
|
104
|
+
| **Results returned** | 20 (deterministic top 20, sorted newest-first) | 20 (deterministic top 20, sorted by relevance) |
|
|
105
|
+
| **Text length** | Full text | Full text |
|
|
106
106
|
| **Date precision** | Month only (`2025-10`) | Full date (`2025-10-15`) |
|
|
107
|
+
| **`--days` cap (when specified)** | 90 days | Unlimited |
|
|
107
108
|
| **Setup** | Nothing | `ASKAIPODS_API_KEY` env var |
|
|
108
109
|
| **Sign up** | n/a | https://podlens.net |
|
|
109
110
|
|
|
110
|
-
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
|
|
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 5/day quota or need full dates and unlimited lookback.
|
|
111
112
|
|
|
112
113
|
## Honest limitations
|
|
113
114
|
|
|
114
115
|
- **No speaker attribution.** The corpus indexes quotes at the episode level but does not attempt to identify *which guest* said each quote. The upstream pipeline avoids speaker labeling because automatic diarization is unreliable, and a wrong attribution is worse than no attribution.
|
|
115
116
|
- **No episode URLs.** The public API does not expose direct podcast or episode links. You will need to search the podcast and episode title in your podcast app of choice.
|
|
116
117
|
- **AI-focused corpus.** Coverage is dense for AI research, ML engineering, AI investing, and AI policy. Off-topic queries return sparse, noisy results.
|
|
117
|
-
- **Short quote excerpts.** Each result is typically 1-3 sentences
|
|
118
|
+
- **Short quote excerpts.** Each result is typically 1-3 sentences. For long-form context, listen to the episode.
|
|
118
119
|
|
|
119
120
|
These are not bugs. The skill surfaces them honestly so neither you nor your agent fabricate things the API does not provide.
|
|
120
121
|
|
|
@@ -125,7 +126,7 @@ These are not bugs. The skill surfaces them honestly so neither you nor your age
|
|
|
125
126
|
| `0` | Success |
|
|
126
127
|
| `1` | Usage error / invalid arguments / API key rejected |
|
|
127
128
|
| `2` | Daily quota exhausted |
|
|
128
|
-
| `3` |
|
|
129
|
+
| `3` | Transient or unexpected failure — network error, rate-limit burst, service 503, protocol/shape error, or internal exception. stderr has the actionable detail. |
|
|
129
130
|
|
|
130
131
|
## How the skill renders results
|
|
131
132
|
|
|
@@ -142,7 +143,7 @@ For member tier (`render_hint: dual_view`), the host agent renders two sections
|
|
|
142
143
|
(3-5 bullets synthesizing patterns across the quotes)
|
|
143
144
|
```
|
|
144
145
|
|
|
145
|
-
For anonymous tier (`render_hint: single_view`), only
|
|
146
|
+
For anonymous tier (`render_hint: single_view`), only Recent Quotes and Insights — the Top Relevant section is intentionally suppressed because anonymous results are sorted by date (newest-first), so api_rank reflects temporal order, not semantic relevance.
|
|
146
147
|
|
|
147
148
|
See [`skill/askaipods/SKILL.md`](skill/askaipods/SKILL.md) for the full skill specification.
|
|
148
149
|
|
|
@@ -158,12 +159,12 @@ askaipods/
|
|
|
158
159
|
├── skill/askaipods/
|
|
159
160
|
│ └── SKILL.md ← agentskills.io standard skill file
|
|
160
161
|
├── examples/ ← per-runtime install guides
|
|
161
|
-
├── package.json ← zero dependencies (Node 18+ stdlib only)
|
|
162
|
+
├── package.json ← zero dependencies (Node 18.3.0+ stdlib only)
|
|
162
163
|
├── LICENSE ← MIT
|
|
163
164
|
└── README.md
|
|
164
165
|
```
|
|
165
166
|
|
|
166
|
-
The CLI is intentionally zero-dependency (Node 18+ stdlib only) so `npx askaipods` cold-starts in under a second and the package install footprint is minimal.
|
|
167
|
+
The CLI is intentionally zero-dependency (Node 18.3.0+ stdlib only — `node:util.parseArgs` requires 18.3.0) so `npx askaipods` cold-starts in under a second and the package install footprint is minimal.
|
|
167
168
|
|
|
168
169
|
## Contributing
|
|
169
170
|
|
package/bin/askaipods.js
CHANGED
|
@@ -4,5 +4,10 @@ import { run } from "../src/cli.js";
|
|
|
4
4
|
run(process.argv.slice(2)).catch((err) => {
|
|
5
5
|
const message = err?.message ?? String(err);
|
|
6
6
|
process.stderr.write(`askaipods: ${message}\n`);
|
|
7
|
-
|
|
7
|
+
// AskaipodsError carries an explicit exitCode for known user/protocol
|
|
8
|
+
// failures. Any other exception is an internal or unexpected failure
|
|
9
|
+
// (format-time RangeError, unhandled rejection, etc.) — these are
|
|
10
|
+
// closer in semantics to exit 3 "protocol / unexpected failure" than
|
|
11
|
+
// to exit 1 "usage error", so default to 3 instead of 1.
|
|
12
|
+
process.exit(typeof err?.exitCode === "number" ? err.exitCode : 3);
|
|
8
13
|
});
|
|
@@ -39,12 +39,12 @@ 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 Latest
|
|
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).
|
|
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
|
-
- **`npx askaipods` fails**: Check that Node.js 18+ is installed: `node --version`. The CLI uses zero dependencies so there are no other prereqs.
|
|
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
48
|
- **Anonymous quota exhausted**: Sign up at https://podlens.net for 50/day, then `export ASKAIPODS_API_KEY=pk_xxx`.
|
|
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
|
|
|
@@ -39,7 +39,7 @@ Codex should detect the trigger, run `npx askaipods search "..." --format json`,
|
|
|
39
39
|
|
|
40
40
|
- **Skill not detected**: Restart Codex (per the official docs, "If an update doesn't appear, restart Codex").
|
|
41
41
|
- **Multiple skills with the same name across scopes**: Codex shows both in the selector — repository-scoped wins by default.
|
|
42
|
-
- **`npx askaipods` fails**: Check Node.js 18+: `node --version`.
|
|
42
|
+
- **`npx askaipods` fails**: Check Node.js 18.3.0+: `node --version`.
|
|
43
43
|
|
|
44
44
|
## Reference
|
|
45
45
|
|
|
@@ -26,7 +26,7 @@ Hermes should pick up the skill from `~/.hermes/skills/askaipods/`, shell out to
|
|
|
26
26
|
|
|
27
27
|
## Troubleshooting
|
|
28
28
|
|
|
29
|
-
- **`npx askaipods` fails**: Hermes is Python-based but the askaipods CLI is Node. Make sure Node.js 18+ is on PATH alongside Python: `node --version`.
|
|
29
|
+
- **`npx askaipods` fails**: Hermes is Python-based but the askaipods CLI is Node. Make sure Node.js 18.3.0+ is on PATH alongside Python: `node --version`.
|
|
30
30
|
- **Skill not picked up**: Hermes documentation indicates skills are loaded from `~/.hermes/skills/`. Restart the agent after install if needed.
|
|
31
31
|
- **Quota exhausted**: Set `ASKAIPODS_API_KEY` in your shell environment before launching Hermes so the variable propagates to subprocess calls.
|
|
32
32
|
|
|
@@ -52,7 +52,7 @@ OpenClaw should recognize the trigger phrase, shell out to `npx askaipods search
|
|
|
52
52
|
## Troubleshooting
|
|
53
53
|
|
|
54
54
|
- **Skill not detected**: Run `openclaw skills update --all` to refresh, or restart the OpenClaw session. Check that the directory name `askaipods` matches the `name` field in `SKILL.md`.
|
|
55
|
-
- **`npx askaipods` fails**: Make sure Node.js 18+ is on PATH: `node --version`.
|
|
55
|
+
- **`npx askaipods` fails**: Make sure Node.js 18.3.0+ is on PATH: `node --version`.
|
|
56
56
|
- **Conflicting copies across precedence levels**: Only the highest-precedence one wins. If you have askaipods in both `~/.agents/skills/` and `<workspace>/skills/`, the workspace one takes effect.
|
|
57
57
|
|
|
58
58
|
## Reference
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "askaipods",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Search AI podcast quotes by topic —
|
|
3
|
+
"version": "0.2.0",
|
|
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": {
|
|
7
7
|
"askaipods": "./bin/askaipods.js"
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"LICENSE"
|
|
20
20
|
],
|
|
21
21
|
"engines": {
|
|
22
|
-
"node": ">=18"
|
|
22
|
+
"node": ">=18.3.0"
|
|
23
23
|
},
|
|
24
24
|
"scripts": {
|
|
25
25
|
"start": "node bin/askaipods.js",
|
package/skill/askaipods/SKILL.md
CHANGED
|
@@ -2,14 +2,14 @@
|
|
|
2
2
|
name: askaipods
|
|
3
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.
|
|
4
4
|
license: MIT
|
|
5
|
-
requirements: Node.js 18+ on PATH, internet access to podlens.net. Optional ASKAIPODS_API_KEY env var unlocks the 50/day member tier; without it the skill works on 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 50/day member tier with full dates and unlimited lookback; without it the skill works on the 5/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
|
|
9
9
|
|
|
10
10
|
This skill turns "what is the AI community saying about X" into a list of real quote excerpts pulled from recent episodes of top AI podcasts. The corpus is semantically indexed (embedding-based search), so phrasings like "test-time compute", "inference-time scaling", and "thinking longer" all return overlapping results — the user does not need to guess the exact words a guest used.
|
|
11
11
|
|
|
12
|
-
The data source is the public PodLens search API at `podlens.net`. The skill never hits that API directly from your model context — it shells out to a small bundled CLI (`askaipods`) that handles HTTP,
|
|
12
|
+
The data source is the public PodLens search API at `podlens.net`. The skill never hits that API directly from your model context — it shells out to a small bundled CLI (`askaipods`) that handles HTTP, error mapping, and result sorting. Your job is to invoke the CLI, parse its JSON, and present the results in the format below.
|
|
13
13
|
|
|
14
14
|
## When to invoke
|
|
15
15
|
|
|
@@ -40,17 +40,31 @@ Run the bundled CLI and pass `--format json`. The flag matters because without i
|
|
|
40
40
|
npx askaipods search "<USER QUERY>" --format json
|
|
41
41
|
```
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
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.
|
|
44
|
+
|
|
45
|
+
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.
|
|
44
46
|
|
|
45
47
|
```bash
|
|
46
|
-
|
|
48
|
+
npx askaipods search "<USER QUERY>" --days 90 --format json
|
|
47
49
|
```
|
|
48
50
|
|
|
49
|
-
|
|
51
|
+
### Time-intent mapping (important)
|
|
50
52
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
53
|
+
When the user's query implies a time window, you MUST pass the appropriate `--days` value. Without it, the API returns all-time results regardless of recency words in the query.
|
|
54
|
+
|
|
55
|
+
| User intent | `--days` value |
|
|
56
|
+
|---|---|
|
|
57
|
+
| "recent", "latest", "最近", "current" | `90` |
|
|
58
|
+
| "this month", "这个月" | `30` |
|
|
59
|
+
| "this week", "这周", "last week" | `7` |
|
|
60
|
+
| "today", "yesterday", "last few days" | `3` |
|
|
61
|
+
| "last N days/weeks/months" | Convert N to days |
|
|
62
|
+
| "this quarter", "这个季度" | `90` |
|
|
63
|
+
| "this year", "今年" | `365` (member only; anonymous capped to 90) |
|
|
64
|
+
| Explicit date range (e.g. "since January") | Convert to days from today |
|
|
65
|
+
| No time intent (broad research) | Omit `--days` (all time — no cap applied) |
|
|
66
|
+
|
|
67
|
+
Do NOT silently default every query to `--days 90` — omitting `--days` on broad research queries preserves valuable historical context that the user did not ask to exclude.
|
|
54
68
|
|
|
55
69
|
## JSON shape returned by the CLI
|
|
56
70
|
|
|
@@ -80,13 +94,13 @@ npx askaipods search "<USER QUERY>" --days 30 --format json
|
|
|
80
94
|
|
|
81
95
|
Field notes that affect how you render:
|
|
82
96
|
|
|
83
|
-
- **`tier`** — `member` if the user has a valid API key, `anonymous` otherwise. Drives the rendering branch below.
|
|
84
|
-
- **`render_hint`** — `dual_view` for member, `single_view` for anonymous. Honor this. The reason: anonymous results are
|
|
97
|
+
- **`tier`** — `member` if the user has a valid API key, `anonymous` otherwise. Drives the rendering branch below. On exit `0`, `tier` is always one of these two values — there is no third "unknown" path to handle (the CLI validates the upstream response and exits `3` if the value is missing or unexpected).
|
|
98
|
+
- **`render_hint`** — `dual_view` for member, `single_view` for anonymous. Honor this. The reason: anonymous results are sorted by `published_at` desc (newest-first) by the API, so `api_rank` reflects temporal order, not semantic relevance. Showing a "Top Most Relevant" section for anonymous tier would mislead the user. Member results arrive in similarity order, so `api_rank` is meaningful for relevance-based views.
|
|
85
99
|
- **`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.
|
|
86
100
|
- **`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.
|
|
87
|
-
- **`results[].date` format** — `YYYY-MM-DD` (or full ISO timestamp) for member tier; `YYYY-MM` only for anonymous tier (deliberately fuzzed by the API
|
|
101
|
+
- **`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.
|
|
88
102
|
- **`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.
|
|
89
|
-
- **`meta.restrictions`** — `null` for member tier; for anonymous tier, an object describing the cap (e.g., `{ max_results:
|
|
103
|
+
- **`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.
|
|
90
104
|
- **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.
|
|
91
105
|
|
|
92
106
|
## How to render the response
|
|
@@ -114,7 +128,7 @@ Output exactly this structure. It is required for consistency across runtimes
|
|
|
114
128
|
|
|
115
129
|
2. ...
|
|
116
130
|
|
|
117
|
-
(these 5 are the results with `api_rank` 1 through 5, regardless of date — pull them from the
|
|
131
|
+
(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.)
|
|
118
132
|
|
|
119
133
|
## 💡 Insights
|
|
120
134
|
|
|
@@ -136,7 +150,7 @@ If the same result appears in both Latest and Top Relevant sections, that's fine
|
|
|
136
150
|
|
|
137
151
|
2. ...
|
|
138
152
|
|
|
139
|
-
(all returned results, in `results` array order which is already newest-first; expect up to
|
|
153
|
+
(all returned results, in `results` array order which is already newest-first; expect up to 20)
|
|
140
154
|
|
|
141
155
|
## 💡 Insights
|
|
142
156
|
|
|
@@ -146,14 +160,14 @@ If the same result appears in both Latest and Top Relevant sections, that's fine
|
|
|
146
160
|
|
|
147
161
|
---
|
|
148
162
|
|
|
149
|
-
*Anonymous tier:
|
|
163
|
+
*Anonymous tier: 20 results sorted newest-first, dates fuzzed to month, `--days` capped at 90 when specified. Set `ASKAIPODS_API_KEY` for 50 searches/day with full dates and unlimited lookback — sign up at https://podlens.net.*
|
|
150
164
|
```
|
|
151
165
|
|
|
152
|
-
The closing note about the anonymous tier matters because it tells the user (a) why the
|
|
166
|
+
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.
|
|
153
167
|
|
|
154
168
|
## Insights guidelines
|
|
155
169
|
|
|
156
|
-
The Insights section is the most valuable part of your response — it is what differentiates this skill from a raw API call. The user could read
|
|
170
|
+
The Insights section is the most valuable part of your response — it is what differentiates this skill from a raw API call. The user could read 20 quotes themselves; what they cannot easily do is *spot the patterns across the 20*. That is your job.
|
|
157
171
|
|
|
158
172
|
Write 3-5 bullets, each one concrete and one sentence long. Cover at least three of these dimensions:
|
|
159
173
|
|
|
@@ -178,8 +192,8 @@ The CLI uses stable exit codes so you can branch on the failure mode:
|
|
|
178
192
|
|---|---|---|
|
|
179
193
|
| `0` | Success | Render the results normally |
|
|
180
194
|
| `1` | Usage error / invalid arguments / API key rejected | Surface the stderr message verbatim — it will be a clear actionable error |
|
|
181
|
-
| `2` | Daily quota exhausted |
|
|
182
|
-
| `3` |
|
|
195
|
+
| `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. |
|
|
196
|
+
| `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. |
|
|
183
197
|
|
|
184
198
|
If the `results` array is empty (zero matches above the similarity threshold), say so explicitly: "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." Do not invent quotes to fill the gap.
|
|
185
199
|
|
|
@@ -190,6 +204,6 @@ Never silently swallow an error. Never fabricate quotes when the API returns not
|
|
|
190
204
|
- **No speaker attribution.** The API returns "podcast + episode + quote text" but not "who said it". The upstream pipeline avoids per-speaker attribution because automatic speaker diarization is unreliable — surfacing wrong attribution would be worse than no attribution.
|
|
191
205
|
- **No episode URLs.** The public API does not expose direct links to episodes. Users who want to listen will need to search the podcast and episode title in their podcast app of choice.
|
|
192
206
|
- **AI-focused corpus.** Coverage is dense for AI research, ML engineering, AI investing, and AI policy. Coverage for unrelated topics is sparse and noisy.
|
|
193
|
-
- **Short quote excerpts, not transcripts.** Each result is one extracted "key point" from an episode, typically 1-3 sentences
|
|
207
|
+
- **Short quote excerpts, not transcripts.** Each result is one extracted "key point" from an episode, typically 1-3 sentences. For long-form context, the user will need to listen.
|
|
194
208
|
|
|
195
209
|
These limitations are not bugs — surfacing them honestly is better than the user discovering them mid-task and losing trust.
|
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.
|
|
17
|
+
const VERSION = "0.2.0";
|
|
18
18
|
|
|
19
19
|
const HELP_TEXT = `askaipods ${VERSION} — search AI podcast quotes by topic
|
|
20
20
|
|
|
@@ -24,21 +24,22 @@ USAGE:
|
|
|
24
24
|
|
|
25
25
|
OPTIONS:
|
|
26
26
|
--format <json|markdown> Output format (default: markdown if TTY, json if piped)
|
|
27
|
-
--days <N> Only return results from the last N days (
|
|
27
|
+
--days <N> Only return results from the last N days (anonymous tier caps at 90; member tier accepts any value)
|
|
28
28
|
--api-key <key> PodLens API key (overrides ASKAIPODS_API_KEY env var)
|
|
29
29
|
-h, --help Show this message
|
|
30
30
|
-v, --version Show version
|
|
31
31
|
|
|
32
32
|
ENVIRONMENT:
|
|
33
|
-
ASKAIPODS_API_KEY PodLens API key. Without it:
|
|
33
|
+
ASKAIPODS_API_KEY PodLens API key. Without it: 5 searches/day per IP (anonymous).
|
|
34
34
|
With it: 50 searches/day per user (member).
|
|
35
35
|
Sign up at https://podlens.net to get one.
|
|
36
36
|
|
|
37
37
|
EXIT CODES:
|
|
38
38
|
0 success
|
|
39
39
|
1 usage error / invalid arguments / API key rejected
|
|
40
|
-
2 daily quota exhausted
|
|
41
|
-
3 network
|
|
40
|
+
2 daily quota exhausted (tier-aware message on stderr)
|
|
41
|
+
3 transient or unexpected failure — network / rate-limit burst / 503 /
|
|
42
|
+
protocol error / internal exception (stderr has the actionable detail)
|
|
42
43
|
|
|
43
44
|
EXAMPLES:
|
|
44
45
|
askaipods "what are people saying about test-time compute"
|
|
@@ -99,9 +100,22 @@ export async function run(argv) {
|
|
|
99
100
|
|
|
100
101
|
let days;
|
|
101
102
|
if (values.days !== undefined) {
|
|
103
|
+
// Strict positive integer match. Three reasons for the shape:
|
|
104
|
+
// (1) parseInt("7abc",10) silently returns 7 — reject any
|
|
105
|
+
// non-digit suffix / scientific notation / decimals / sign.
|
|
106
|
+
// (2) client.js only forwards `days` to the API when it's > 0,
|
|
107
|
+
// so "0" or "00" would silently drop the filter entirely
|
|
108
|
+
// instead of filtering to "0 days" as the user expected.
|
|
109
|
+
// (3) Number.parseInt("9".repeat(400), 10) returns Infinity, and
|
|
110
|
+
// JSON.stringify({days: Infinity}) emits {"days":null}, which
|
|
111
|
+
// would send a malformed body instead of the user's intent.
|
|
112
|
+
// Reject inputs that don't survive round-trip as a safe int.
|
|
113
|
+
if (!/^[1-9]\d*$/.test(values.days)) {
|
|
114
|
+
throw usageError("--days must be a positive integer (1 or greater)");
|
|
115
|
+
}
|
|
102
116
|
const n = Number.parseInt(values.days, 10);
|
|
103
|
-
if (!Number.
|
|
104
|
-
throw usageError("--days
|
|
117
|
+
if (!Number.isSafeInteger(n)) {
|
|
118
|
+
throw usageError("--days value is too large");
|
|
105
119
|
}
|
|
106
120
|
days = n;
|
|
107
121
|
}
|
|
@@ -111,7 +125,51 @@ export async function run(argv) {
|
|
|
111
125
|
throw usageError(`--format must be 'json' or 'markdown', got '${format}'`);
|
|
112
126
|
}
|
|
113
127
|
|
|
114
|
-
|
|
128
|
+
// Source of truth for the API key:
|
|
129
|
+
// 1. --api-key flag if provided AND non-empty after trim. An empty
|
|
130
|
+
// or whitespace-only flag is rejected as a usage error — Node's
|
|
131
|
+
// Headers constructor normalizes a whitespace-only header value
|
|
132
|
+
// to empty, so without this trim the user would silently get no
|
|
133
|
+
// X-PodLens-API-Key header and a silent tier downgrade from
|
|
134
|
+
// member to anonymous.
|
|
135
|
+
// 2. ASKAIPODS_API_KEY env var if --api-key is unset. The env var
|
|
136
|
+
// is trimmed and treated as unset when empty/whitespace — shell
|
|
137
|
+
// unset/export mishaps and trailing-newline cases are common and
|
|
138
|
+
// unlikely to reflect user intent, so we silently coerce rather
|
|
139
|
+
// than erroring.
|
|
140
|
+
// HTTP header values must be ByteStrings (each character ≤ 0xFF).
|
|
141
|
+
// Characters outside the printable ASCII + Latin-1 extended range
|
|
142
|
+
// cause Node's Headers constructor to throw a ByteString TypeError,
|
|
143
|
+
// which the fetch catch in client.js would mislabel as a network
|
|
144
|
+
// error (exit 3) instead of a user input problem. This allowlist
|
|
145
|
+
// rejects C0 controls (0x00-0x1F), DEL (0x7F), and any codepoint
|
|
146
|
+
// above 0xFF (LS, PS, ZWSP, emoji, etc.) at the CLI boundary with
|
|
147
|
+
// exit 1.
|
|
148
|
+
const INVALID_KEY_CHARS = /[^\x20-\x7E\x80-\xFF]/;
|
|
149
|
+
let apiKey;
|
|
150
|
+
if (values["api-key"] !== undefined) {
|
|
151
|
+
const trimmed = values["api-key"].trim();
|
|
152
|
+
if (trimmed.length === 0) {
|
|
153
|
+
throw usageError(
|
|
154
|
+
"--api-key value cannot be empty or whitespace-only; omit the flag to use the anonymous tier or the ASKAIPODS_API_KEY env var",
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
if (INVALID_KEY_CHARS.test(trimmed)) {
|
|
158
|
+
throw usageError("--api-key value contains invalid characters (control chars, non-Latin-1 Unicode, or emoji are not allowed in HTTP headers)");
|
|
159
|
+
}
|
|
160
|
+
apiKey = trimmed;
|
|
161
|
+
} else {
|
|
162
|
+
const envTrimmed = (process.env.ASKAIPODS_API_KEY ?? "").trim();
|
|
163
|
+
if (envTrimmed.length === 0) {
|
|
164
|
+
apiKey = undefined;
|
|
165
|
+
} else if (INVALID_KEY_CHARS.test(envTrimmed)) {
|
|
166
|
+
throw usageError(
|
|
167
|
+
"ASKAIPODS_API_KEY env var contains invalid characters (control chars, non-Latin-1 Unicode, or emoji are not allowed in HTTP headers)",
|
|
168
|
+
);
|
|
169
|
+
} else {
|
|
170
|
+
apiKey = envTrimmed;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
115
173
|
|
|
116
174
|
const response = await search({ query, days, apiKey });
|
|
117
175
|
|
package/src/client.js
CHANGED
|
@@ -26,6 +26,106 @@ function exitErr(code, message) {
|
|
|
26
26
|
return new AskaipodsError(message, code);
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
function isPlainObject(v) {
|
|
30
|
+
return v !== null && typeof v === "object" && !Array.isArray(v);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Strict calendar validation for published_at. Accepts only the three
|
|
34
|
+
// documented shapes, validates all numeric components against calendar
|
|
35
|
+
// and clock bounds, and round-trips via Date.UTC to catch impossible
|
|
36
|
+
// day/month combinations (Feb 30 etc). Timezone offset is required
|
|
37
|
+
// whenever a time component is present — a timezone-less datetime like
|
|
38
|
+
// "2025-10-15T12:00:00" would be ambiguous (Date.parse interprets it
|
|
39
|
+
// in the system's local timezone, making sort order non-deterministic
|
|
40
|
+
// across machines). Format: Z or ±HH:MM (colonized), hour bounded
|
|
41
|
+
// 0-14, minute bounded 0-59.
|
|
42
|
+
const PUBLISHED_AT_SHAPE =
|
|
43
|
+
/^(\d{4})-(\d{2})(?:-(\d{2})(?:T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(Z|[+-]\d{2}:\d{2}))?)?$/;
|
|
44
|
+
function isValidPublishedAt(v) {
|
|
45
|
+
if (v == null) return true;
|
|
46
|
+
if (typeof v !== "string") return false;
|
|
47
|
+
const parts = v.match(PUBLISHED_AT_SHAPE);
|
|
48
|
+
if (!parts) return false;
|
|
49
|
+
const year = Number(parts[1]);
|
|
50
|
+
const month = Number(parts[2]);
|
|
51
|
+
if (year < 1970 || year > 9999) return false;
|
|
52
|
+
if (month < 1 || month > 12) return false;
|
|
53
|
+
if (parts[3] !== undefined) {
|
|
54
|
+
const day = Number(parts[3]);
|
|
55
|
+
if (day < 1 || day > 31) return false;
|
|
56
|
+
const dt = new Date(Date.UTC(year, month - 1, day));
|
|
57
|
+
if (
|
|
58
|
+
dt.getUTCFullYear() !== year ||
|
|
59
|
+
dt.getUTCMonth() !== month - 1 ||
|
|
60
|
+
dt.getUTCDate() !== day
|
|
61
|
+
) {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
if (parts[4] !== undefined) {
|
|
65
|
+
const hh = Number(parts[4]);
|
|
66
|
+
const mm = Number(parts[5]);
|
|
67
|
+
const ss = Number(parts[6]);
|
|
68
|
+
if (hh > 23 || mm > 59 || ss > 59) return false;
|
|
69
|
+
if (parts[7] !== undefined && parts[7] !== "Z") {
|
|
70
|
+
// parts[7] is "±HH:MM" (enforced by regex). Check offset bounds.
|
|
71
|
+
const offHh = Number(parts[7].slice(1, 3));
|
|
72
|
+
const offMm = Number(parts[7].slice(4, 6));
|
|
73
|
+
if (offHh > 14 || offMm > 59) return false;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Validate the PodLens success envelope against the documented contract.
|
|
81
|
+
// Any mismatch is treated as a protocol break and surfaces as exit 3 —
|
|
82
|
+
// better a loud AskaipodsError(exitCode=3) than a TypeError escaping as
|
|
83
|
+
// exit 1 when format.js tries to operate on malformed fields.
|
|
84
|
+
//
|
|
85
|
+
// Required envelope:
|
|
86
|
+
// data : non-array object
|
|
87
|
+
// data.total : finite number
|
|
88
|
+
// data.results : array (may be empty)
|
|
89
|
+
// data.results[i] : non-array object
|
|
90
|
+
// data.results[i].text : string (required, never null)
|
|
91
|
+
// data.results[i].episode_title : string or null/undefined
|
|
92
|
+
// data.results[i].podcast_name : string or null/undefined
|
|
93
|
+
// data.results[i].published_at : string or null/undefined
|
|
94
|
+
// data.meta : non-array object
|
|
95
|
+
// data.meta.tier : closed enum {"anonymous","member"}
|
|
96
|
+
// data.meta.quota : non-array object
|
|
97
|
+
// data.meta.quota.used : finite number
|
|
98
|
+
// data.meta.quota.limit : finite number
|
|
99
|
+
//
|
|
100
|
+
// Optional (kept loose on purpose):
|
|
101
|
+
// data.meta.quota.period, data.meta.quota.next_reset,
|
|
102
|
+
// data.meta.query_hash, data.meta.restrictions, data.meta.cta
|
|
103
|
+
function isValidSuccessEnvelope(data) {
|
|
104
|
+
if (!isPlainObject(data)) return false;
|
|
105
|
+
if (typeof data.total !== "number" || !Number.isFinite(data.total)) return false;
|
|
106
|
+
if (!Array.isArray(data.results)) return false;
|
|
107
|
+
for (const item of data.results) {
|
|
108
|
+
if (!isPlainObject(item)) return false;
|
|
109
|
+
// text must be a non-empty, non-whitespace-only string. An all-
|
|
110
|
+
// whitespace text would otherwise render as an empty blockquote
|
|
111
|
+
// row (`> ` with nothing after it) in --format markdown.
|
|
112
|
+
if (typeof item.text !== "string" || item.text.trim().length === 0) return false;
|
|
113
|
+
// `!= null` intentionally matches both null and undefined (contract
|
|
114
|
+
// allows either as "missing"), but rejects numbers, objects, arrays.
|
|
115
|
+
if (item.episode_title != null && typeof item.episode_title !== "string") return false;
|
|
116
|
+
if (item.podcast_name != null && typeof item.podcast_name !== "string") return false;
|
|
117
|
+
if (!isValidPublishedAt(item.published_at)) return false;
|
|
118
|
+
}
|
|
119
|
+
const m = data.meta;
|
|
120
|
+
if (!isPlainObject(m)) return false;
|
|
121
|
+
if (m.tier !== "anonymous" && m.tier !== "member") return false;
|
|
122
|
+
const q = m.quota;
|
|
123
|
+
if (!isPlainObject(q)) return false;
|
|
124
|
+
if (typeof q.used !== "number" || !Number.isFinite(q.used)) return false;
|
|
125
|
+
if (typeof q.limit !== "number" || !Number.isFinite(q.limit)) return false;
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
|
|
29
129
|
export async function search({ query, days, apiKey, endpoint = PODLENS_ENDPOINT }) {
|
|
30
130
|
if (typeof query !== "string" || query.trim().length < MIN_QUERY_LEN) {
|
|
31
131
|
throw exitErr(1, "query is required (1-300 characters)");
|
|
@@ -36,7 +136,7 @@ export async function search({ query, days, apiKey, endpoint = PODLENS_ENDPOINT
|
|
|
36
136
|
|
|
37
137
|
const headers = {
|
|
38
138
|
"Content-Type": "application/json",
|
|
39
|
-
"User-Agent": "askaipods/0.
|
|
139
|
+
"User-Agent": "askaipods/0.2.0 (+https://github.com/Delibread0601/askaipods)",
|
|
40
140
|
};
|
|
41
141
|
if (apiKey) {
|
|
42
142
|
headers["X-PodLens-API-Key"] = apiKey;
|
|
@@ -77,20 +177,28 @@ export async function search({ query, days, apiKey, endpoint = PODLENS_ENDPOINT
|
|
|
77
177
|
}
|
|
78
178
|
|
|
79
179
|
if (response.ok) {
|
|
180
|
+
if (!isValidSuccessEnvelope(data)) {
|
|
181
|
+
throw exitErr(
|
|
182
|
+
3,
|
|
183
|
+
"unexpected response shape from podlens.net (envelope, results entries, meta.tier, or meta.quota failed contract validation). Retry in a moment.",
|
|
184
|
+
);
|
|
185
|
+
}
|
|
80
186
|
return data;
|
|
81
187
|
}
|
|
82
188
|
|
|
83
189
|
// Distinguish 429 cases by inspecting the message: the server uses
|
|
84
190
|
// distinct strings for "burst limit hit" vs "daily quota exhausted",
|
|
85
|
-
// and only the latter warrants
|
|
191
|
+
// and only the latter warrants the "daily quota" exit code. The
|
|
192
|
+
// quota message is tier-aware: a member hitting the 50/day cap must
|
|
193
|
+
// not be told to "set ASKAIPODS_API_KEY" — they already have one.
|
|
86
194
|
if (response.status === 429) {
|
|
87
195
|
const msg = String(data?.error ?? "").toLowerCase();
|
|
88
196
|
if (msg.includes("quota")) {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
"daily search quota exhausted
|
|
92
|
-
"For 50 searches/day, set ASKAIPODS_API_KEY (sign up at https://podlens.net)."
|
|
93
|
-
);
|
|
197
|
+
const quotaMsg = apiKey
|
|
198
|
+
? "daily search quota exhausted (member tier: 50/day). Quota resets at 00:00 UTC."
|
|
199
|
+
: "daily search quota exhausted (anonymous tier: 5/day). Quota resets at 00:00 UTC. " +
|
|
200
|
+
"For 50 searches/day, set ASKAIPODS_API_KEY (sign up at https://podlens.net).";
|
|
201
|
+
throw exitErr(2, quotaMsg);
|
|
94
202
|
}
|
|
95
203
|
throw exitErr(3, "rate limited by podlens.net (too many requests in a short window). Retry in a minute.");
|
|
96
204
|
}
|
package/src/format.js
CHANGED
|
@@ -12,21 +12,38 @@
|
|
|
12
12
|
// Relevant" sub-views — see SKILL.md.
|
|
13
13
|
|
|
14
14
|
const ANONYMOUS_NOTE =
|
|
15
|
-
"Anonymous tier:
|
|
16
|
-
"
|
|
15
|
+
"Anonymous tier: 20 results sorted newest-first, dates fuzzed to month, " +
|
|
16
|
+
"--days capped at 90 when specified. Set ASKAIPODS_API_KEY for 50 searches/day with full dates and unlimited lookback.";
|
|
17
17
|
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
18
|
+
// Sort results newest-first by parsing each `published_at` to a UTC
|
|
19
|
+
// millisecond timestamp and comparing numerically. Pure lexical compare
|
|
20
|
+
// is broken for ISO timestamps with timezone offsets:
|
|
21
|
+
// "2025-01-01T00:30:00+14:00" (UTC 2024-12-31T10:30Z) lex-sorts ahead
|
|
22
|
+
// of "2024-12-31T23:30:00-12:00" (UTC 2025-01-01T11:30Z), reversing the
|
|
23
|
+
// newest-first contract for any member-tier response that carries
|
|
24
|
+
// offset timestamps. Numeric UTC compare fixes that.
|
|
25
|
+
//
|
|
26
|
+
// Anonymous tier dates are YYYY-MM (month only); Date.parse is
|
|
27
|
+
// inconsistent across engines for that shape, so normalize to
|
|
28
|
+
// YYYY-MM-01 first. Member tier dates are always Date.parse-able
|
|
29
|
+
// (either YYYY-MM-DD or a full ISO 8601 timestamp with offset).
|
|
30
|
+
//
|
|
31
|
+
// Nulls and any unparseable value sort to the end so absent-date
|
|
32
|
+
// results don't crowd out the dated ones.
|
|
33
|
+
function toUtcMs(dateStr) {
|
|
34
|
+
if (!dateStr) return null;
|
|
35
|
+
const normalized = /^\d{4}-\d{2}$/.test(dateStr) ? `${dateStr}-01` : dateStr;
|
|
36
|
+
const ms = Date.parse(normalized);
|
|
37
|
+
return Number.isNaN(ms) ? null : ms;
|
|
38
|
+
}
|
|
22
39
|
export function sortByDateDesc(items) {
|
|
23
40
|
return [...items].sort((a, b) => {
|
|
24
|
-
const
|
|
25
|
-
const
|
|
26
|
-
if (
|
|
27
|
-
if (
|
|
28
|
-
if (
|
|
29
|
-
return
|
|
41
|
+
const am = toUtcMs(a?.published_at);
|
|
42
|
+
const bm = toUtcMs(b?.published_at);
|
|
43
|
+
if (am === bm) return 0;
|
|
44
|
+
if (am === null) return 1;
|
|
45
|
+
if (bm === null) return -1;
|
|
46
|
+
return bm - am;
|
|
30
47
|
});
|
|
31
48
|
}
|
|
32
49
|
|
|
@@ -34,18 +51,25 @@ export function sortByDateDesc(items) {
|
|
|
34
51
|
// its `api_rank` (1 = most semantically relevant in API order) so the
|
|
35
52
|
// SKILL.md can tell the agent to derive a "Top Relevant" sub-view for
|
|
36
53
|
// member tier without re-querying. For anonymous tier api_rank reflects
|
|
37
|
-
//
|
|
38
|
-
//
|
|
54
|
+
// temporal order (newest-first from the API), not semantic relevance —
|
|
55
|
+
// `render_hint` flags that distinction.
|
|
39
56
|
//
|
|
40
|
-
// `
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
57
|
+
// Preconditions: `response` must be a validated success envelope from
|
|
58
|
+
// `client.search()`. `client.js` validates `meta.tier` is a non-empty
|
|
59
|
+
// string and `meta.quota` is an object before returning, so we read
|
|
60
|
+
// those fields directly here without a fallback. A programmatic caller
|
|
61
|
+
// that bypasses client.search() and hands toStructured a malformed
|
|
62
|
+
// response will get a TypeError — that's louder than a silent fallback
|
|
63
|
+
// and matches the "protocol break → exit 3" philosophy of client.js.
|
|
46
64
|
export function toStructured(query, response) {
|
|
47
|
-
|
|
48
|
-
|
|
65
|
+
// These fields are guaranteed by client.js's success-envelope validator:
|
|
66
|
+
// response.results (array), response.meta (object),
|
|
67
|
+
// response.meta.tier (non-empty string), response.meta.quota (object).
|
|
68
|
+
// Read them directly. The remaining fields below (total,
|
|
69
|
+
// meta.query_hash, meta.restrictions) are optional per the server
|
|
70
|
+
// contract and keep their `?? null` / fallback defenses.
|
|
71
|
+
const tier = response.meta.tier;
|
|
72
|
+
const apiResults = response.results;
|
|
49
73
|
|
|
50
74
|
const withRank = apiResults.map((r, idx) => ({ ...r, api_rank: idx + 1 }));
|
|
51
75
|
const sorted = sortByDateDesc(withRank);
|
|
@@ -63,10 +87,10 @@ export function toStructured(query, response) {
|
|
|
63
87
|
api_rank: r.api_rank,
|
|
64
88
|
})),
|
|
65
89
|
meta: {
|
|
66
|
-
total_returned:
|
|
67
|
-
quota: response
|
|
68
|
-
restrictions: response
|
|
69
|
-
query_hash: response
|
|
90
|
+
total_returned: response.total,
|
|
91
|
+
quota: response.meta.quota,
|
|
92
|
+
restrictions: response.meta.restrictions ?? null,
|
|
93
|
+
query_hash: response.meta.query_hash ?? null,
|
|
70
94
|
},
|
|
71
95
|
};
|
|
72
96
|
}
|