askaipods 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # askaipods
2
2
 
3
- > Search AI podcast quotes about a topic — find what real guests on Lex Fridman, Dwarkesh Patel, No Priors, Latent Space, and dozens of other AI podcasts are actually saying. 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).
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"
@@ -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 a **Latest** + **Top Relevant** + **Insights** structure.
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 results are a randomized subset and the rank is not a true semantic-relevance signal).
71
71
 
72
72
  ## Usage
73
73
 
@@ -94,7 +94,7 @@ 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 a Latest section, a Top Relevant section, and an AI-generated Insights summary. No CLI knowledge required from the user.
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 a randomized subset of the top 20 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
 
@@ -125,7 +125,7 @@ These are not bugs. The skill surfaces them honestly so neither you nor your age
125
125
  | `0` | Success |
126
126
  | `1` | Usage error / invalid arguments / API key rejected |
127
127
  | `2` | Daily quota exhausted |
128
- | `3` | Network error / podlens.net unavailable |
128
+ | `3` | Transient or unexpected failure — network error, rate-limit burst, service 503, protocol/shape error, or internal exception. stderr has the actionable detail. |
129
129
 
130
130
  ## How the skill renders results
131
131
 
@@ -158,12 +158,12 @@ askaipods/
158
158
  ├── skill/askaipods/
159
159
  │ └── SKILL.md ← agentskills.io standard skill file
160
160
  ├── examples/ ← per-runtime install guides
161
- ├── package.json ← zero dependencies (Node 18+ stdlib only)
161
+ ├── package.json ← zero dependencies (Node 18.3.0+ stdlib only)
162
162
  ├── LICENSE ← MIT
163
163
  └── README.md
164
164
  ```
165
165
 
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.
166
+ 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
167
 
168
168
  ## Contributing
169
169
 
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
- process.exit(typeof err?.exitCode === "number" ? err.exitCode : 1);
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
  });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "askaipods",
3
- "version": "0.1.0",
4
- "description": "Search AI podcast quotes by topic — find what Lex Fridman, Dwarkesh Patel, No Priors, and Latent Space guests are actually saying. 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.",
3
+ "version": "0.1.1",
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",
@@ -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 10/day anonymous tier (per-IP).
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; without it the skill works on the 10/day anonymous tier (per-IP).
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, retries, error mapping, and result sorting. Your job is to invoke the CLI, parse its JSON, and present the results in the format below.
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,11 +40,7 @@ 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
- If `npx askaipods` is not available in the user's environment (e.g. the package was not yet published to npm), fall back to running the local CLI from this skill's parent directory:
44
-
45
- ```bash
46
- node <path-to-askaipods-repo>/bin/askaipods.js search "<USER QUERY>" --format json
47
- ```
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.
48
44
 
49
45
  To restrict to recent episodes only, add `--days N` (the API caps anonymous tier at 7 days; member tier accepts any value):
50
46
 
@@ -80,7 +76,7 @@ npx askaipods search "<USER QUERY>" --days 30 --format json
80
76
 
81
77
  Field notes that affect how you render:
82
78
 
83
- - **`tier`** — `member` if the user has a valid API key, `anonymous` otherwise. Drives the rendering branch below. The CLI defaults `tier` to `"anonymous"` if the upstream response somehow lacks the field, so you will always land on one of the two documented branches never on a third "unknown" path.
79
+ - **`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).
84
80
  - **`render_hint`** — `dual_view` for member, `single_view` for anonymous. Honor this. The reason: anonymous results are a randomized 10-of-20 subset, so `api_rank` only describes order *within that random subset*, not true semantic relevance against the corpus. Showing a "Top Most Relevant" section for anonymous tier would mislead the user.
85
81
  - **`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
82
  - **`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,7 +110,7 @@ Output exactly this structure. It is required for consistency across runtimes
114
110
 
115
111
  2. ...
116
112
 
117
- (these 5 are the results with `api_rank` 1 through 5, regardless of date — pull them from the same `results` array by filtering on `api_rank`)
113
+ (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
114
 
119
115
  ## 💡 Insights
120
116
 
@@ -178,8 +174,8 @@ The CLI uses stable exit codes so you can branch on the failure mode:
178
174
  |---|---|---|
179
175
  | `0` | Success | Render the results normally |
180
176
  | `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 | "Daily search quota reached. Anonymous tier resets at 00:00 UTC; for 50 searches/day, set `ASKAIPODS_API_KEY` (sign up at https://podlens.net)." |
182
- | `3` | Network error / podlens.net unavailable | Retry once after a brief pause; if it fails again, tell the user "PodLens search is temporarily unavailable. Try again in a few minutes." |
177
+ | `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. |
178
+ | `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
179
 
184
180
  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
181
 
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.1.0";
17
+ const VERSION = "0.1.1";
18
18
 
19
19
  const HELP_TEXT = `askaipods ${VERSION} — search AI podcast quotes by topic
20
20
 
@@ -37,8 +37,9 @@ ENVIRONMENT:
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 error / podlens.net unavailable
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.isFinite(n) || n < 0) {
104
- throw usageError("--days must be a non-negative integer");
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
- const apiKey = values["api-key"] ?? process.env.ASKAIPODS_API_KEY;
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.1.0 (+https://github.com/Delibread0601/askaipods)",
139
+ "User-Agent": "askaipods/0.1.1 (+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 telling the user about API keys.
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
- throw exitErr(
90
- 2,
91
- "daily search quota exhausted. Anonymous tier resets at 00:00 UTC. " +
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: 10/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
@@ -15,18 +15,35 @@ const ANONYMOUS_NOTE =
15
15
  "Anonymous tier: 10 randomized results from top 20, text truncated by rank, " +
16
16
  "dates fuzzed to month. Set ASKAIPODS_API_KEY for 50 searches/day with full text and dates.";
17
17
 
18
- // Lexical compare on YYYY-MM[-DD][THH:MM:SSZ] descending puts newest
19
- // first regardless of whether the date is a full ISO timestamp (member
20
- // tier) or a YYYY-MM month-prefix (anonymous tier). Nulls sort to the
21
- // end so absent-date results don't crowd out the dated ones.
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 ad = a?.published_at ?? "";
25
- const bd = b?.published_at ?? "";
26
- if (ad === bd) return 0;
27
- if (!ad) return 1;
28
- if (!bd) return -1;
29
- return bd < ad ? -1 : 1;
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
 
@@ -37,15 +54,22 @@ export function sortByDateDesc(items) {
37
54
  // only the relative order within a randomized subset, not the corpus
38
55
  // rank — `render_hint` flags that distinction.
39
56
  //
40
- // `tier` defaults to "anonymous" rather than "unknown" if the upstream
41
- // response is missing the field, so the SKILL.md tier branch (which
42
- // only documents `anonymous` and `member`) always lands on a documented
43
- // path. Anonymous is the safer default because it disables the
44
- // "Top Relevant" view better to under-promise relevance ranking than
45
- // to render a misleading section based on randomized data.
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
- const tier = response?.meta?.tier ?? "anonymous";
48
- const apiResults = Array.isArray(response?.results) ? response.results : [];
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: typeof response?.total === "number" ? response.total : apiResults.length,
67
- quota: response?.meta?.quota ?? null,
68
- restrictions: response?.meta?.restrictions ?? null,
69
- query_hash: response?.meta?.query_hash ?? null,
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
  }