agentproc 0.4.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/hub.js +103 -38
- package/src/hub.test.js +56 -3
package/package.json
CHANGED
package/src/hub.js
CHANGED
|
@@ -177,14 +177,67 @@ async function httpGetText(url) {
|
|
|
177
177
|
return r.text();
|
|
178
178
|
}
|
|
179
179
|
|
|
180
|
+
/**
|
|
181
|
+
* Like httpGetText, but returns null on 404 instead of throwing. Used for
|
|
182
|
+
* probing optional profile files (e.g. bridge.sh only exists for echo-agent)
|
|
183
|
+
* and for detecting "profile does not exist" without burning an API call.
|
|
184
|
+
*/
|
|
185
|
+
async function httpGetTextOptional(url) {
|
|
186
|
+
const r = await fetch(url, { headers: authHeaders({ json: false }) });
|
|
187
|
+
if (r.status === 404) return null;
|
|
188
|
+
if (!r.ok) {
|
|
189
|
+
throw new HubError(`fetch failed (HTTP ${r.status}) for ${url}`, {
|
|
190
|
+
status: r.status,
|
|
191
|
+
hint: 'Profile files should exist in the hub repo. Try `agentproc hub list` to verify.',
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
return r.text();
|
|
195
|
+
}
|
|
196
|
+
|
|
180
197
|
/**
|
|
181
198
|
* Fetch the entire repo tree (1 API call, returns all paths under hub/).
|
|
182
|
-
* Cached in
|
|
199
|
+
* Cached two ways: in-memory for the lifetime of the process, and on disk
|
|
200
|
+
* at ~/.agentproc/cache/hub/tree.json with the same 24h TTL as profiles.
|
|
201
|
+
*
|
|
202
|
+
* The disk cache is the important one for rate-limit relief: the GitHub
|
|
203
|
+
* Trees API is the single call that rate-limits anonymous users to ~60/hr,
|
|
204
|
+
* and every CLI invocation is a fresh process (so the in-memory cache never
|
|
205
|
+
* survives). With the disk cache, a normal user makes at most ~1 Trees API
|
|
206
|
+
* call per day regardless of how many `hub list` / `hub run` they run.
|
|
183
207
|
* @returns {Promise<Array<{path: string, type: 'blob'|'tree'}>>}
|
|
184
208
|
*/
|
|
185
209
|
let _treeCache = null;
|
|
210
|
+
|
|
211
|
+
function treeCachePath() {
|
|
212
|
+
return path.join(cacheRoot(), 'tree.json');
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function clearTreeCache() {
|
|
216
|
+
_treeCache = null;
|
|
217
|
+
const p = treeCachePath();
|
|
218
|
+
if (fs.existsSync(p)) {
|
|
219
|
+
try { fs.unlinkSync(p); } catch { /* best effort */ }
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
186
223
|
async function getTree() {
|
|
187
224
|
if (_treeCache) return _treeCache;
|
|
225
|
+
|
|
226
|
+
const tp = treeCachePath();
|
|
227
|
+
if (fs.existsSync(tp)) {
|
|
228
|
+
try {
|
|
229
|
+
const meta = JSON.parse(fs.readFileSync(tp, 'utf8'));
|
|
230
|
+
const age = Math.max(0, Date.now() / 1000 - (meta.fetched_at || 0));
|
|
231
|
+
if (age < HUB_CACHE_TTL_SECS && Array.isArray(meta.tree)) {
|
|
232
|
+
_treeCache = meta.tree.map((e) => ({
|
|
233
|
+
path: String((e && e.path) || ''),
|
|
234
|
+
type: String((e && e.type) || ''),
|
|
235
|
+
}));
|
|
236
|
+
return _treeCache;
|
|
237
|
+
}
|
|
238
|
+
} catch { /* corrupt cache file — refetch */ }
|
|
239
|
+
}
|
|
240
|
+
|
|
188
241
|
const data = await httpGetJson(GITHUB_TREES);
|
|
189
242
|
if (!data || !Array.isArray(data.tree)) {
|
|
190
243
|
throw new Error('unexpected tree API response');
|
|
@@ -195,6 +248,16 @@ async function getTree() {
|
|
|
195
248
|
path: String(e.path || ''),
|
|
196
249
|
type: String(e.type || ''), // 'blob' or 'tree'
|
|
197
250
|
}));
|
|
251
|
+
|
|
252
|
+
fs.mkdirSync(cacheRoot(), { recursive: true });
|
|
253
|
+
try {
|
|
254
|
+
fs.writeFileSync(tp, JSON.stringify({
|
|
255
|
+
fetched_at: Date.now() / 1000,
|
|
256
|
+
ref: HUB_REF,
|
|
257
|
+
tree: _treeCache,
|
|
258
|
+
}), 'utf8');
|
|
259
|
+
} catch { /* disk cache is best-effort */ }
|
|
260
|
+
|
|
198
261
|
return _treeCache;
|
|
199
262
|
}
|
|
200
263
|
|
|
@@ -225,26 +288,10 @@ async function listRemoteFiles(subpath) {
|
|
|
225
288
|
return out;
|
|
226
289
|
}
|
|
227
290
|
|
|
228
|
-
/**
|
|
229
|
-
* List actual files inside a hub/<name>/ directory.
|
|
230
|
-
* @param {string} name
|
|
231
|
-
* @returns {Promise<Array<{name: string, path: string}>>}
|
|
232
|
-
*/
|
|
233
|
-
async function listRemoteProfileFiles(name) {
|
|
234
|
-
const prefix = `hub/${name}/`;
|
|
235
|
-
const tree = await getTree();
|
|
236
|
-
return tree
|
|
237
|
-
.filter((e) => e.type === 'blob' && e.path.startsWith(prefix))
|
|
238
|
-
.map((e) => ({
|
|
239
|
-
name: e.path.slice(prefix.length).split('/').pop(),
|
|
240
|
-
path: e.path,
|
|
241
|
-
}));
|
|
242
|
-
}
|
|
243
|
-
|
|
244
291
|
/**
|
|
245
292
|
* List top-level profile names (the directories directly under hub/).
|
|
246
|
-
* Cheap: uses the same
|
|
247
|
-
* after
|
|
293
|
+
* Cheap: uses the same disk-cached tree as getTree(), so calling this
|
|
294
|
+
* after listRemoteFiles does not cost an extra API request.
|
|
248
295
|
* @returns {Promise<string[]>}
|
|
249
296
|
*/
|
|
250
297
|
async function listProfileNames() {
|
|
@@ -318,19 +365,28 @@ function editDistance(a, b) {
|
|
|
318
365
|
return prev[n];
|
|
319
366
|
}
|
|
320
367
|
|
|
321
|
-
async function downloadFile(remotePath, localPath) {
|
|
322
|
-
const text = await httpGetText(GITHUB_RAW(remotePath));
|
|
323
|
-
fs.mkdirSync(path.dirname(localPath), { recursive: true });
|
|
324
|
-
fs.writeFileSync(localPath, text, 'utf8');
|
|
325
|
-
}
|
|
326
|
-
|
|
327
368
|
// ---------------------------------------------------------------------------
|
|
328
369
|
// Public API
|
|
329
370
|
// ---------------------------------------------------------------------------
|
|
330
371
|
|
|
372
|
+
// Every hub profile is this fixed set of files (see hub/README.md):
|
|
373
|
+
// profile.yaml (required) + bridge.py + bridge.js + README.md,
|
|
374
|
+
// with echo-agent additionally shipping bridge.sh. We fetch them directly
|
|
375
|
+
// via raw.githubusercontent.com (CDN, not rate-limited) so `hub run` never
|
|
376
|
+
// calls the GitHub Trees API in the happy path. If a future profile adds a
|
|
377
|
+
// new file type, extend this list.
|
|
378
|
+
const PROFILE_FILE_CANDIDATES = [
|
|
379
|
+
'profile.yaml', 'bridge.py', 'bridge.js', 'bridge.sh', 'README.md',
|
|
380
|
+
];
|
|
381
|
+
|
|
331
382
|
/**
|
|
332
383
|
* Fetch a profile directory to local cache. Returns the cache path.
|
|
333
384
|
*
|
|
385
|
+
* Fetches files directly via raw.githubusercontent.com (CDN, not
|
|
386
|
+
* rate-limited) — no GitHub Trees API call in the happy path. Only an
|
|
387
|
+
* unknown profile name (profile.yaml 404) falls back to the disk-cached
|
|
388
|
+
* tree to produce a "did you mean" suggestion.
|
|
389
|
+
*
|
|
334
390
|
* @param {string} name
|
|
335
391
|
* @param {{refresh?: boolean, onLog?: function(string): void}} [opts]
|
|
336
392
|
* @returns {Promise<string>} absolute cache path
|
|
@@ -338,9 +394,8 @@ async function downloadFile(remotePath, localPath) {
|
|
|
338
394
|
async function fetchProfile(name, opts = {}) {
|
|
339
395
|
const { refresh = false, onLog = null } = opts;
|
|
340
396
|
|
|
341
|
-
// On refresh,
|
|
342
|
-
|
|
343
|
-
if (refresh) _treeCache = null;
|
|
397
|
+
// On refresh, clear the tree cache so we see newly-added profiles.
|
|
398
|
+
if (refresh) clearTreeCache();
|
|
344
399
|
|
|
345
400
|
const age = cacheAgeSecs(name);
|
|
346
401
|
const dir = cacheDir(name);
|
|
@@ -359,11 +414,14 @@ async function fetchProfile(name, opts = {}) {
|
|
|
359
414
|
}
|
|
360
415
|
}
|
|
361
416
|
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
417
|
+
// Probe profile.yaml via raw URL. raw.githubusercontent.com is CDN-backed
|
|
418
|
+
// and not subject to the 60/hr anonymous API limit, so this does not burn
|
|
419
|
+
// rate-limit budget.
|
|
420
|
+
const probe = await httpGetTextOptional(GITHUB_RAW(`hub/${name}/profile.yaml`));
|
|
421
|
+
if (probe === null) {
|
|
422
|
+
// profile.yaml 404 → the name is wrong. Fall back to the (disk-cached)
|
|
423
|
+
// tree to produce a "did you mean" suggestion. This is the only path
|
|
424
|
+
// that may call the Trees API for `hub run`, and it's cached for 24h.
|
|
367
425
|
const known = await listProfileNames();
|
|
368
426
|
const suggestion = suggestCloseName(name, known);
|
|
369
427
|
const hint = suggestion
|
|
@@ -375,16 +433,22 @@ async function fetchProfile(name, opts = {}) {
|
|
|
375
433
|
});
|
|
376
434
|
}
|
|
377
435
|
|
|
378
|
-
// Clear cache, then
|
|
436
|
+
// Clear cache, then download the candidate file set via raw URLs.
|
|
379
437
|
if (fs.existsSync(dir)) {
|
|
380
438
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
381
439
|
}
|
|
382
440
|
fs.mkdirSync(dir, { recursive: true });
|
|
383
441
|
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
442
|
+
// profile.yaml already fetched via the probe.
|
|
443
|
+
fs.writeFileSync(path.join(dir, 'profile.yaml'), probe, 'utf8');
|
|
444
|
+
if (onLog) onLog(` - profile.yaml`);
|
|
445
|
+
|
|
446
|
+
for (const fname of PROFILE_FILE_CANDIDATES) {
|
|
447
|
+
if (fname === 'profile.yaml') continue;
|
|
448
|
+
const text = await httpGetTextOptional(GITHUB_RAW(`hub/${name}/${fname}`));
|
|
449
|
+
if (text === null) continue; // optional file not present for this profile
|
|
450
|
+
fs.writeFileSync(path.join(dir, fname), text, 'utf8');
|
|
451
|
+
if (onLog) onLog(` - ${fname}`);
|
|
388
452
|
}
|
|
389
453
|
|
|
390
454
|
writeCacheMeta(name);
|
|
@@ -476,6 +540,7 @@ module.exports = {
|
|
|
476
540
|
cacheRoot,
|
|
477
541
|
cacheDir,
|
|
478
542
|
cacheAgeSecs,
|
|
543
|
+
clearTreeCache,
|
|
479
544
|
fetchProfile,
|
|
480
545
|
listProfiles,
|
|
481
546
|
showReadme,
|
package/src/hub.test.js
CHANGED
|
@@ -109,7 +109,11 @@ function installFakeFetch(tree = FAKE_TREE, contents = FAKE_FILE_CONTENTS) {
|
|
|
109
109
|
return { ok: true, text: async () => content };
|
|
110
110
|
}
|
|
111
111
|
}
|
|
112
|
-
|
|
112
|
+
// Unmatched raw URL → 404. This is now legitimate: `hub run` probes a
|
|
113
|
+
// fixed candidate file set via raw URLs, and optional files (e.g.
|
|
114
|
+
// bridge.sh on a non-echo profile) or a wrong profile name legitimately
|
|
115
|
+
// 404 without burning GitHub's rate-limited Trees API.
|
|
116
|
+
return { ok: false, status: 404, text: async () => '' };
|
|
113
117
|
};
|
|
114
118
|
counter.restore = () => { global.fetch = orig; };
|
|
115
119
|
return counter;
|
|
@@ -183,6 +187,35 @@ describe('hub', { concurrency: false }, () => {
|
|
|
183
187
|
}
|
|
184
188
|
});
|
|
185
189
|
|
|
190
|
+
test('happy path does not call the rate-limited Trees API', async () => {
|
|
191
|
+
// `hub run` fetches profile files via raw.githubusercontent.com (CDN,
|
|
192
|
+
// not rate-limited). A known profile must not trigger any api.github.com
|
|
193
|
+
// call — that's the whole point of the rate-limit fix.
|
|
194
|
+
const counter = installFakeFetch();
|
|
195
|
+
try {
|
|
196
|
+
await hub.fetchProfile('echo-agent');
|
|
197
|
+
assert.strictEqual(counter.json, 0);
|
|
198
|
+
} finally {
|
|
199
|
+
counter.restore();
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test('skips optional files that 404 (e.g. bridge.sh on claude-code)', async () => {
|
|
204
|
+
const counter = installFakeFetch();
|
|
205
|
+
try {
|
|
206
|
+
const dir = await hub.fetchProfile('claude-code');
|
|
207
|
+
const names = fs.readdirSync(dir);
|
|
208
|
+
assert.ok(names.includes('profile.yaml'));
|
|
209
|
+
assert.ok(names.includes('bridge.py'));
|
|
210
|
+
assert.ok(names.includes('bridge.js'));
|
|
211
|
+
assert.ok(names.includes('README.md'));
|
|
212
|
+
// claude-code has no bridge.sh — the 404 is swallowed, not stored.
|
|
213
|
+
assert.ok(!names.includes('bridge.sh'));
|
|
214
|
+
} finally {
|
|
215
|
+
counter.restore();
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
|
|
186
219
|
test('unknown profile raises', async () => {
|
|
187
220
|
const counter = installFakeFetch([{ path: 'hub', type: 'tree' }]);
|
|
188
221
|
try {
|
|
@@ -209,9 +242,11 @@ describe('hub', { concurrency: false }, () => {
|
|
|
209
242
|
const counter = installFakeFetch();
|
|
210
243
|
try {
|
|
211
244
|
await hub.fetchProfile('echo-agent');
|
|
212
|
-
const
|
|
245
|
+
const firstText = counter.text;
|
|
213
246
|
await hub.fetchProfile('echo-agent', { refresh: true });
|
|
214
|
-
|
|
247
|
+
// hub run fetches files via raw URLs (CDN), not the rate-limited
|
|
248
|
+
// Trees API — so a refresh re-fetches the file set, not the tree.
|
|
249
|
+
assert.ok(counter.text > firstText);
|
|
215
250
|
} finally {
|
|
216
251
|
counter.restore();
|
|
217
252
|
}
|
|
@@ -263,6 +298,24 @@ describe('hub', { concurrency: false }, () => {
|
|
|
263
298
|
counter.restore();
|
|
264
299
|
}
|
|
265
300
|
});
|
|
301
|
+
|
|
302
|
+
test('disk-caches the tree so repeat calls skip the Trees API', async () => {
|
|
303
|
+
// The first listProfiles hits the Trees API (counter.json 0→1) and
|
|
304
|
+
// writes ~/.agentproc/cache/hub/tree.json. A second call in the same
|
|
305
|
+
// process reuses the cached tree and must not make another API call.
|
|
306
|
+
const counter = installFakeFetch();
|
|
307
|
+
try {
|
|
308
|
+
hub.clearTreeCache(); // reset in-memory + disk from prior tests
|
|
309
|
+
await hub.listProfiles();
|
|
310
|
+
assert.strictEqual(counter.json, 1);
|
|
311
|
+
const treeCache = path.join(hub.cacheRoot(), 'tree.json');
|
|
312
|
+
assert.ok(fs.existsSync(treeCache), 'tree.json not written to disk');
|
|
313
|
+
await hub.listProfiles();
|
|
314
|
+
assert.strictEqual(counter.json, 1, 'second call hit the Trees API again');
|
|
315
|
+
} finally {
|
|
316
|
+
counter.restore();
|
|
317
|
+
}
|
|
318
|
+
});
|
|
266
319
|
});
|
|
267
320
|
|
|
268
321
|
// ----- showReadme -----
|