@oddessentials/agent-guild 0.3.0 → 0.5.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 +12 -7
- package/bin/agent-guild-report.mjs +14 -1
- package/bin/agent-guild.mjs +85 -46
- package/config/providers.default.json +4 -0
- package/package.json +1 -1
- package/src/manager/launch.mjs +64 -0
- package/src/manager/main.mjs +27 -4
- package/src/manager/news.mjs +470 -0
- package/src/manager/providers.mjs +11 -0
- package/src/manager/server.mjs +30 -8
- package/src/manager/session-history.mjs +496 -0
- package/src/manager/session-manager.mjs +4 -0
- package/src/manager/session.mjs +16 -0
- package/src/report/hooks.mjs +4 -0
- package/web/app.js +715 -50
- package/web/art/art.css +20 -0
- package/web/index.html +74 -4
- package/web/styles.css +104 -4
|
@@ -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
|
+
}
|
|
@@ -60,6 +60,15 @@ function normalizeUsage(usage) {
|
|
|
60
60
|
return null;
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
/** "claude", "codex", "gemini", "grok", a { command, args } that prints past sessions as JSON, or null. */
|
|
64
|
+
function normalizeHistory(history) {
|
|
65
|
+
if (['claude', 'codex', 'gemini', 'grok'].includes(history)) return history;
|
|
66
|
+
if (history && typeof history === 'object' && typeof history.command === 'string' && history.command) {
|
|
67
|
+
return { command: history.command, args: Array.isArray(history.args) ? history.args.map(String) : [] };
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
63
72
|
function stringList(value) {
|
|
64
73
|
return Array.isArray(value) ? value.map(String).filter(Boolean) : [];
|
|
65
74
|
}
|
|
@@ -153,6 +162,7 @@ function normalize(raw, platform, warnings) {
|
|
|
153
162
|
package: merged.package ? String(merged.package) : null,
|
|
154
163
|
versionArgs: Array.isArray(merged.versionArgs) && merged.versionArgs.length ? merged.versionArgs.map(String) : null,
|
|
155
164
|
usage: normalizeUsage(merged.usage),
|
|
165
|
+
history: normalizeHistory(merged.history),
|
|
156
166
|
modelPattern: merged.modelPattern ? String(merged.modelPattern) : null,
|
|
157
167
|
args: Array.isArray(merged.args) ? merged.args.map(String) : [],
|
|
158
168
|
resumeArgs: Array.isArray(merged.resumeArgs) ? merged.resumeArgs.map(String) : [],
|
|
@@ -612,6 +622,7 @@ export class ProviderRegistry extends EventEmitter {
|
|
|
612
622
|
warnings: this.installWarnings(provider, installs),
|
|
613
623
|
npmNote: provider.npmNote,
|
|
614
624
|
usageSource: provider.usage === null ? null : typeof provider.usage === 'string' ? provider.usage : 'command',
|
|
625
|
+
historySource: provider.history === null ? null : typeof provider.history === 'string' ? provider.history : 'command',
|
|
615
626
|
accounts: provider.accounts.map(({ id, label }) => ({ id, label })),
|
|
616
627
|
modelPattern: provider.modelPattern,
|
|
617
628
|
color: provider.color,
|
package/src/manager/server.mjs
CHANGED
|
@@ -100,7 +100,9 @@ export function createManagerServer({
|
|
|
100
100
|
manager,
|
|
101
101
|
registry,
|
|
102
102
|
usage,
|
|
103
|
+
history,
|
|
103
104
|
modelStats,
|
|
105
|
+
news = null,
|
|
104
106
|
token,
|
|
105
107
|
host = '127.0.0.1',
|
|
106
108
|
port = 0,
|
|
@@ -108,6 +110,9 @@ export function createManagerServer({
|
|
|
108
110
|
version = '0.0.0',
|
|
109
111
|
selfUpdate = null,
|
|
110
112
|
extraOrigins = [],
|
|
113
|
+
/** The double-click launcher file for this platform, or null when the package carries none. */
|
|
114
|
+
launcher = null,
|
|
115
|
+
/** @type {(opts: { restart: boolean }) => void} */
|
|
111
116
|
onShutdownRequest = () => {},
|
|
112
117
|
}) {
|
|
113
118
|
const upgradeInfo = () => (selfUpdate ? selfUpdate.describe() : null);
|
|
@@ -183,9 +188,10 @@ export function createManagerServer({
|
|
|
183
188
|
return sendJson(res, 200, { ok: true, name: 'agent-guild', version, pid: process.pid });
|
|
184
189
|
}
|
|
185
190
|
|
|
186
|
-
// Agent and
|
|
187
|
-
// token that the manager injects into each tool's
|
|
188
|
-
|
|
191
|
+
// Agent, model and tool-session reports may authenticate with the
|
|
192
|
+
// per-session report token that the manager injects into each tool's
|
|
193
|
+
// environment.
|
|
194
|
+
const reportMatch = route.match(/^\/sessions\/([a-f0-9]+)\/(agents|model|tool-session)$/);
|
|
189
195
|
if (reportMatch && method === 'POST') {
|
|
190
196
|
const [, id, kind] = reportMatch;
|
|
191
197
|
const body = await readJsonBody(req);
|
|
@@ -194,7 +200,8 @@ export function createManagerServer({
|
|
|
194
200
|
reportToken: req.headers['x-agent-guild-report-token'],
|
|
195
201
|
};
|
|
196
202
|
if (kind === 'agents') return sendJson(res, 200, { agent: manager.reportAgent(id, body, auth) });
|
|
197
|
-
return sendJson(res, 200, { model: manager.reportModel(id, body, auth) });
|
|
203
|
+
if (kind === 'model') return sendJson(res, 200, { model: manager.reportModel(id, body, auth) });
|
|
204
|
+
return sendJson(res, 200, { toolSessionId: manager.reportToolSession(id, body, auth) });
|
|
198
205
|
}
|
|
199
206
|
|
|
200
207
|
requireAuth(req, url);
|
|
@@ -209,6 +216,7 @@ export function createManagerServer({
|
|
|
209
216
|
startedAt,
|
|
210
217
|
warnings: registry.warnings,
|
|
211
218
|
upgrade: upgradeInfo(),
|
|
219
|
+
launcher,
|
|
212
220
|
});
|
|
213
221
|
}
|
|
214
222
|
if (route === '/upgrade' && method === 'POST') {
|
|
@@ -230,6 +238,17 @@ export function createManagerServer({
|
|
|
230
238
|
if (route === '/model-stats' && method === 'GET') {
|
|
231
239
|
return sendJson(res, 200, await modelStats.snapshot(manager.list()));
|
|
232
240
|
}
|
|
241
|
+
if (route === '/news' && method === 'GET' && news) {
|
|
242
|
+
return sendJson(res, 200, news.snapshot());
|
|
243
|
+
}
|
|
244
|
+
const historyMatch = route.match(/^\/providers\/([a-z0-9][a-z0-9_-]{0,31})\/history$/);
|
|
245
|
+
if (historyMatch && method === 'GET') {
|
|
246
|
+
const provider = registry.get(historyMatch[1]);
|
|
247
|
+
if (!provider) throw new HttpError(404, `unknown provider "${historyMatch[1]}"`, 'unknown_provider');
|
|
248
|
+
if (!provider.history) throw new HttpError(400, `${provider.tool} has no history source configured`, 'history_unsupported');
|
|
249
|
+
const account = registry.account(provider, url.searchParams.get('account'));
|
|
250
|
+
return sendJson(res, 200, { history: await history.list(provider, account, { limit: url.searchParams.get('limit') }) });
|
|
251
|
+
}
|
|
233
252
|
const installMatch = route.match(/^\/providers\/([a-z0-9][a-z0-9_-]{0,31})\/install$/);
|
|
234
253
|
if (installMatch && method === 'POST') {
|
|
235
254
|
const body = await readJsonBody(req);
|
|
@@ -254,14 +273,16 @@ export function createManagerServer({
|
|
|
254
273
|
err.running = running;
|
|
255
274
|
throw err;
|
|
256
275
|
}
|
|
276
|
+
// With `restart`, a new manager is started once this one has closed.
|
|
277
|
+
const restart = body.restart === true;
|
|
257
278
|
// Refuse new sessions from this moment, before the shutdown itself
|
|
258
279
|
// runs: a session accepted in between would be ended without warning.
|
|
259
280
|
manager.closing = true;
|
|
260
|
-
sendJson(res, 202, { ok: true, running });
|
|
281
|
+
sendJson(res, 202, { ok: true, running, restart });
|
|
261
282
|
// Tell every client first, so a second page shows "stopped" rather
|
|
262
283
|
// than "not reachable" when its socket drops.
|
|
263
|
-
broadcast({ type: 'manager.stopping', running });
|
|
264
|
-
setImmediate(onShutdownRequest);
|
|
284
|
+
broadcast({ type: 'manager.stopping', running, restart });
|
|
285
|
+
setImmediate(() => onShutdownRequest({ restart }));
|
|
265
286
|
return undefined;
|
|
266
287
|
}
|
|
267
288
|
|
|
@@ -342,10 +363,11 @@ export function createManagerServer({
|
|
|
342
363
|
manager.on('event', broadcast);
|
|
343
364
|
registry.on('updated', () => broadcast({ type: 'providers.updated', providers: registry.list() }));
|
|
344
365
|
selfUpdate?.on('updated', () => broadcast({ type: 'manager.upgrade', upgrade: upgradeInfo() }));
|
|
366
|
+
news?.on('updated', () => broadcast({ type: 'news.updated' }));
|
|
345
367
|
|
|
346
368
|
function handleEvents(ws) {
|
|
347
369
|
eventClients.add(ws);
|
|
348
|
-
safeSend(ws, { type: 'hello', version, upgrade: upgradeInfo(), sessions: manager.list() });
|
|
370
|
+
safeSend(ws, { type: 'hello', version, pid: process.pid, launcher, upgrade: upgradeInfo(), sessions: manager.list() });
|
|
349
371
|
ws.on('close', () => eventClients.delete(ws));
|
|
350
372
|
ws.on('message', () => { /* events socket is server -> client only */ });
|
|
351
373
|
}
|