@firstpick/pi-extension-hacker-news 0.1.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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +52 -0
  3. package/index.ts +608 -0
  4. package/package.json +27 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Firstpick
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,52 @@
1
+ # pi-extension-hacker-news
2
+
3
+ Pi extension that adds a generic `news_feed` tool backed by Hacker News, Socket.dev Blog, and optional authenticated sources like daily.dev. By default it fetches up to 10 total entries across all configured sources, split evenly per enabled source.
4
+
5
+ ## Tools
6
+
7
+ - `news_feed` — fetch entries from `hackernews`, `socket`, `dailydev`, or `all`.
8
+ - Hacker News feeds: `top`, `new`, `best`, `ask`, `show`, `job`.
9
+ - Socket source uses `https://socket.dev/api/blog/feed.json`.
10
+ - daily.dev source uses `https://api.daily.dev/public/v1/feeds/popular` with `DAILY_DEV_TOKEN` when configured, otherwise falls back to the unofficial unauthenticated GraphQL endpoint.
11
+
12
+ ## Commands
13
+
14
+ ```text
15
+ /news [hackernews|socket|dailydev|all] [limit] [top|new|best|ask|show|job]
16
+ /news-setup
17
+ ```
18
+
19
+ Examples:
20
+
21
+ ```text
22
+ /news # all enabled sources, max 10 total
23
+ /news socket 10
24
+ /news dailydev 10
25
+ /news all 20 new
26
+ /news hackernews 20 new
27
+ /news-setup # configure optional source tokens, currently daily.dev
28
+ ```
29
+
30
+ ## daily.dev setup
31
+
32
+ Run `/news-setup`, choose `daily.dev API token`, then paste a Personal Access Token from:
33
+
34
+ ```text
35
+ https://app.daily.dev/settings/api
36
+ ```
37
+
38
+ The token is optional. Without it, daily.dev falls back to the unofficial GraphQL API. If configured, the token is saved to Pi's global env file:
39
+
40
+ ```text
41
+ ~/.pi/agent/.env
42
+ ```
43
+
44
+ ## Install locally
45
+
46
+ Symlink `index.ts` into Pi's extension directory:
47
+
48
+ ```bash
49
+ ln -s /home/firstpick/npm-packages/pi-extension-hacker-news/index.ts ~/.pi/agent/extensions/hacker-news.ts
50
+ ```
51
+
52
+ Then run `/reload` in Pi.
package/index.ts ADDED
@@ -0,0 +1,608 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
5
+ import { Box, Text } from "@earendil-works/pi-tui";
6
+ import { Type } from "typebox";
7
+
8
+ const HN_BASE_URL = "https://hacker-news.firebaseio.com/v0";
9
+ const SOCKET_FEED_URL = "https://socket.dev/api/blog/feed.json";
10
+ const DAILY_DEV_BASE_URL = "https://api.daily.dev/public/v1";
11
+ const DAILY_DEV_TOKEN_ENV = "DAILY_DEV_TOKEN";
12
+ const NEWS_MESSAGE_TYPE = "news-feed-result";
13
+ const DEFAULT_LIMIT = 10;
14
+ const MAX_LIMIT = 50;
15
+ const MAX_COMMENT_DEPTH = 3;
16
+ const MAX_COMMENT_COUNT = 100;
17
+
18
+ const NEWS_SOURCES = ["hackernews", "socket", "dailydev", "all"] as const;
19
+ const CONCRETE_NEWS_SOURCES = ["hackernews", "socket", "dailydev"] as const;
20
+ type ConcreteNewsSource = (typeof CONCRETE_NEWS_SOURCES)[number];
21
+ type NewsSource = (typeof NEWS_SOURCES)[number];
22
+
23
+ const HN_FEEDS = ["top", "new", "best", "ask", "show", "job"] as const;
24
+ type HnFeed = (typeof HN_FEEDS)[number];
25
+
26
+ type HnItem = {
27
+ id: number;
28
+ deleted?: boolean;
29
+ type?: "job" | "story" | "comment" | "poll" | "pollopt";
30
+ by?: string;
31
+ time?: number;
32
+ text?: string;
33
+ dead?: boolean;
34
+ parent?: number;
35
+ poll?: number;
36
+ kids?: number[];
37
+ url?: string;
38
+ score?: number;
39
+ title?: string;
40
+ parts?: number[];
41
+ descendants?: number;
42
+ };
43
+
44
+ type CommentNode = {
45
+ id: number;
46
+ by?: string;
47
+ time?: number;
48
+ text?: string;
49
+ kids?: CommentNode[];
50
+ };
51
+
52
+ type NewsEntry = {
53
+ source: "hackernews" | "socket" | "dailydev";
54
+ id?: string | number;
55
+ title: string;
56
+ url: string;
57
+ sourceUrl?: string;
58
+ author?: string;
59
+ score?: number;
60
+ comments?: number;
61
+ publishedAt?: string;
62
+ summary?: string;
63
+ feed?: string;
64
+ };
65
+
66
+ type SocketJsonFeed = {
67
+ title?: string;
68
+ home_page_url?: string;
69
+ feed_url?: string;
70
+ items?: Array<{
71
+ id?: string;
72
+ url?: string;
73
+ external_url?: string;
74
+ title?: string;
75
+ content_text?: string;
76
+ content_html?: string;
77
+ summary?: string;
78
+ date_published?: string;
79
+ date_modified?: string;
80
+ author?: { name?: string };
81
+ authors?: Array<{ name?: string }>;
82
+ }>;
83
+ };
84
+
85
+ type DailyDevPost = {
86
+ id?: string;
87
+ title?: string;
88
+ url?: string | null;
89
+ summary?: string | null;
90
+ publishedAt?: string | null;
91
+ createdAt?: string;
92
+ commentsPermalink?: string;
93
+ source?: { name?: string; handle?: string };
94
+ tags?: string[];
95
+ numUpvotes?: number;
96
+ numComments?: number;
97
+ author?: { name?: string | null } | null;
98
+ };
99
+
100
+ type DailyDevFeedResponse = {
101
+ data?: DailyDevPost[];
102
+ };
103
+
104
+ type DailyDevGraphqlResponse = {
105
+ data?: {
106
+ latest?: DailyDevPost[];
107
+ };
108
+ errors?: Array<{ message?: string }>;
109
+ };
110
+
111
+ type SetupUiContext = {
112
+ ui: {
113
+ input(title: string, placeholder?: string): Promise<string | undefined>;
114
+ select(title: string, options: string[]): Promise<string | undefined>;
115
+ notify(message: string, level?: "info" | "warning" | "error" | "success"): void;
116
+ };
117
+ };
118
+
119
+ type EnvResolution = {
120
+ value?: string;
121
+ source?: string;
122
+ path?: string;
123
+ };
124
+
125
+ type NewsMessageDetails = {
126
+ source: NewsSource;
127
+ entries: NewsEntry[];
128
+ generatedAt: number;
129
+ };
130
+
131
+ const sourceSchema = Type.Union([Type.Literal("hackernews"), Type.Literal("socket"), Type.Literal("dailydev"), Type.Literal("all")]);
132
+ const hnFeedSchema = Type.Union([
133
+ Type.Literal("top"),
134
+ Type.Literal("new"),
135
+ Type.Literal("best"),
136
+ Type.Literal("ask"),
137
+ Type.Literal("show"),
138
+ Type.Literal("job"),
139
+ ]);
140
+
141
+ const NEWS_FEED_PARAMS = Type.Object({
142
+ source: Type.Optional(sourceSchema, { description: "News source: hackernews, socket, or all." }),
143
+ feed: Type.Optional(hnFeedSchema, { description: "Hacker News feed when source is hackernews/all: top, new, best, ask, show, or job." }),
144
+ limit: Type.Optional(Type.Number({ minimum: 1, maximum: MAX_LIMIT, description: "Number of entries to return, max 50." })),
145
+ });
146
+
147
+ const ITEM_PARAMS = Type.Object({
148
+ id: Type.Number({ minimum: 1, description: "Hacker News item ID." }),
149
+ includeComments: Type.Optional(Type.Boolean({ description: "Whether to include a bounded comment tree for story items." })),
150
+ maxDepth: Type.Optional(Type.Number({ minimum: 0, maximum: MAX_COMMENT_DEPTH, description: "Maximum comment depth, max 3." })),
151
+ maxComments: Type.Optional(Type.Number({ minimum: 1, maximum: MAX_COMMENT_COUNT, description: "Maximum total comments to fetch, max 100." })),
152
+ });
153
+
154
+ function clampInteger(value: unknown, fallback: number, min: number, max: number): number {
155
+ const parsed = typeof value === "number" && Number.isFinite(value) ? Math.trunc(value) : fallback;
156
+ return Math.min(max, Math.max(min, parsed));
157
+ }
158
+
159
+ function getGlobalEnvPath(): string {
160
+ return join(homedir(), ".pi", "agent", ".env");
161
+ }
162
+
163
+ function parseEnvFile(filePath: string): Record<string, string> {
164
+ if (!existsSync(filePath)) return {};
165
+ const values: Record<string, string> = {};
166
+ for (const rawLine of readFileSync(filePath, "utf8").split(/\r?\n/)) {
167
+ const line = rawLine.trim();
168
+ if (!line || line.startsWith("#")) continue;
169
+ const match = line.match(/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/);
170
+ if (!match) continue;
171
+ let value = match[2] ?? "";
172
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
173
+ value = value.slice(1, -1);
174
+ }
175
+ values[match[1] ?? ""] = value.replace(/\\n/g, "\n");
176
+ }
177
+ return values;
178
+ }
179
+
180
+ function quoteEnvValue(value: string): string {
181
+ return JSON.stringify(value);
182
+ }
183
+
184
+ function upsertEnvValue(filePath: string, key: string, value: string): void {
185
+ let content = existsSync(filePath) ? readFileSync(filePath, "utf8") : "";
186
+ const line = `${key}=${quoteEnvValue(value)}`;
187
+ const pattern = new RegExp(`^\\s*(?:export\\s+)?${key}\\s*=.*$`, "m");
188
+ content = pattern.test(content) ? content.replace(pattern, line) : `${content}${content && !content.endsWith("\n") ? "\n" : ""}${line}\n`;
189
+ mkdirSync(dirname(filePath), { recursive: true });
190
+ writeFileSync(filePath, content, { mode: 0o600 });
191
+ }
192
+
193
+ function resolveEnvValue(key: string): EnvResolution {
194
+ const envValue = process.env[key]?.trim();
195
+ if (envValue) return { value: envValue, source: "environment" };
196
+
197
+ const globalEnvPath = getGlobalEnvPath();
198
+ const globalValue = parseEnvFile(globalEnvPath)[key]?.trim();
199
+ if (globalValue) return { value: globalValue, source: "Pi global .env", path: globalEnvPath };
200
+
201
+ return {};
202
+ }
203
+
204
+ function hnFeedToEndpoint(feed: HnFeed): string {
205
+ return feed === "top" ? "topstories" : `${feed}stories`;
206
+ }
207
+
208
+ function hnItemUrl(id: number): string {
209
+ return `https://news.ycombinator.com/item?id=${id}`;
210
+ }
211
+
212
+ async function fetchUrlJson<T>(url: string, signal?: AbortSignal): Promise<T> {
213
+ const response = await fetch(url, {
214
+ signal,
215
+ headers: { "user-agent": "pi-news-feed/0.1 (+https://socket.dev)" },
216
+ });
217
+ if (!response.ok) throw new Error(`HTTP ${response.status} for ${url}`);
218
+ return (await response.json()) as T;
219
+ }
220
+
221
+ async function fetchHnJson<T>(path: string, signal?: AbortSignal): Promise<T> {
222
+ return fetchUrlJson<T>(`${HN_BASE_URL}${path}`, signal);
223
+ }
224
+
225
+ async function fetchHnItem(id: number, signal?: AbortSignal): Promise<HnItem | null> {
226
+ return fetchHnJson<HnItem | null>(`/item/${id}.json`, signal);
227
+ }
228
+
229
+ function htmlToText(html: string | undefined): string | undefined {
230
+ if (!html) return undefined;
231
+ return html
232
+ .replace(/<p>/gi, "\n\n")
233
+ .replace(/<br\s*\/?>/gi, "\n")
234
+ .replace(/<[^>]+>/g, "")
235
+ .replace(/&#x27;/g, "'")
236
+ .replace(/&quot;/g, '"')
237
+ .replace(/&gt;/g, ">")
238
+ .replace(/&lt;/g, "<")
239
+ .replace(/&amp;/g, "&")
240
+ .trim();
241
+ }
242
+
243
+ function summarizeHnStory(item: HnItem, feed: HnFeed): NewsEntry {
244
+ return {
245
+ source: "hackernews",
246
+ feed,
247
+ id: item.id,
248
+ title: item.title ?? `(untitled item ${item.id})`,
249
+ url: item.url ?? hnItemUrl(item.id),
250
+ sourceUrl: hnItemUrl(item.id),
251
+ author: item.by,
252
+ score: item.score,
253
+ comments: item.descendants,
254
+ publishedAt: item.time ? new Date(item.time * 1000).toISOString() : undefined,
255
+ };
256
+ }
257
+
258
+ async function fetchHnFeed(feed: HnFeed, limit: number, signal?: AbortSignal): Promise<NewsEntry[]> {
259
+ const ids = await fetchHnJson<number[]>(`/${hnFeedToEndpoint(feed)}.json`, signal);
260
+ const items = await Promise.all(ids.slice(0, limit).map((id) => fetchHnItem(id, signal)));
261
+ return items.filter((item): item is HnItem => !!item).map((item) => summarizeHnStory(item, feed));
262
+ }
263
+
264
+ async function fetchSocketFeed(limit: number, signal?: AbortSignal): Promise<NewsEntry[]> {
265
+ const feed = await fetchUrlJson<SocketJsonFeed>(SOCKET_FEED_URL, signal);
266
+ return (feed.items ?? []).slice(0, limit).map((item) => ({
267
+ source: "socket" as const,
268
+ id: item.id,
269
+ title: item.title ?? item.id ?? "Untitled Socket blog post",
270
+ url: item.url ?? item.external_url ?? item.id ?? "https://socket.dev/blog",
271
+ sourceUrl: item.url ?? item.external_url ?? item.id,
272
+ author: item.author?.name ?? item.authors?.map((author) => author.name).filter(Boolean).join(", "),
273
+ publishedAt: item.date_published ?? item.date_modified,
274
+ summary: item.summary ?? htmlToText(item.content_html)?.slice(0, 280) ?? item.content_text?.slice(0, 280),
275
+ }));
276
+ }
277
+
278
+ function mapDailyDevPosts(posts: DailyDevPost[], limit: number): NewsEntry[] {
279
+ return posts.slice(0, limit).map((post) => ({
280
+ source: "dailydev" as const,
281
+ id: post.id,
282
+ title: post.title ?? post.id ?? "Untitled daily.dev post",
283
+ url: post.url ?? post.commentsPermalink ?? (post.id ? `https://app.daily.dev/posts/${post.id}` : "https://app.daily.dev/"),
284
+ sourceUrl: post.commentsPermalink,
285
+ author: post.author?.name ?? post.source?.name ?? post.source?.handle,
286
+ score: post.numUpvotes,
287
+ comments: post.numComments,
288
+ publishedAt: post.publishedAt ?? post.createdAt,
289
+ summary: post.summary ?? post.tags?.slice(0, 5).map((tag) => `#${tag}`).join(" "),
290
+ }));
291
+ }
292
+
293
+ async function fetchDailyDevRestFeed(limit: number, token: string, signal?: AbortSignal): Promise<NewsEntry[]> {
294
+ const url = `${DAILY_DEV_BASE_URL}/feeds/popular?limit=${encodeURIComponent(String(limit))}`;
295
+ const response = await fetch(url, {
296
+ signal,
297
+ headers: {
298
+ authorization: `Bearer ${token}`,
299
+ "user-agent": "pi-news-feed/0.1 (+https://daily.dev)",
300
+ },
301
+ });
302
+ if (!response.ok) throw new Error(`daily.dev API ${response.status} for /feeds/popular`);
303
+
304
+ const feed = (await response.json()) as DailyDevFeedResponse;
305
+ return mapDailyDevPosts(feed.data ?? [], limit);
306
+ }
307
+
308
+ async function fetchDailyDevGraphqlFeed(limit: number, signal?: AbortSignal): Promise<NewsEntry[]> {
309
+ // Best-effort unauthenticated fallback. This is daily.dev's internal GraphQL API,
310
+ // not the supported Plus Public API, so the query may break if daily.dev changes it.
311
+ const query = `
312
+ query NewsFeedDailyDevFallback($pageSize: Int!, $latest: String!) {
313
+ latest(params: { pageSize: $pageSize, page: 0, sortBy: "popularity", latest: $latest }) {
314
+ id
315
+ title
316
+ url
317
+ createdAt
318
+ source { name handle }
319
+ numUpvotes
320
+ numComments
321
+ }
322
+ }
323
+ `;
324
+ const response = await fetch("https://api.daily.dev/graphql", {
325
+ method: "POST",
326
+ signal,
327
+ headers: {
328
+ "content-type": "application/json",
329
+ "user-agent": "pi-news-feed/0.1 (+https://daily.dev)",
330
+ },
331
+ body: JSON.stringify({
332
+ query,
333
+ variables: { pageSize: limit, latest: new Date().toISOString() },
334
+ }),
335
+ });
336
+ if (!response.ok) throw new Error(`daily.dev GraphQL ${response.status} for latest feed`);
337
+
338
+ const payload = (await response.json()) as DailyDevGraphqlResponse;
339
+ if (payload.errors?.length) {
340
+ throw new Error(`daily.dev GraphQL error: ${payload.errors.map((error) => error.message ?? "unknown").join("; ")}`);
341
+ }
342
+ return mapDailyDevPosts(payload.data?.latest ?? [], limit);
343
+ }
344
+
345
+ async function fetchDailyDevFeed(limit: number, signal?: AbortSignal): Promise<NewsEntry[]> {
346
+ const token = resolveEnvValue(DAILY_DEV_TOKEN_ENV).value;
347
+ if (token) return fetchDailyDevRestFeed(limit, token, signal);
348
+ return fetchDailyDevGraphqlFeed(limit, signal);
349
+ }
350
+
351
+ function sourceLabel(source: NewsEntry["source"]): string {
352
+ if (source === "hackernews") return "🟧 Hacker News";
353
+ if (source === "socket") return "🛡️ Socket.dev Blog";
354
+ if (source === "dailydev") return "🟦 daily.dev";
355
+ return source;
356
+ }
357
+
358
+ function formatDate(value: string | undefined): string | undefined {
359
+ if (!value) return undefined;
360
+ const date = new Date(value);
361
+ if (Number.isNaN(date.getTime())) return value;
362
+ return date.toLocaleString("en-GB", {
363
+ year: "numeric",
364
+ month: "short",
365
+ day: "2-digit",
366
+ hour: "2-digit",
367
+ minute: "2-digit",
368
+ timeZoneName: "short",
369
+ });
370
+ }
371
+
372
+ function truncateText(value: string | undefined, maxLength: number): string | undefined {
373
+ if (!value) return undefined;
374
+ const normalized = value.replace(/\s+/g, " ").trim();
375
+ if (normalized.length <= maxLength) return normalized;
376
+ return `${normalized.slice(0, Math.max(0, maxLength - 1)).trimEnd()}…`;
377
+ }
378
+
379
+ function formatEntry(entry: NewsEntry, index: number): string {
380
+ const badges = [
381
+ entry.feed ? `[${entry.feed}]` : undefined,
382
+ entry.score === undefined ? undefined : `▲ ${entry.score}`,
383
+ entry.comments === undefined ? undefined : `💬 ${entry.comments}`,
384
+ entry.author ? `👤 ${entry.author}` : undefined,
385
+ formatDate(entry.publishedAt) ? `🕒 ${formatDate(entry.publishedAt)}` : undefined,
386
+ ].filter(Boolean).join(" ");
387
+ const summary = truncateText(entry.summary, 220);
388
+ return [
389
+ ` ${String(index + 1).padStart(2, " ")}. ${entry.title}`,
390
+ badges ? ` ${badges}` : undefined,
391
+ ` 🔗 ${entry.url}`,
392
+ entry.sourceUrl && entry.sourceUrl !== entry.url ? ` 🧭 ${entry.sourceUrl}` : undefined,
393
+ summary ? ` 📝 ${summary}` : undefined,
394
+ ].filter(Boolean).join("\n");
395
+ }
396
+
397
+ function popularityRank(entry: NewsEntry): number {
398
+ // Prefer explicit popularity signals when the source exposes them.
399
+ // HN has score + comments; Socket's JSON feed currently has no popularity metric.
400
+ if (entry.score !== undefined || entry.comments !== undefined) {
401
+ return (entry.score ?? 0) * 1000 + (entry.comments ?? 0);
402
+ }
403
+ return Number.NEGATIVE_INFINITY;
404
+ }
405
+
406
+ function sortEntriesForDisplay(entries: NewsEntry[]): NewsEntry[] {
407
+ return [...entries].sort((a, b) => {
408
+ const popularityDelta = popularityRank(b) - popularityRank(a);
409
+ if (popularityDelta !== 0) return popularityDelta;
410
+ return Date.parse(b.publishedAt ?? "0") - Date.parse(a.publishedAt ?? "0");
411
+ });
412
+ }
413
+
414
+ function formatSection(sourceName: ConcreteNewsSource, entries: NewsEntry[]): string {
415
+ const sortedEntries = sortEntriesForDisplay(entries);
416
+ const label = sourceLabel(sourceName);
417
+ const headerText = `${label} (${entries.length})`;
418
+ const rule = "═".repeat(Math.max(24, headerText.length + 2));
419
+ return [`╔${rule}╗`, `║ ${headerText.padEnd(rule.length - 1)}║`, `╚${rule}╝`, "", ...sortedEntries.map(formatEntry)].join("\n");
420
+ }
421
+
422
+ function formatNews(source: NewsSource, entries: NewsEntry[]): string {
423
+ if (entries.length === 0) return `No ${source} news entries found.`;
424
+
425
+ const sourceOrder = source === "all" ? CONCRETE_NEWS_SOURCES : CONCRETE_NEWS_SOURCES.filter((sourceName) => sourceName === source);
426
+ const sections = sourceOrder.flatMap((sourceName) => {
427
+ const sourceEntries = entries.filter((entry) => entry.source === sourceName);
428
+ if (sourceEntries.length === 0) return [];
429
+ return [formatSection(sourceName, sourceEntries)];
430
+ });
431
+
432
+ const total = entries.length;
433
+ const title = source === "all" ? `🗞️ News Feed — ${total} stories across ${sections.length} sources` : `🗞️ News Feed — ${sourceLabel(source)} — ${total} stories`;
434
+ return `${title}\n\n${sections.join("\n\n")}`;
435
+ }
436
+
437
+ function getEnabledNewsSources(): ConcreteNewsSource[] {
438
+ return [...CONCRETE_NEWS_SOURCES];
439
+ }
440
+
441
+ async function fetchConcreteNewsFeed(source: ConcreteNewsSource, hnFeed: HnFeed, limit: number, signal?: AbortSignal): Promise<NewsEntry[]> {
442
+ if (source === "hackernews") return fetchHnFeed(hnFeed, limit, signal);
443
+ if (source === "socket") return fetchSocketFeed(limit, signal);
444
+ return fetchDailyDevFeed(limit, signal);
445
+ }
446
+
447
+ function perSourceLimit(totalLimit: number, sourceCount: number): number {
448
+ return Math.max(1, Math.floor(totalLimit / Math.max(1, sourceCount)));
449
+ }
450
+
451
+ async function fetchNewsFeed(source: NewsSource, hnFeed: HnFeed, limit: number, signal?: AbortSignal): Promise<NewsEntry[]> {
452
+ if (source !== "all") return fetchConcreteNewsFeed(source, hnFeed, limit, signal);
453
+
454
+ const enabledSources = getEnabledNewsSources();
455
+ const limitPerSource = perSourceLimit(limit, enabledSources.length);
456
+ const results = await Promise.all(
457
+ enabledSources.map((sourceName) => fetchConcreteNewsFeed(sourceName, hnFeed, limitPerSource, signal)),
458
+ );
459
+ return results.flat()
460
+ .sort((a, b) => Date.parse(b.publishedAt ?? "0") - Date.parse(a.publishedAt ?? "0"))
461
+ .slice(0, limit);
462
+ }
463
+
464
+ function renderStyledNews(details: NewsMessageDetails, theme: any): Box {
465
+ const sourceOrder = details.source === "all" ? CONCRETE_NEWS_SOURCES : CONCRETE_NEWS_SOURCES.filter((sourceName) => sourceName === details.source);
466
+ const lines: string[] = [];
467
+ const activeSections = sourceOrder.filter((sourceName) => details.entries.some((entry) => entry.source === sourceName));
468
+ lines.push(theme.fg("accent", theme.bold(`🗞️ News Feed — ${details.entries.length} stories across ${activeSections.length} source${activeSections.length === 1 ? "" : "s"}`)));
469
+ lines.push(theme.fg("dim", `Generated ${formatDate(new Date(details.generatedAt).toISOString()) ?? "now"}`));
470
+
471
+ for (const sourceName of sourceOrder) {
472
+ const sourceEntries = sortEntriesForDisplay(details.entries.filter((entry) => entry.source === sourceName));
473
+ if (sourceEntries.length === 0) continue;
474
+
475
+ lines.push("");
476
+ lines.push(theme.fg("accent", theme.bold(`━━ ${sourceLabel(sourceName)} (${sourceEntries.length}) ${"━".repeat(18)}`)));
477
+
478
+ sourceEntries.forEach((entry, index) => {
479
+ const badges = [
480
+ entry.feed ? theme.fg("dim", `[${entry.feed}]`) : undefined,
481
+ entry.score === undefined ? undefined : theme.fg("success", `▲ ${entry.score}`),
482
+ entry.comments === undefined ? undefined : theme.fg("warning", `💬 ${entry.comments}`),
483
+ entry.author ? theme.fg("dim", `👤 ${entry.author}`) : undefined,
484
+ formatDate(entry.publishedAt) ? theme.fg("dim", `🕒 ${formatDate(entry.publishedAt)}`) : undefined,
485
+ ].filter(Boolean).join(" ");
486
+ const summary = truncateText(entry.summary, 220);
487
+
488
+ lines.push(`${theme.fg("dim", String(index + 1).padStart(2, " ") + ".")} ${theme.bold(entry.title)}`);
489
+ if (badges) lines.push(` ${badges}`);
490
+ lines.push(` ${theme.fg("accent", "🔗")} ${entry.url}`);
491
+ if (entry.sourceUrl && entry.sourceUrl !== entry.url) lines.push(` ${theme.fg("dim", "🧭")} ${theme.fg("dim", entry.sourceUrl)}`);
492
+ if (summary) lines.push(` ${theme.fg("dim", "📝")} ${summary}`);
493
+ lines.push("");
494
+ });
495
+ }
496
+
497
+ const box = new Box(1, 1, (text: string) => theme.bg("customMessageBg", text));
498
+ box.addChild(new Text(lines.join("\n").trimEnd(), 0, 0));
499
+ return box;
500
+ }
501
+
502
+ const SETUP_PROVIDERS = [
503
+ {
504
+ label: "daily.dev API token",
505
+ envKey: DAILY_DEV_TOKEN_ENV,
506
+ url: "https://app.daily.dev/settings/api",
507
+ placeholder: "Paste your daily.dev Personal Access Token",
508
+ },
509
+ ] as const;
510
+
511
+ async function runNewsSetup(ctx: SetupUiContext): Promise<void> {
512
+ const choice = await ctx.ui.select("News setup", SETUP_PROVIDERS.map((provider) => provider.label));
513
+ const provider = SETUP_PROVIDERS.find((candidate) => candidate.label === choice);
514
+ if (!provider) {
515
+ ctx.ui.notify("News setup cancelled.", "warning");
516
+ return;
517
+ }
518
+
519
+ const existing = resolveEnvValue(provider.envKey);
520
+ const action = existing.value
521
+ ? await ctx.ui.select(`${provider.label} is already configured via ${existing.source}.`, ["Replace token", "Show setup URL", "Cancel"])
522
+ : "Replace token";
523
+
524
+ if (action === "Show setup URL") {
525
+ ctx.ui.notify(`Create/manage token here: ${provider.url}`, "info");
526
+ return;
527
+ }
528
+ if (action !== "Replace token") {
529
+ ctx.ui.notify("News setup cancelled.", "warning");
530
+ return;
531
+ }
532
+
533
+ const token = (await ctx.ui.input(provider.label, provider.placeholder))?.trim();
534
+ if (!token) {
535
+ ctx.ui.notify("News setup cancelled: no token entered.", "warning");
536
+ return;
537
+ }
538
+
539
+ const filePath = getGlobalEnvPath();
540
+ upsertEnvValue(filePath, provider.envKey, token);
541
+ process.env[provider.envKey] = token;
542
+ ctx.ui.notify(`${provider.label} saved to ${filePath}`, "success");
543
+ }
544
+
545
+ export default function newsFeedExtension(pi: ExtensionAPI) {
546
+ pi.registerMessageRenderer(NEWS_MESSAGE_TYPE, (message, _options, theme) => {
547
+ const details = message.details as NewsMessageDetails | undefined;
548
+ if (!details) {
549
+ const box = new Box(1, 1, (text: string) => theme.bg("customMessageBg", text));
550
+ box.addChild(new Text(String(message.content ?? "No news details available."), 0, 0));
551
+ return box;
552
+ }
553
+ return renderStyledNews(details, theme);
554
+ });
555
+
556
+ pi.registerTool({
557
+
558
+ name: "news_feed",
559
+ label: "News Feed",
560
+ description: "Fetch news entries from Hacker News, Socket.dev Blog, and configured authenticated sources like daily.dev.",
561
+ promptSnippet: "Fetch news from Hacker News feeds, Socket.dev blog JSON feed, and configured authenticated sources like daily.dev.",
562
+ promptGuidelines: ["Use news_feed when the user asks for current Hacker News, Socket.dev, security, supply-chain, or general news feed entries."],
563
+ parameters: NEWS_FEED_PARAMS,
564
+ async execute(_toolCallId, params, signal) {
565
+ const source = (params.source ?? "all") as NewsSource;
566
+ const feed = (params.feed ?? "top") as HnFeed;
567
+ const limit = clampInteger(params.limit, DEFAULT_LIMIT, 1, MAX_LIMIT);
568
+ const entries = await fetchNewsFeed(source, feed, limit, signal);
569
+ return {
570
+ content: [{ type: "text", text: formatNews(source, entries) }],
571
+ details: { source, feed, limit, entries },
572
+ };
573
+ },
574
+ });
575
+
576
+ pi.registerCommand("news-setup", {
577
+ description: "Configure optional news sources such as daily.dev tokens.",
578
+ handler: async (_args, ctx) => {
579
+ await runNewsSetup(ctx);
580
+ },
581
+ });
582
+
583
+ pi.registerCommand("news", {
584
+ description: "Fetch news: /news [hackernews|socket|dailydev|all] [limit] [top|new|best|ask|show|job]",
585
+ handler: async (args, ctx) => {
586
+ const tokens = args.trim().split(/\s+/).filter(Boolean);
587
+ const source = NEWS_SOURCES.includes(tokens[0] as NewsSource) ? (tokens.shift() as NewsSource) : "all";
588
+ const limitTokenIndex = tokens.findIndex((token) => /^\d+$/.test(token));
589
+ const limitInput = limitTokenIndex >= 0 ? Number(tokens.splice(limitTokenIndex, 1)[0]) : undefined;
590
+ const feed = HN_FEEDS.includes(tokens[0] as HnFeed) ? (tokens[0] as HnFeed) : "top";
591
+ const limit = clampInteger(limitInput, DEFAULT_LIMIT, 1, MAX_LIMIT);
592
+
593
+ try {
594
+ const entries = await fetchNewsFeed(source, feed, limit);
595
+ pi.sendMessage({
596
+ customType: NEWS_MESSAGE_TYPE,
597
+ content: formatNews(source, entries),
598
+ display: true,
599
+ details: { source, entries, generatedAt: Date.now() } satisfies NewsMessageDetails,
600
+ });
601
+ } catch (error) {
602
+ const message = error instanceof Error ? error.message : String(error);
603
+ ctx.ui.notify(`Failed to fetch news: ${message}`, "error");
604
+ }
605
+ },
606
+ });
607
+
608
+ }
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "@firstpick/pi-extension-hacker-news",
3
+ "version": "0.1.0",
4
+ "description": "Hacker News tools and commands for Pi using the official Firebase API.",
5
+ "license": "MIT",
6
+ "keywords": [
7
+ "pi-package",
8
+ "pi",
9
+ "pi-coding-agent",
10
+ "extension",
11
+ "hacker-news"
12
+ ],
13
+ "pi": {
14
+ "extensions": [
15
+ "./index.ts"
16
+ ]
17
+ },
18
+ "peerDependencies": {
19
+ "@earendil-works/pi-coding-agent": "*",
20
+ "typebox": "*"
21
+ },
22
+ "files": [
23
+ "index.ts",
24
+ "README.md",
25
+ "LICENSE"
26
+ ]
27
+ }