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.
Files changed (3) hide show
  1. package/package.json +1 -1
  2. package/src/hub.js +103 -38
  3. package/src/hub.test.js +56 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentproc",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "AgentProc Protocol SDK + CLI for Node.js — connect any Agent CLI to a messaging platform",
5
5
  "main": "src/index.js",
6
6
  "types": "src/index.d.ts",
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 memory for the lifetime of the process.
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 in-memory tree cache as getTree(), so calling this
247
- * after listRemoteProfileFiles does not cost an extra API request.
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, also clear the in-memory tree cache so we see new files
342
- // (e.g. profiles added since the process started).
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
- const entries = await listRemoteProfileFiles(name);
363
- if (entries.length === 0) {
364
- // getTree succeeded (otherwise listRemoteProfileFiles would have thrown
365
- // a HubError already). So the name is genuinely wrong — surface the list
366
- // of available names so the user can correct the typo.
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 re-download every file in the profile directory.
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
- for (const entry of entries) {
385
- const local = path.join(dir, entry.name);
386
- await downloadFile(entry.path, local);
387
- if (onLog) onLog(` - ${entry.name}`);
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
- throw new Error(`unexpected text URL: ${url}`);
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 first = counter.json;
245
+ const firstText = counter.text;
213
246
  await hub.fetchProfile('echo-agent', { refresh: true });
214
- assert.ok(counter.json > first);
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 -----