@oddessentials/agent-guild 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -7
- package/config/providers.default.json +10 -0
- package/package.json +1 -1
- package/src/manager/config.mjs +7 -1
- package/src/manager/main.mjs +20 -7
- package/src/manager/news.mjs +470 -0
- package/src/manager/providers.mjs +89 -5
- package/src/manager/self-update.mjs +205 -0
- package/src/manager/server.mjs +15 -1
- package/src/manager/session-manager.mjs +58 -7
- package/src/manager/session.mjs +4 -1
- package/src/manager/usage.mjs +106 -25
- package/web/app.js +489 -12
- package/web/art/art.css +28 -6
- package/web/index.html +31 -0
- package/web/styles.css +87 -19
- package/web/theme.js +10 -0
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import { EventEmitter } from 'node:events';
|
|
3
|
+
import { VERSION } from './config.mjs';
|
|
4
|
+
|
|
5
|
+
const TTL_MS = 30 * 60 * 1000;
|
|
6
|
+
const RETRY_MS = 10 * 60 * 1000;
|
|
7
|
+
const RELEASE_TTL_MS = 60 * 60 * 1000;
|
|
8
|
+
const FETCH_TIMEOUT_MS = 20000;
|
|
9
|
+
const MAX_BYTES = 5 * 1024 * 1024;
|
|
10
|
+
const CONCURRENCY = 4;
|
|
11
|
+
const WINDOW_MS = 30 * 24 * 60 * 60 * 1000;
|
|
12
|
+
const FUTURE_MS = 2 * 24 * 60 * 60 * 1000;
|
|
13
|
+
const LIMITS = { news: 20, releases: 5, research: 10 };
|
|
14
|
+
const TITLE_MAX = 300;
|
|
15
|
+
const SUMMARY_MAX = 240;
|
|
16
|
+
const RAW_MAX = 8000;
|
|
17
|
+
const USER_AGENT = `agent-guild/${VERSION} (+https://github.com/oddessentials/agent-guild)`;
|
|
18
|
+
const FEED_ACCEPT = 'application/rss+xml, application/atom+xml, application/xml;q=0.9, text/xml;q=0.8, */*;q=0.5';
|
|
19
|
+
|
|
20
|
+
const latestRelease = (id, name, repo, provider) => ({
|
|
21
|
+
id, name, category: 'releases', provider, format: 'github', every: RELEASE_TTL_MS,
|
|
22
|
+
url: `https://api.github.com/repos/${repo}/releases/latest`,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
export const FEEDS = [
|
|
26
|
+
{ id: 'openai', name: 'OpenAI', category: 'news', url: 'https://openai.com/news/rss.xml' },
|
|
27
|
+
{ id: 'google-deepmind', name: 'Google DeepMind', category: 'news', url: 'https://deepmind.google/blog/rss.xml' },
|
|
28
|
+
{ id: 'google-ai', name: 'Google AI', category: 'news', url: 'https://blog.google/innovation-and-ai/technology/ai/rss/' },
|
|
29
|
+
{ id: 'hugging-face', name: 'Hugging Face', category: 'news', url: 'https://huggingface.co/blog/feed.xml' },
|
|
30
|
+
{ id: 'ollama-blog', name: 'Ollama', category: 'news', url: 'https://ollama.com/blog/rss.xml' },
|
|
31
|
+
latestRelease('claude-code', 'Claude Code', 'anthropics/claude-code', 'anthropic'),
|
|
32
|
+
latestRelease('codex-cli', 'Codex CLI', 'openai/codex', 'openai'),
|
|
33
|
+
latestRelease('gemini-cli', 'Gemini CLI', 'google-gemini/gemini-cli', 'google'),
|
|
34
|
+
{ id: 'ollama', name: 'Ollama', category: 'releases', url: 'https://github.com/ollama/ollama/releases.atom' },
|
|
35
|
+
{ id: 'llama-cpp', name: 'llama.cpp', category: 'releases', url: 'https://github.com/ggml-org/llama.cpp/releases.atom' },
|
|
36
|
+
{ id: 'transformers', name: 'Transformers', category: 'releases', url: 'https://github.com/huggingface/transformers/releases.atom' },
|
|
37
|
+
{ id: 'arxiv-cs-cl', name: 'arXiv cs.CL', category: 'research', filter: true, url: 'https://export.arxiv.org/rss/cs.CL' },
|
|
38
|
+
{ id: 'arxiv-cs-ai', name: 'arXiv cs.AI', category: 'research', filter: true, url: 'https://export.arxiv.org/rss/cs.AI' },
|
|
39
|
+
{ id: 'simon-willison', name: 'Simon Willison\'s Weblog', category: 'news', url: 'https://simonwillison.net/atom/everything/' },
|
|
40
|
+
{ id: 'interconnects', name: 'Interconnects', category: 'news', url: 'https://www.interconnects.ai/feed' },
|
|
41
|
+
{ id: 'lil-log', name: 'Lil\'Log', category: 'news', url: 'https://lilianweng.github.io/index.xml' },
|
|
42
|
+
{
|
|
43
|
+
id: 'hacker-news', name: 'Hacker News', category: 'news', format: 'hn', filter: true,
|
|
44
|
+
url: 'https://hn.algolia.com/api/v1/search_by_date?tags=story&numericFilters=points%3E%3D50&hitsPerPage=100&attributesToRetrieve=title,url,created_at,points,num_comments,objectID&attributesToHighlight=none',
|
|
45
|
+
},
|
|
46
|
+
{ id: 'llm-digest', name: 'LLM Digest', category: 'news', url: 'https://www.llm-digest.com/rss.xml' },
|
|
47
|
+
{ id: 'slashdot', name: 'Slashdot', category: 'news', filter: true, url: 'https://rss.slashdot.org/Slashdot/slashdotMain' },
|
|
48
|
+
{ id: 'slashdot-developers', name: 'Slashdot', category: 'news', filter: true, url: 'https://rss.slashdot.org/Slashdot/slashdotDevelopers' },
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
const TERMS = /\bagents?\b|\bagentic\b|\bmulti[-\s]?agents?\b|\btool[-\s]?use\b|\bmcp\b|\bollama\b|\bllama\.cpp\b|\bllamacpp\b|\bggml\b|\bgguf\b|\bopen[-\s]?weights?\b|\blocal[-\s]+models?\b|\bvllm\b|\bsglang\b|\bquanti[sz]\w*\b/i;
|
|
52
|
+
const TRACKING = new Set(['fbclid', 'gclid', 'mc_cid', 'mc_eid', 'igshid', 'ref_src']);
|
|
53
|
+
const ARXIV_PATH = /^\/(?:abs|pdf|html)\/([a-z-]+\/\d{7}|\d{4}\.\d{4,5})(?:v\d+)?(?:\.pdf)?$/i;
|
|
54
|
+
const PRERELEASE = /(?:^|[^a-z])(?:alpha|beta|rc|nightly|preview|canary|dev|pre)(?![a-z])/i;
|
|
55
|
+
const ARXIV_PREFIX = /^arXiv:\S+\s+Announce Type:\s*[\w-]+\s+Abstract:\s*/i;
|
|
56
|
+
const ENTRY = /<((?:[\w-]+:)?(?:item|entry))\b[^>]*>([\s\S]*?)<\/\1\s*>/gi;
|
|
57
|
+
const DATE_FIELDS = ['pubDate', 'published', 'dc:date', 'updated', 'issued', 'modified'];
|
|
58
|
+
const NAMED = {
|
|
59
|
+
amp: '&', lt: '<', gt: '>', quot: '"', apos: '\'', nbsp: ' ', ensp: ' ', emsp: ' ', thinsp: ' ', shy: '', zwj: '', zwnj: '',
|
|
60
|
+
ndash: '–', mdash: '—', hellip: '…', lsquo: '‘', rsquo: '’', sbquo: '‚', ldquo: '“', rdquo: '”', bdquo: '„', laquo: '«', raquo: '»',
|
|
61
|
+
bull: '•', middot: '·', prime: '′', Prime: '″', copy: '©', reg: '®', trade: '™', deg: '°', plusmn: '±', times: '×', divide: '÷',
|
|
62
|
+
euro: '€', pound: '£', cent: '¢', yen: '¥', sect: '§', para: '¶', larr: '←', rarr: '→', uarr: '↑', darr: '↓', harr: '↔',
|
|
63
|
+
aacute: 'á', agrave: 'à', acirc: 'â', auml: 'ä', eacute: 'é', egrave: 'è', ecirc: 'ê', iacute: 'í', oacute: 'ó', ouml: 'ö',
|
|
64
|
+
uacute: 'ú', uuml: 'ü', ccedil: 'ç', ntilde: 'ñ', szlig: 'ß', Auml: 'Ä', Ouml: 'Ö', Uuml: 'Ü',
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
class FeedError extends Error {}
|
|
68
|
+
|
|
69
|
+
const fields = new Map();
|
|
70
|
+
const attrs = new Map();
|
|
71
|
+
|
|
72
|
+
export function matchesTerms(title, text = '') {
|
|
73
|
+
return TERMS.test(`${title}\n${text}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function decodeEntities(text) {
|
|
77
|
+
return text.replace(/&(?:#(\d{1,7})|#x([0-9a-f]{1,6})|([a-z][a-z0-9]{1,31}));/gi, (match, dec, hex, name) => {
|
|
78
|
+
if (name) return Object.hasOwn(NAMED, name) ? NAMED[name] : match;
|
|
79
|
+
const code = dec ? Number(dec) : parseInt(hex, 16);
|
|
80
|
+
return code > 0 && code <= 0x10ffff && (code < 0xd800 || code > 0xdfff) ? String.fromCodePoint(code) : '�';
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function tidy(text) {
|
|
85
|
+
return text.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '').replace(/\s+/g, ' ').trim();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function stripTags(html) {
|
|
89
|
+
return html
|
|
90
|
+
.replace(/<(script|style|template)\b[\s\S]*?(?:<\/\1\s*>|$)/gi, ' ')
|
|
91
|
+
.replace(/<\/?(?:p|div|br|li|ul|ol|h[1-6]|tr|td|th|table|blockquote|pre|section|article|header|footer|figure|figcaption|hr)\b[^>]*>/gi, ' ')
|
|
92
|
+
.replace(/<[^>]*>/g, '')
|
|
93
|
+
.replace(/<[^>]*$/, '');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function clip(text, max) {
|
|
97
|
+
if (text.length <= max) return text;
|
|
98
|
+
let cut = text.slice(0, max);
|
|
99
|
+
if (/[\ud800-\udbff]$/.test(cut)) cut = cut.slice(0, -1);
|
|
100
|
+
const space = cut.lastIndexOf(' ');
|
|
101
|
+
if (space >= max * 0.6) cut = cut.slice(0, space);
|
|
102
|
+
return `${cut.replace(/[\s.,;:–—-]+$/, '')}…`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function protect(xml) {
|
|
106
|
+
const blocks = [];
|
|
107
|
+
const text = xml
|
|
108
|
+
.replace(/\u0000/g, '')
|
|
109
|
+
.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, (_, inner) => `\u0000${blocks.push(inner) - 1}\u0000`)
|
|
110
|
+
.replace(/<!--[\s\S]*?-->/g, '');
|
|
111
|
+
return { text, blocks };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function xmlText(inner, blocks) {
|
|
115
|
+
return inner.split(/\u0000(\d+)\u0000/).map((part, i) => (i % 2 ? blocks[Number(part)] ?? '' : decodeEntities(part))).join('');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function field(raw, name) {
|
|
119
|
+
let re = fields.get(name);
|
|
120
|
+
if (!re) fields.set(name, (re = new RegExp(`<${name}(\\s[^>]*)?(?:/>|>([\\s\\S]*?)</${name}\\s*>)`, 'i')));
|
|
121
|
+
const match = re.exec(raw);
|
|
122
|
+
return match ? { attrs: match[1] ?? '', inner: match[2] ?? '' } : null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function attr(source, name) {
|
|
126
|
+
let re = attrs.get(name);
|
|
127
|
+
if (!re) attrs.set(name, (re = new RegExp(`(?:^|\\s)${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`, 'i')));
|
|
128
|
+
const match = re.exec(source);
|
|
129
|
+
return match ? decodeEntities(match[1] ?? match[2]) : null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function textOf(found, blocks, { html, max = RAW_MAX }) {
|
|
133
|
+
const type = attr(found.attrs, 'type');
|
|
134
|
+
const source = xmlText(found.inner.slice(0, max), blocks).slice(0, max);
|
|
135
|
+
return tidy(decodeEntities((type ? /html/i.test(type) : html) ? stripTags(source) : source));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function webUrl(value, base) {
|
|
139
|
+
try {
|
|
140
|
+
const url = new URL(String(value).trim(), base);
|
|
141
|
+
if ((url.protocol !== 'https:' && url.protocol !== 'http:') || url.username || url.password || url.href.length > 2000) return null;
|
|
142
|
+
return url.href;
|
|
143
|
+
} catch {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function entryLink(raw, blocks, base) {
|
|
149
|
+
const links = [...raw.matchAll(/<link\b([^>]*)>/gi)]
|
|
150
|
+
.map(([, found]) => ({ href: attr(found, 'href'), rel: attr(found, 'rel') ?? 'alternate', type: attr(found, 'type') }))
|
|
151
|
+
.filter((link) => link.href);
|
|
152
|
+
const alternate = links.find((link) => link.rel === 'alternate' && (!link.type || /html/i.test(link.type))) ?? links.find((link) => link.rel === 'alternate');
|
|
153
|
+
if (alternate) return webUrl(alternate.href, base);
|
|
154
|
+
const text = field(raw, 'link');
|
|
155
|
+
if (text && text.inner.trim()) return webUrl(tidy(xmlText(text.inner, blocks)), base);
|
|
156
|
+
const guid = field(raw, 'guid');
|
|
157
|
+
return guid && attr(guid.attrs, 'isPermaLink') !== 'false' ? webUrl(tidy(xmlText(guid.inner, blocks)), base) : null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function entryDate(raw, blocks) {
|
|
161
|
+
for (const name of DATE_FIELDS) {
|
|
162
|
+
const found = field(raw, name);
|
|
163
|
+
if (!found) continue;
|
|
164
|
+
const time = Date.parse(tidy(xmlText(found.inner, blocks)));
|
|
165
|
+
if (Number.isFinite(time)) return time;
|
|
166
|
+
}
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function feedEntries(xml) {
|
|
171
|
+
const { text, blocks } = protect(xml);
|
|
172
|
+
if (!/<(?:[\w-]+:)?(?:rss|feed|RDF)\b/i.test(text)) throw new FeedError('did not send a feed');
|
|
173
|
+
return [...text.matchAll(ENTRY)].map(([, , raw]) => ({ raw, blocks, date: entryDate(raw, blocks) }));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function readEntry({ raw, blocks }, base) {
|
|
177
|
+
const title = field(raw, 'title');
|
|
178
|
+
const body = [['summary', false], ['description', true], ['content:encoded', true], ['content', false]]
|
|
179
|
+
.map(([name, html]) => [field(raw, name), html])
|
|
180
|
+
.find(([found]) => found);
|
|
181
|
+
const announce = field(raw, 'arxiv:announce_type');
|
|
182
|
+
return {
|
|
183
|
+
title: title ? clip(textOf(title, blocks, { html: false, max: Infinity }), TITLE_MAX) : '',
|
|
184
|
+
link: entryLink(raw, blocks, base),
|
|
185
|
+
text: body ? textOf(body[0], blocks, { html: body[1] }).replace(ARXIV_PREFIX, '') : '',
|
|
186
|
+
announce: announce ? tidy(xmlText(announce.inner, blocks)) : null,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function parseFeed(xml, base) {
|
|
191
|
+
return feedEntries(xml).map((entry) => ({ ...readEntry(entry, base), date: entry.date }));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function count(value, word) {
|
|
195
|
+
return Number.isInteger(value) && value >= 0 ? `${value} ${word}${value === 1 ? '' : 's'}` : null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function parseHackerNews(text) {
|
|
199
|
+
const hits = JSON.parse(text)?.hits;
|
|
200
|
+
if (!Array.isArray(hits)) throw new FeedError('sent no stories');
|
|
201
|
+
return hits.filter((hit) => hit && typeof hit === 'object').map((hit) => {
|
|
202
|
+
const id = String(hit.objectID ?? '');
|
|
203
|
+
const thread = /^\d+$/.test(id) ? `https://news.ycombinator.com/item?id=${id}` : null;
|
|
204
|
+
const link = (typeof hit.url === 'string' && webUrl(hit.url)) || thread;
|
|
205
|
+
return {
|
|
206
|
+
title: clip(tidy(decodeEntities(String(hit.title ?? ''))), TITLE_MAX),
|
|
207
|
+
link,
|
|
208
|
+
text: [count(hit.points, 'point'), count(hit.num_comments, 'comment')].filter(Boolean).join(' · '),
|
|
209
|
+
discussion: link === thread ? null : thread,
|
|
210
|
+
date: Date.parse(hit.created_at),
|
|
211
|
+
};
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function markdownText(markdown) {
|
|
216
|
+
return String(markdown)
|
|
217
|
+
.replace(/```[\s\S]*?(?:```|$)/g, ' ')
|
|
218
|
+
.replace(/!\[[^\]]*\]\([^)]*\)/g, ' ')
|
|
219
|
+
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
|
|
220
|
+
.replace(/<[^>]*>/g, ' ')
|
|
221
|
+
.split(/\r?\n/)
|
|
222
|
+
.filter((line) => !/^\s*(?:#{1,6}\s|\*\*full changelog\b|full changelog\b)/i.test(line))
|
|
223
|
+
.map((line) => tidy(decodeEntities(line
|
|
224
|
+
.replace(/^\s*(?:[-*+]\s+|\d+\.\s+|>\s*)/, '')
|
|
225
|
+
.replace(/^#\d+\s+|\s*\(#\d+\)/g, '')
|
|
226
|
+
.replace(/\s+by @[\w-]+(?:\s+in\s+\S+)?/g, '')
|
|
227
|
+
.replace(/https?:\/\/\S+/g, '')
|
|
228
|
+
.replace(/[*_`~]+/g, ''))))
|
|
229
|
+
.filter(Boolean)
|
|
230
|
+
.join(' · ');
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function releaseTitle(name, title) {
|
|
234
|
+
const label = title.replace(/^(?:patch\s+)?release\b:?\s*/i, '').replace(/^rust-(?=v?\d)/i, '').trim() || title;
|
|
235
|
+
return label.toLowerCase().startsWith(name.toLowerCase()) ? label : `${name} ${label}`;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function parseGithubRelease(text, name) {
|
|
239
|
+
const release = JSON.parse(text);
|
|
240
|
+
if (!release || typeof release !== 'object' || typeof release.tag_name !== 'string') throw new FeedError('sent no release');
|
|
241
|
+
if (release.draft || release.prerelease) return [];
|
|
242
|
+
return [{
|
|
243
|
+
title: releaseTitle(name, tidy(String(release.name || release.tag_name))),
|
|
244
|
+
link: webUrl(release.html_url ?? ''),
|
|
245
|
+
text: markdownText(release.body ?? ''),
|
|
246
|
+
date: Date.parse(release.published_at),
|
|
247
|
+
}];
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function releaseTag(link) {
|
|
251
|
+
const tag = /^https?:\/\/(?:www\.)?github\.com\/[^/]+\/[^/]+\/releases\/tag\/([^/?#]+)/i.exec(link)?.[1];
|
|
252
|
+
if (!tag) return null;
|
|
253
|
+
try {
|
|
254
|
+
return decodeURIComponent(tag);
|
|
255
|
+
} catch {
|
|
256
|
+
return tag;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function isPrerelease(tag) {
|
|
261
|
+
return PRERELEASE.test(tag);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const tracking = (key) => /^utm_/i.test(key) || TRACKING.has(key.toLowerCase());
|
|
265
|
+
|
|
266
|
+
export function cleanUrl(link) {
|
|
267
|
+
const url = new URL(link);
|
|
268
|
+
const keys = [...url.searchParams.keys()].filter(tracking);
|
|
269
|
+
if (keys.length === 0) return link;
|
|
270
|
+
for (const key of keys) url.searchParams.delete(key);
|
|
271
|
+
return url.href;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export function canonicalUrl(link) {
|
|
275
|
+
const url = new URL(link);
|
|
276
|
+
const host = url.hostname.toLowerCase().replace(/^www\./, '');
|
|
277
|
+
const port = url.port && url.port !== '80' && url.port !== '443' ? `:${url.port}` : '';
|
|
278
|
+
let path = url.pathname || '/';
|
|
279
|
+
if (path.length > 1 && path.endsWith('/')) path = path.slice(0, -1);
|
|
280
|
+
const arxiv = host.endsWith('arxiv.org') ? ARXIV_PATH.exec(path) : null;
|
|
281
|
+
if (arxiv) path = `/abs/${arxiv[1]}`;
|
|
282
|
+
const query = [...url.searchParams]
|
|
283
|
+
.filter(([key]) => !tracking(key))
|
|
284
|
+
.sort(([a, x], [b, y]) => (a < b ? -1 : a > b ? 1 : x < y ? -1 : x > y ? 1 : 0));
|
|
285
|
+
return `${host}${port}${path}${query.length ? `?${new URLSearchParams(query)}` : ''}`;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function summaryFor(text, title) {
|
|
289
|
+
const rest = text.toLowerCase().startsWith(title.toLowerCase()) ? text.slice(title.length).replace(/^[\s.,;:–—-]+/, '') : text;
|
|
290
|
+
return clip(rest, SUMMARY_MAX);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function select(feed, entries, now) {
|
|
294
|
+
const fresh = entries
|
|
295
|
+
.filter((entry) => Number.isFinite(entry.date) && entry.date - now <= FUTURE_MS && now - entry.date <= WINDOW_MS)
|
|
296
|
+
.sort((a, b) => b.date - a.date);
|
|
297
|
+
const items = [];
|
|
298
|
+
for (const entry of fresh) {
|
|
299
|
+
if (items.length >= LIMITS[feed.category]) break;
|
|
300
|
+
const found = entry.raw === undefined ? entry : { ...readEntry(entry, feed.url), date: entry.date };
|
|
301
|
+
if (!found.title || !found.link) continue;
|
|
302
|
+
if (found.announce && /^replace/i.test(found.announce)) continue;
|
|
303
|
+
const release = feed.category === 'releases' && feed.format !== 'github';
|
|
304
|
+
const tag = releaseTag(found.link) ?? (release ? found.title.split(' ')[0] : null);
|
|
305
|
+
if (tag && isPrerelease(tag)) continue;
|
|
306
|
+
if (feed.filter && !matchesTerms(found.title, found.text)) continue;
|
|
307
|
+
const key = canonicalUrl(found.link);
|
|
308
|
+
items.push({
|
|
309
|
+
key,
|
|
310
|
+
id: crypto.createHash('sha256').update(key).digest('hex').slice(0, 16),
|
|
311
|
+
title: release ? releaseTitle(feed.name, found.title) : found.title,
|
|
312
|
+
url: cleanUrl(found.link),
|
|
313
|
+
discussion: found.discussion ?? null,
|
|
314
|
+
summary: summaryFor(found.text, found.title),
|
|
315
|
+
date: found.date,
|
|
316
|
+
time: Math.min(found.date, now),
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
return items;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function merge(previous, fresh, limit, now) {
|
|
323
|
+
const byKey = new Map(previous.filter((item) => now - item.time <= WINDOW_MS).map((item) => [item.key, item]));
|
|
324
|
+
for (const item of fresh) {
|
|
325
|
+
const stored = byKey.get(item.key);
|
|
326
|
+
if (stored?.date === item.date) item.time = stored.time;
|
|
327
|
+
byKey.set(item.key, item);
|
|
328
|
+
}
|
|
329
|
+
return [...byKey.values()].sort((a, b) => b.time - a.time).slice(0, limit);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async function readBody(res, maxBytes) {
|
|
333
|
+
const tooLarge = () => new FeedError(`sent more than ${maxBytes / 1024 / 1024} MB`);
|
|
334
|
+
if (Number(res.headers.get('content-length')) > maxBytes) throw tooLarge();
|
|
335
|
+
const chunks = [];
|
|
336
|
+
let size = 0;
|
|
337
|
+
for await (const chunk of res.body ?? []) {
|
|
338
|
+
size += chunk.byteLength;
|
|
339
|
+
if (size > maxBytes) throw tooLarge();
|
|
340
|
+
chunks.push(chunk);
|
|
341
|
+
}
|
|
342
|
+
const bytes = Buffer.concat(chunks);
|
|
343
|
+
const charset = /charset\s*=\s*["']?([\w.:-]+)/i.exec(res.headers.get('content-type') ?? '')?.[1]
|
|
344
|
+
?? /^\s*<\?xml[^>]*encoding\s*=\s*["']([\w.:-]+)/i.exec(bytes.subarray(0, 256).toString('latin1'))?.[1];
|
|
345
|
+
try {
|
|
346
|
+
return new TextDecoder(charset ?? 'utf-8').decode(bytes);
|
|
347
|
+
} catch {
|
|
348
|
+
return new TextDecoder().decode(bytes);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function refusal(res) {
|
|
353
|
+
if ((res.status === 403 || res.status === 429) && res.headers.get('x-ratelimit-remaining') === '0') return 'GitHub API rate limit exceeded';
|
|
354
|
+
return `HTTP ${res.status}`;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function failure(err, timeoutMs) {
|
|
358
|
+
if (err instanceof FeedError) return err.message;
|
|
359
|
+
if (err?.name === 'TimeoutError') return `did not answer within ${timeoutMs / 1000} seconds`;
|
|
360
|
+
if (err instanceof SyntaxError) return 'sent data that could not be read';
|
|
361
|
+
return `could not be reached (${err?.cause?.code || err?.message || 'unknown error'})`;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const iso = (time) => (time ? new Date(time).toISOString() : null);
|
|
365
|
+
|
|
366
|
+
export class NewsFeed extends EventEmitter {
|
|
367
|
+
constructor({
|
|
368
|
+
registry = null, feeds = FEEDS, fetchImpl = fetch, ttlMs = TTL_MS, retryMs = RETRY_MS, timeoutMs = FETCH_TIMEOUT_MS, maxBytes = MAX_BYTES,
|
|
369
|
+
} = {}) {
|
|
370
|
+
super();
|
|
371
|
+
this.registry = registry;
|
|
372
|
+
this.fetchImpl = fetchImpl;
|
|
373
|
+
this.ttlMs = ttlMs;
|
|
374
|
+
this.retryMs = retryMs;
|
|
375
|
+
this.timeoutMs = timeoutMs;
|
|
376
|
+
this.maxBytes = maxBytes;
|
|
377
|
+
this.states = feeds.map((feed) => ({ feed, items: [], etag: null, lastModified: null, checkedAt: 0, okAt: 0, error: null }));
|
|
378
|
+
this.refreshedAt = 0;
|
|
379
|
+
this.refreshing = null;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
snapshot() {
|
|
383
|
+
const active = this.states.filter((state) => !state.feed.provider || this._installed(state.feed.provider));
|
|
384
|
+
this._refreshDue(active);
|
|
385
|
+
return this._describe(active);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
_installed(id) {
|
|
389
|
+
const provider = this.registry?.providers?.find((p) => p.id === id);
|
|
390
|
+
try {
|
|
391
|
+
return Boolean(provider && this.registry.resolve(provider));
|
|
392
|
+
} catch {
|
|
393
|
+
return false;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
_refreshDue(active) {
|
|
398
|
+
if (this.refreshing) return;
|
|
399
|
+
const now = Date.now();
|
|
400
|
+
const due = active.filter((state) => now - state.checkedAt >= (state.error ? this.retryMs : state.feed.every ?? this.ttlMs));
|
|
401
|
+
if (due.length === 0) return;
|
|
402
|
+
this.refreshing = this._refresh(due).finally(() => {
|
|
403
|
+
this.refreshing = null;
|
|
404
|
+
this.emit('updated');
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
async _refresh(states) {
|
|
409
|
+
const queue = [...states];
|
|
410
|
+
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, queue.length) }, async () => {
|
|
411
|
+
while (queue.length) await this._update(queue.shift());
|
|
412
|
+
}));
|
|
413
|
+
this.refreshedAt = Date.now();
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
async _update(state) {
|
|
417
|
+
const { feed } = state;
|
|
418
|
+
state.checkedAt = Date.now();
|
|
419
|
+
try {
|
|
420
|
+
const headers = { 'User-Agent': USER_AGENT, Accept: feed.format === 'github' ? 'application/vnd.github+json' : feed.format === 'hn' ? 'application/json' : FEED_ACCEPT };
|
|
421
|
+
if (feed.format === 'github') headers['X-GitHub-Api-Version'] = '2022-11-28';
|
|
422
|
+
if (state.etag) headers['If-None-Match'] = state.etag;
|
|
423
|
+
if (state.lastModified) headers['If-Modified-Since'] = state.lastModified;
|
|
424
|
+
const res = await this.fetchImpl(feed.url, { headers, signal: AbortSignal.timeout(this.timeoutMs) });
|
|
425
|
+
if (res.status !== 304) {
|
|
426
|
+
if (!res.ok) throw new FeedError(refusal(res));
|
|
427
|
+
const text = await readBody(res, this.maxBytes);
|
|
428
|
+
const entries = feed.format === 'hn' ? parseHackerNews(text) : feed.format === 'github' ? parseGithubRelease(text, feed.name) : feedEntries(text);
|
|
429
|
+
const now = Date.now();
|
|
430
|
+
state.items = merge(state.items, select(feed, entries, now), LIMITS[feed.category], now);
|
|
431
|
+
state.etag = res.headers.get('etag');
|
|
432
|
+
state.lastModified = res.headers.get('last-modified');
|
|
433
|
+
}
|
|
434
|
+
state.error = null;
|
|
435
|
+
state.okAt = Date.now();
|
|
436
|
+
} catch (err) {
|
|
437
|
+
state.error = failure(err, this.timeoutMs);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
_describe(active) {
|
|
442
|
+
const now = Date.now();
|
|
443
|
+
const taken = new Set();
|
|
444
|
+
const items = [];
|
|
445
|
+
for (const { feed, items: own } of active) {
|
|
446
|
+
for (const item of own) {
|
|
447
|
+
if (now - item.time > WINDOW_MS || taken.has(item.key)) continue;
|
|
448
|
+
taken.add(item.key);
|
|
449
|
+
items.push({ feed, item });
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
items.sort((a, b) => b.item.time - a.item.time);
|
|
453
|
+
return {
|
|
454
|
+
refreshedAt: iso(this.refreshedAt),
|
|
455
|
+
refreshing: Boolean(this.refreshing),
|
|
456
|
+
sources: active.map(({ feed, error, okAt }) => ({ id: feed.id, name: feed.name, category: feed.category, error, okAt: iso(okAt) })),
|
|
457
|
+
items: items.map(({ feed, item }) => ({
|
|
458
|
+
id: item.id,
|
|
459
|
+
title: item.title,
|
|
460
|
+
url: item.url,
|
|
461
|
+
discussion: item.discussion,
|
|
462
|
+
summary: item.summary,
|
|
463
|
+
source: feed.name,
|
|
464
|
+
sourceId: feed.id,
|
|
465
|
+
category: feed.category,
|
|
466
|
+
publishedAt: iso(item.time),
|
|
467
|
+
})),
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
}
|
|
@@ -11,8 +11,9 @@ import { EventEmitter } from 'node:events';
|
|
|
11
11
|
import { fileURLToPath } from 'node:url';
|
|
12
12
|
import { resolveCommand, resolveAllCommands, pathKey, buildSpawnSpec, runSpec } from './command-resolver.mjs';
|
|
13
13
|
import { compareVersions, probeVersion, fetchManifest, latestVersion, DEFAULT_NPM_REGISTRY } from './versions.mjs';
|
|
14
|
-
import { CHANNEL_LABELS, classifyInstall, formatCommand, helpDescribes, knownLaunchers, listInstallations, platformDependency } from './install-channels.mjs';
|
|
14
|
+
import { CHANNEL_LABELS, classifyInstall, expandHome, formatCommand, helpDescribes, knownLaunchers, listInstallations, platformDependency } from './install-channels.mjs';
|
|
15
15
|
import { weavePaths } from './shell-env.mjs';
|
|
16
|
+
import { paths } from './config.mjs';
|
|
16
17
|
|
|
17
18
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
18
19
|
export const DEFAULTS_FILE = path.resolve(here, '../../config/providers.default.json');
|
|
@@ -86,6 +87,50 @@ function normalizeChannels(raw) {
|
|
|
86
87
|
return out;
|
|
87
88
|
}
|
|
88
89
|
|
|
90
|
+
function normalizeHooks(raw) {
|
|
91
|
+
if (!raw || typeof raw !== 'object' || typeof raw.path !== 'string' || typeof raw.example !== 'string') return null;
|
|
92
|
+
const parts = raw.path.split(/[\\/]+/);
|
|
93
|
+
if (parts.some((part) => part === '' || part === '.' || part === '..') || !/^[A-Za-z0-9._-]+$/.test(raw.example)) return null;
|
|
94
|
+
return { path: parts.join('/'), example: raw.example };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const DEFAULT_ACCOUNT = 'default';
|
|
98
|
+
|
|
99
|
+
function normalizeAccounts(raw, id, homeVar, warnings) {
|
|
100
|
+
const accounts = [{ id: DEFAULT_ACCOUNT, label: 'Default', dir: null }];
|
|
101
|
+
if (raw === undefined || raw === null) return accounts;
|
|
102
|
+
if (!Array.isArray(raw)) {
|
|
103
|
+
warnings.push(`provider "${id}": ignored accounts; it must be an array`);
|
|
104
|
+
return accounts;
|
|
105
|
+
}
|
|
106
|
+
if (!homeVar && raw.length > 0) {
|
|
107
|
+
warnings.push(`provider "${id}": ignored accounts; the provider has no homeVar`);
|
|
108
|
+
return accounts;
|
|
109
|
+
}
|
|
110
|
+
for (const entry of raw) {
|
|
111
|
+
const item = typeof entry === 'string' ? { id: entry } : entry;
|
|
112
|
+
const accountId = item && typeof item === 'object' ? String(item.id ?? '') : '';
|
|
113
|
+
if (!ID_RE.test(accountId)) {
|
|
114
|
+
warnings.push(`provider "${id}": skipped account with invalid id ${JSON.stringify(item?.id ?? entry)}`);
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
const label = typeof item.label === 'string' && item.label.trim() ? item.label.trim().slice(0, 40) : accountId.charAt(0).toUpperCase() + accountId.slice(1);
|
|
118
|
+
const dir = typeof item.dir === 'string' && item.dir.trim() ? item.dir.trim() : null;
|
|
119
|
+
const existing = accounts.find((a) => a.id === accountId);
|
|
120
|
+
if (existing) {
|
|
121
|
+
if (accountId !== DEFAULT_ACCOUNT) {
|
|
122
|
+
warnings.push(`provider "${id}": skipped duplicate account "${accountId}"`);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
existing.label = label;
|
|
126
|
+
if (dir) warnings.push(`provider "${id}": ignored dir of the default account; it uses the tool's own home folder`);
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
accounts.push({ id: accountId, label, dir });
|
|
130
|
+
}
|
|
131
|
+
return accounts;
|
|
132
|
+
}
|
|
133
|
+
|
|
89
134
|
function httpsUrl(value, field, id, warnings) {
|
|
90
135
|
if (value === undefined || value === null || value === '') return null;
|
|
91
136
|
try {
|
|
@@ -99,6 +144,7 @@ function httpsUrl(value, field, id, warnings) {
|
|
|
99
144
|
function normalize(raw, platform, warnings) {
|
|
100
145
|
const merged = { ...raw, ...(raw[platform] || {}) };
|
|
101
146
|
for (const key of PLATFORM_KEYS) delete merged[key];
|
|
147
|
+
const homeVar = typeof merged.homeVar === 'string' && /^[A-Za-z_][A-Za-z0-9_]*$/.test(merged.homeVar) ? merged.homeVar : null;
|
|
102
148
|
return {
|
|
103
149
|
id: merged.id,
|
|
104
150
|
vendor: String(merged.vendor || merged.id),
|
|
@@ -111,6 +157,10 @@ function normalize(raw, platform, warnings) {
|
|
|
111
157
|
args: Array.isArray(merged.args) ? merged.args.map(String) : [],
|
|
112
158
|
resumeArgs: Array.isArray(merged.resumeArgs) ? merged.resumeArgs.map(String) : [],
|
|
113
159
|
env: normalizeEnv(merged.env),
|
|
160
|
+
homeVar,
|
|
161
|
+
accountEnv: normalizeEnv(merged.accountEnv),
|
|
162
|
+
hooks: normalizeHooks(merged.hooks),
|
|
163
|
+
accounts: normalizeAccounts(merged.accounts, merged.id, homeVar, warnings),
|
|
114
164
|
color: String(merged.color || '#64748B'),
|
|
115
165
|
monogram: String(merged.monogram || String(merged.vendor || merged.id).charAt(0)).slice(0, 2),
|
|
116
166
|
icon: merged.icon ? String(merged.icon) : null,
|
|
@@ -172,10 +222,12 @@ export class ProviderRegistry extends EventEmitter {
|
|
|
172
222
|
* @param {string} [opts.registryUrl] npm registry for lookups and installs; default: npm's own configuration
|
|
173
223
|
* @param {boolean} [opts.checkUpdates] false skips registry lookups entirely
|
|
174
224
|
* @param {Function} [opts.fetchImpl]
|
|
225
|
+
* @param {string} [opts.accountsDir] where accounts without a dir get their home folders
|
|
175
226
|
*/
|
|
176
|
-
constructor({ userFile, env, platform = process.platform, iconDir, registryUrl = null, checkUpdates = true, fetchImpl, pathReader = null } = {}) {
|
|
227
|
+
constructor({ userFile, env, platform = process.platform, iconDir, registryUrl = null, checkUpdates = true, fetchImpl, pathReader = null, accountsDir = paths.accounts } = {}) {
|
|
177
228
|
super();
|
|
178
229
|
this.userFile = userFile;
|
|
230
|
+
this.accountsDir = accountsDir;
|
|
179
231
|
this.env = env || process.env;
|
|
180
232
|
this.platform = platform;
|
|
181
233
|
this.iconDir = iconDir;
|
|
@@ -184,6 +236,7 @@ export class ProviderRegistry extends EventEmitter {
|
|
|
184
236
|
this.fetchImpl = fetchImpl;
|
|
185
237
|
this.pathReader = pathReader;
|
|
186
238
|
this._pathReadAt = 0;
|
|
239
|
+
this._pathPending = null;
|
|
187
240
|
this._installs = new Map();
|
|
188
241
|
this.versions = new Map();
|
|
189
242
|
this._refreshing = null;
|
|
@@ -209,6 +262,8 @@ export class ProviderRegistry extends EventEmitter {
|
|
|
209
262
|
npmRegistryUrl() {
|
|
210
263
|
if (this.registryUrl) return Promise.resolve(this.registryUrl);
|
|
211
264
|
this._npmRegistry ??= (async () => {
|
|
265
|
+
// PATH discovery in flight may change which npm is found; wait for it.
|
|
266
|
+
await this._pathPending;
|
|
212
267
|
const npm = this.resolveNpm();
|
|
213
268
|
if (!npm) return DEFAULT_NPM_REGISTRY;
|
|
214
269
|
try {
|
|
@@ -236,11 +291,18 @@ export class ProviderRegistry extends EventEmitter {
|
|
|
236
291
|
return pending;
|
|
237
292
|
}
|
|
238
293
|
|
|
239
|
-
|
|
240
|
-
|
|
294
|
+
/** Resolves to true when the PATH changed. A concurrent caller waits for the read already in flight. */
|
|
295
|
+
refreshPath({ force = false } = {}) {
|
|
296
|
+
if (!this.pathReader) return Promise.resolve(false);
|
|
241
297
|
const now = Date.now();
|
|
242
|
-
if (!force && now - this._pathReadAt < PATH_REFRESH_MS) return false;
|
|
298
|
+
if (!force && now - this._pathReadAt < PATH_REFRESH_MS) return this._pathPending ?? Promise.resolve(false);
|
|
243
299
|
this._pathReadAt = now;
|
|
300
|
+
const pending = this._readPath().finally(() => { if (this._pathPending === pending) this._pathPending = null; });
|
|
301
|
+
this._pathPending = pending;
|
|
302
|
+
return pending;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async _readPath() {
|
|
244
306
|
const discovered = await Promise.resolve().then(() => this.pathReader()).catch(() => null);
|
|
245
307
|
if (!discovered) return false;
|
|
246
308
|
const key = pathKey(this.env, this.platform);
|
|
@@ -462,6 +524,27 @@ export class ProviderRegistry extends EventEmitter {
|
|
|
462
524
|
return this.providers.find((p) => p.id === id) || null;
|
|
463
525
|
}
|
|
464
526
|
|
|
527
|
+
accountFor(provider, account) {
|
|
528
|
+
if (account.id === DEFAULT_ACCOUNT || !provider.homeVar) return { id: account.id, label: account.label, dir: null, env: {} };
|
|
529
|
+
const p = this.platform === 'win32' ? path.win32 : path.posix;
|
|
530
|
+
const own = account.dir ? expandHome(account.dir, this.env, this.platform) : null;
|
|
531
|
+
const dir = own && p.isAbsolute(own) ? p.normalize(own) : p.join(this.accountsDir, provider.id, own || account.id);
|
|
532
|
+
const env = { [provider.homeVar]: dir };
|
|
533
|
+
for (const [key, value] of Object.entries(provider.accountEnv)) env[key] = value.replaceAll('{dir}', dir);
|
|
534
|
+
return { id: account.id, label: account.label, dir, env };
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
accountsFor(provider) {
|
|
538
|
+
return provider.accounts.map((account) => this.accountFor(provider, account));
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
account(provider, id = null) {
|
|
542
|
+
const wanted = id === null || id === undefined || id === '' ? DEFAULT_ACCOUNT : String(id);
|
|
543
|
+
const found = provider.accounts.find((a) => a.id === wanted);
|
|
544
|
+
if (!found) throw refusal(404, 'unknown_account', `${provider.tool} has no account "${wanted}"`);
|
|
545
|
+
return this.accountFor(provider, found);
|
|
546
|
+
}
|
|
547
|
+
|
|
465
548
|
commandFor(provider) {
|
|
466
549
|
return provider.command === '@shell' ? defaultShell(this.env, this.platform) : provider.command;
|
|
467
550
|
}
|
|
@@ -529,6 +612,7 @@ export class ProviderRegistry extends EventEmitter {
|
|
|
529
612
|
warnings: this.installWarnings(provider, installs),
|
|
530
613
|
npmNote: provider.npmNote,
|
|
531
614
|
usageSource: provider.usage === null ? null : typeof provider.usage === 'string' ? provider.usage : 'command',
|
|
615
|
+
accounts: provider.accounts.map(({ id, label }) => ({ id, label })),
|
|
532
616
|
modelPattern: provider.modelPattern,
|
|
533
617
|
color: provider.color,
|
|
534
618
|
monogram: provider.monogram,
|