agent-office-studio 0.8.0 → 0.9.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/server/guests.js CHANGED
@@ -32,6 +32,9 @@ const REMOTE_LOG = 600;
32
32
  const MAX_THREADS = 300;
33
33
  const utcDay = (ms = Date.now()) => new Date(ms).toISOString().slice(0, 10);
34
34
  const fail = (code, message) => Object.assign(new Error(message), { code });
35
+ // Handles and project names match exactly, ignoring case only: the relay
36
+ // treats bob-x and bobx as two people, and so must we.
37
+ const same = (a, b) => String(a).toLowerCase() === String(b).toLowerCase();
35
38
 
36
39
  // ---------- what a host may put on our page ----------
37
40
  //
@@ -152,13 +155,13 @@ export class Guests {
152
155
  this.office = office;
153
156
  this.copies = visits;
154
157
  this.file = path.join(dataDir, 'guests.json');
155
- this.used = {};
158
+ this.used = Object.create(null);
156
159
  // Where each of our jobs at a host was asked for (chat thread and
157
160
  // channel), and the project last used at each host.
158
161
  this.asked = { threads: {}, lastRepo: {} };
159
162
  try {
160
163
  const saved = JSON.parse(fs.readFileSync(this.file, 'utf8'));
161
- this.used = saved.used || {};
164
+ this.used = Object.assign(Object.create(null), saved.used);
162
165
  this.asked = { threads: saved.asked?.threads || {}, lastRepo: saved.asked?.lastRepo || {} };
163
166
  } catch {}
164
167
  this.remote = new Map(); // link id -> { link, host, staff, repos, clearance, jobs: Map(host job id -> job) }
@@ -207,6 +210,17 @@ export class Guests {
207
210
  return mode;
208
211
  }
209
212
 
213
+ // A job's mode, lowered to what the host allows now.
214
+ lowerMode(p, mode) {
215
+ return CLEARANCE.includes(mode) && CLEARANCE.indexOf(mode) <= CLEARANCE.indexOf(p.clearance) ? mode : p.clearance;
216
+ }
217
+
218
+ // Which of our agents may take the job: only those still lent to the guest.
219
+ lend(job, p) {
220
+ job.pool = p.agents || undefined;
221
+ if (p.agents && job.wantAgent && !p.agents.includes(job.wantAgent)) job.wantAgent = null;
222
+ }
223
+
210
224
  checkTokens(link, p) {
211
225
  if (this.tokensToday(link.id) >= p.tokensPerDay) throw fail(429, 'You have used today’s tokens for this office. They reset at midnight UTC.');
212
226
  }
@@ -229,12 +243,13 @@ export class Guests {
229
243
  switch (op) {
230
244
  case 'hello':
231
245
  return {
232
- staff: this.office.staff(),
246
+ staff: this.office.staff().filter((s) => !p.agents || p.agents.includes(s.id)),
233
247
  modes: Object.fromEntries(Object.entries(this.office.modes()).filter(([k]) => CLEARANCE.indexOf(k) <= CLEARANCE.indexOf(p.clearance))),
234
248
  jobs: this.jobsOf(link.id).map((j) => guestJob(j)),
235
249
  policy: {
236
250
  repos: p.repos, clearance: p.clearance, tokensPerDay: p.tokensPerDay, tokensToday: this.tokensToday(link.id),
237
251
  ourAgents: p.ourAgents !== false, theirAgents: this.copies ? p.theirAgents || 'off' : 'off',
252
+ agents: p.agents || null,
238
253
  },
239
254
  // We can run a lent agent in one of the guest's own projects (createJob's `home`).
240
255
  home: true,
@@ -258,17 +273,23 @@ export class Guests {
258
273
  this.checkTokens(link, p);
259
274
  if (this.jobsOf(link.id, true).length >= MAX_OPEN_JOBS) throw fail(429, `You can have ${MAX_OPEN_JOBS} jobs going at once here. Wait for one to finish.`);
260
275
  const prompt = String(args.prompt ?? '').slice(0, MAX_PROMPT);
276
+ const agentId = typeof args.agentId === 'string' ? args.agentId : '';
277
+ if (p.agents && agentId && !p.agents.includes(agentId)) throw fail(403, 'That agent isn’t lent to you.');
261
278
  const job = this.office.createJob(
262
- { repo: home ? `${home.repo}@${home.host}` : args.repo, prompt, mode, model: typeof args.model === 'string' ? args.model : '', agentId: typeof args.agentId === 'string' ? args.agentId : '' },
263
- { from: `@${link.peer}`, guest: { link: link.id }, ...(home && { away: home }) },
279
+ { repo: home ? `${home.repo}@${home.host}` : args.repo, prompt, mode, model: typeof args.model === 'string' ? args.model : '', agentId },
280
+ { from: `@${link.peer}`, guest: { link: link.id }, ...(home && { away: home }), ...(p.agents && { pool: p.agents }) },
264
281
  );
265
282
  return { job: guestJob(job) };
266
283
  }
267
284
  case 'reply': {
268
285
  if (p.ourAgents === false) throw fail(403, 'This office doesn’t lend its agents any more.');
269
286
  const job = this.ownJob(link, args.job);
270
- const mode = args.mode === undefined ? undefined : this.capMode(p, args.mode);
287
+ // Held to the host's limits as they are now: a job started at Full
288
+ // autonomy, in a project since unshared, doesn't carry on as it was.
289
+ const mode = args.mode === undefined ? this.lowerMode(p, job.mode) : this.capMode(p, args.mode);
290
+ if (!job.away && !p.repos.includes(job.repo)) throw fail(403, 'That project isn’t shared with you any more.');
271
291
  this.checkTokens(link, p);
292
+ this.lend(job, p);
272
293
  this.office.replyJob(job, { message: String(args.text ?? '').slice(0, MAX_PROMPT), mode });
273
294
  return { ok: true };
274
295
  }
@@ -288,17 +309,33 @@ export class Guests {
288
309
  // host, as an away job's `away`. The relay names both ends of a link, so a
289
310
  // guest can only point us at its own office.
290
311
  async homeFolder(link, repo) {
291
- const norm = (v) => String(v).toLowerCase().replace(/[^a-z0-9]/g, '');
292
- let e = [...this.remote.values()].find((x) => norm(x.host) === norm(link.peer));
293
- const back = !e && this.links.links.find((l) => l.kind === 'guest' && l.role === 'guest' && norm(l.peer) === norm(link.peer));
312
+ let e = [...this.remote.values()].find((x) => same(x.host, link.peer));
313
+ const back = !e && this.links.links.find((l) => l.kind === 'guest' && l.role === 'guest' && same(l.peer, link.peer));
294
314
  if (back) e = await this.remoteHello(back).catch(() => null);
295
315
  if (!e) throw fail(403, `To use our agents in your own projects, make @${this.links.status().handle || 'us'} your guest too, with their agents allowed in your projects.`);
296
316
  if (!['read', 'edit'].includes(e.theirAgents)) throw fail(403, `You haven’t let @${this.links.status().handle || 'us'}’s agents work in your projects. Turn that on where we’re your guest.`);
297
- const r = e.repos.find((x) => norm(x) === norm(repo));
317
+ const r = e.repos.find((x) => same(x, repo));
298
318
  if (!r) throw fail(403, `You haven’t shared ${repo} with @${this.links.status().handle || 'us'}. Add it where we’re your guest.`);
299
319
  return { link: e.link, host: e.host, repo: r, access: e.theirAgents };
300
320
  }
301
321
 
322
+ // The host changed a guest's limits: open jobs the new ones rule out stop,
323
+ // and queued ones drop to the clearance and agents now allowed. A running
324
+ // session can't change its mode, so one above the new clearance stops too.
325
+ policyChanged(linkId) {
326
+ const p = this.links.guestPolicy(linkId);
327
+ for (const job of this.jobsOf(linkId, true)) {
328
+ const why = !p || p.ourAgents === false ? 'This office stopped lending its agents.'
329
+ : !job.away && !p.repos.includes(job.repo) ? 'That project isn’t shared with the guest any more.'
330
+ : job.status === 'working' && this.lowerMode(p, job.mode) !== job.mode ? 'The host lowered what this guest’s jobs may do.'
331
+ : job.status === 'working' && p.agents && !p.agents.includes(job.agentId) ? 'That agent isn’t lent to this guest any more.'
332
+ : null;
333
+ if (why) { this.office.stopJob(job, `Stopped: ${why}`); continue; }
334
+ job.mode = this.lowerMode(p, job.mode);
335
+ this.lend(job, p);
336
+ }
337
+ }
338
+
302
339
  // Tokens a guest job used: counted against its guest, and a guest over its
303
340
  // day's allowance has its running jobs stopped.
304
341
  addTokens(job, n) {
@@ -308,11 +345,14 @@ export class Guests {
308
345
  const u = this.used[linkId]?.day === day ? this.used[linkId] : { day, tokens: 0 };
309
346
  u.tokens += n;
310
347
  this.used[linkId] = u;
311
- this.save();
312
348
  const p = this.links.guestPolicy(linkId);
313
349
  if (p && u.tokens >= p.tokensPerDay) {
350
+ this.save();
314
351
  for (const j of this.jobsOf(linkId, true)) this.office.stopJob(j, 'Stopped: the guest used today’s tokens.');
352
+ return;
315
353
  }
354
+ // This runs on every API call, so the file is written at most every few seconds.
355
+ if (!this.saveTimer) { this.saveTimer = setTimeout(() => { this.saveTimer = null; this.save(); }, 5000); this.saveTimer.unref?.(); }
316
356
  }
317
357
 
318
358
  save() {
@@ -323,7 +363,7 @@ export class Guests {
323
363
  fs.mkdirSync(path.dirname(this.file), { recursive: true });
324
364
  const threads = Object.entries(this.asked.threads);
325
365
  if (threads.length > MAX_THREADS) this.asked.threads = Object.fromEntries(threads.slice(-MAX_THREADS));
326
- fs.writeFileSync(this.file + '.tmp', JSON.stringify({ used: this.used, asked: this.asked }));
366
+ fs.writeFileSync(this.file + '.tmp', JSON.stringify({ used: this.used, asked: this.asked }), { mode: 0o600 });
327
367
  fs.renameSync(this.file + '.tmp', this.file);
328
368
  } catch (err) { console.error('[guests] could not save', err.message); }
329
369
  }
@@ -611,7 +651,7 @@ export class Guests {
611
651
  const offices = this.remoteStatus();
612
652
  const find = (name, host) => {
613
653
  for (const o of offices) {
614
- if (host && norm(o.host) !== norm(host)) continue;
654
+ if (host && !same(o.host, host)) continue;
615
655
  const s = o.staff.find((x) => x.here && norm(x.name) === norm(name));
616
656
  if (s) return { link: o.link, host: o.host, staff: s };
617
657
  }
@@ -636,14 +676,13 @@ export class Guests {
636
676
  // file tools come back to us over the link where the host is our guest
637
677
  // (visits.js), so that link must let their agents into this project.
638
678
  lentHome(entry, repo) {
639
- const norm = (v) => String(v).toLowerCase().replace(/[^a-z0-9]/g, '');
640
679
  const h = entry.host;
641
680
  if (!entry.home) throw fail(400, `@${h}’s office is on an older version that can only run its agents in its own projects. Ask @${h} to update.`);
642
- const back = this.links.links.find((l) => l.kind === 'guest' && l.role === 'host' && norm(l.peer) === norm(h));
681
+ const back = this.links.links.find((l) => l.kind === 'guest' && l.role === 'host' && same(l.peer, h));
643
682
  const p = back && this.links.guestPolicy(back.id);
644
683
  if (!p) throw fail(400, `To use @${h}’s agents in #${repo}, make @${h} your guest too, with their agents allowed in your projects.`);
645
684
  if (!['read', 'edit'].includes(p.theirAgents)) throw fail(400, `To use @${h}’s agents in #${repo}, let their agents work in your projects: Change, where @${h} is your guest.`);
646
- const r = p.repos.find((x) => norm(x) === norm(repo));
685
+ const r = p.repos.find((x) => same(x, repo));
647
686
  if (!r) throw fail(400, `You haven’t shared ${repo} with @${h}. Add it under Change, where @${h} is your guest, and ask again.`);
648
687
  return r;
649
688
  }
@@ -657,11 +696,11 @@ export class Guests {
657
696
  const tags = [...text.matchAll(/(^|\s)#([\w.-]+)/g)];
658
697
  // A project of ours, not theirs: their agent would run in their folder
659
698
  // and never see ours. Say so, rather than quietly working somewhere else.
660
- const ours = tags.find((t) => !repos.some((r) => norm(r) === norm(t[2])) && this.office.repoPath(t[2]));
699
+ const ours = tags.find((t) => !repos.some((r) => same(r, t[2])) && this.office.repoPath(t[2]));
661
700
  if (ours) return { repo: this.lentHome(entry, ours[2]), home: true, text: text.replace(ours[0], ' ').replace(/[ \t]+/g, ' ').trim() };
662
701
  if (repos.length === 1) return { repo: repos[0], text };
663
702
  for (const t of tags) {
664
- const repo = repos.find((r) => norm(r) === norm(t[2]));
703
+ const repo = repos.find((r) => same(r, t[2]));
665
704
  if (repo) return { repo, text: text.replace(t[0], ' ').replace(/[ \t]+/g, ' ').trim() };
666
705
  }
667
706
  const said = norm(text);
@@ -680,10 +719,9 @@ export class Guests {
680
719
 
681
720
  // `#web@friend`: a host's project our agents may work in, or null.
682
721
  awayFolder(repo, host) {
683
- const norm = (v) => String(v).toLowerCase().replace(/[^a-z0-9]/g, '');
684
722
  for (const e of this.remote.values()) {
685
- if (norm(e.host) !== norm(host) || !['read', 'edit'].includes(e.theirAgents)) continue;
686
- const r = e.repos.find((x) => norm(x) === norm(repo));
723
+ if (!same(e.host, host) || !['read', 'edit'].includes(e.theirAgents)) continue;
724
+ const r = e.repos.find((x) => same(x, repo));
687
725
  if (r) return { link: e.link, host: e.host, repo: r, access: e.theirAgents };
688
726
  }
689
727
  return null;