@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.
- package/LICENSE +21 -0
- package/README.md +52 -0
- package/index.ts +608 -0
- 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(/'/g, "'")
|
|
236
|
+
.replace(/"/g, '"')
|
|
237
|
+
.replace(/>/g, ">")
|
|
238
|
+
.replace(/</g, "<")
|
|
239
|
+
.replace(/&/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
|
+
}
|