agent-office-studio 0.5.0 → 0.6.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/CHANGELOG.md +12 -0
- package/dist/assets/index-B9ZRfVrF.js +4475 -0
- package/dist/assets/index-BiK6nglq.css +1 -0
- package/dist/index.html +2 -2
- package/docs/configuration.md +1 -1
- package/docs/linked-offices.md +60 -7
- package/package.json +2 -2
- package/server/guests.js +315 -7
- package/server/index.js +227 -18
- package/server/links.js +48 -9
- package/server/office-mcp.js +19 -0
- package/server/settings.js +1 -1
- package/server/visits.js +475 -0
- package/dist/assets/index-Cdivbl1d.css +0 -1
- package/dist/assets/index-CfqwI0KM.js +0 -4462
package/server/guests.js
CHANGED
|
@@ -6,8 +6,12 @@
|
|
|
6
6
|
// clearance cap, tokens per day). The guest's office is never trusted: it
|
|
7
7
|
// only ever sees its own jobs, and the feed it gets is cut down to them.
|
|
8
8
|
//
|
|
9
|
-
// As guest, the
|
|
10
|
-
//
|
|
9
|
+
// As guest, we keep the host's hello (its agents, the projects and clearance
|
|
10
|
+
// it shares, our jobs there) for as long as the link lives, and fold its feed
|
|
11
|
+
// into our own office: the boss tags the host's agents in our chat like our
|
|
12
|
+
// own, and their jobs sit in our list. Ids are namespaced `<link>.<id>` so a
|
|
13
|
+
// host can never name one of our jobs or agents. The older /ws/visit/:link
|
|
14
|
+
// page still works off the same requests.
|
|
11
15
|
// See docs/proposals/linked-offices.md, "Guests".
|
|
12
16
|
|
|
13
17
|
import crypto from 'node:crypto';
|
|
@@ -22,6 +26,9 @@ const CONNECT_WAIT_MS = 5_000;
|
|
|
22
26
|
const FEED_EVERY_MS = 250;
|
|
23
27
|
const FEED_BYTES = 48 * 1024; // under the relay's 64 KB a frame
|
|
24
28
|
const FEED_TYPES = new Set(['job', 'log', 'staff']);
|
|
29
|
+
const HELLO_RETRY_MS = 30_000;
|
|
30
|
+
const REMOTE_LOG = 600;
|
|
31
|
+
const MAX_THREADS = 300;
|
|
25
32
|
const utcDay = (ms = Date.now()) => new Date(ms).toISOString().slice(0, 10);
|
|
26
33
|
const fail = (code, message) => Object.assign(new Error(message), { code });
|
|
27
34
|
|
|
@@ -104,6 +111,10 @@ export function cleanFeed(m) {
|
|
|
104
111
|
if (m.t === 'job') { const job = cleanJob(m.job); return job && { t: 'job', job }; }
|
|
105
112
|
if (m.t === 'log') { const entry = cleanEntry(m.entry); return entry && id(m.jobId) && { t: 'log', jobId: m.jobId, entry }; }
|
|
106
113
|
if (m.t === 'staff') return Array.isArray(m.dismissed) ? { t: 'staff', dismissed: m.dismissed.filter((x) => id(x)).slice(0, 50) } : null;
|
|
114
|
+
// The host changed what we may do there; we ask for its hello again.
|
|
115
|
+
if (m.t === 'policy') return { t: 'policy' };
|
|
116
|
+
// The host stopped one of our agents working in its project.
|
|
117
|
+
if (m.t === 'visit') return id(m.job) && m.ended === 'stopped' ? { t: 'visit', job: m.job, ended: 'stopped' } : null;
|
|
107
118
|
return null;
|
|
108
119
|
}
|
|
109
120
|
|
|
@@ -115,6 +126,13 @@ const CLEAN_BODY = {
|
|
|
115
126
|
reply: () => ({ ok: true }),
|
|
116
127
|
stop: () => ({ ok: true }),
|
|
117
128
|
hello: (b) => b,
|
|
129
|
+
'visit.open': (b) => {
|
|
130
|
+
if (typeof b.sid !== 'string' || !/^[0-9a-f]{32}$/.test(b.sid)) throw fail(502, 'The host sent back a session that doesn’t look right.');
|
|
131
|
+
return { sid: b.sid, access: b.access === 'edit' ? 'edit' : 'read' };
|
|
132
|
+
},
|
|
133
|
+
// What the host says about its own files goes only to our agent, as text.
|
|
134
|
+
'visit.fs': (b) => ({ text: str(b.text, 64 * 1024) }),
|
|
135
|
+
'visit.close': () => ({ ok: true }),
|
|
118
136
|
};
|
|
119
137
|
|
|
120
138
|
// What a guest may see of one of its own jobs.
|
|
@@ -125,16 +143,30 @@ export function guestJob(j, tail = 40) {
|
|
|
125
143
|
|
|
126
144
|
export class Guests {
|
|
127
145
|
// office: { jobs(), createJob(body, extra), replyJob(job, body), stopJob(job), diff(job, path), staff(), modes() }
|
|
128
|
-
|
|
146
|
+
// office also has send(msg), which broadcasts to our own browsers.
|
|
147
|
+
// visits (as host): our side of a guest's own agents working in our
|
|
148
|
+
// projects; see visits.js.
|
|
149
|
+
constructor(dataDir, { links, office, visits = null }) {
|
|
129
150
|
this.links = links;
|
|
130
151
|
this.office = office;
|
|
152
|
+
this.copies = visits;
|
|
131
153
|
this.file = path.join(dataDir, 'guests.json');
|
|
132
154
|
this.used = {};
|
|
133
|
-
|
|
155
|
+
// Where each of our jobs at a host was asked for (chat thread and
|
|
156
|
+
// channel), and the project last used at each host.
|
|
157
|
+
this.asked = { threads: {}, lastRepo: {} };
|
|
158
|
+
try {
|
|
159
|
+
const saved = JSON.parse(fs.readFileSync(this.file, 'utf8'));
|
|
160
|
+
this.used = saved.used || {};
|
|
161
|
+
this.asked = { threads: saved.asked?.threads || {}, lastRepo: saved.asked?.lastRepo || {} };
|
|
162
|
+
} catch {}
|
|
163
|
+
this.remote = new Map(); // link id -> { link, host, staff, repos, clearance, jobs: Map(host job id -> job) }
|
|
164
|
+
this.helloing = new Map(); // link id -> retry-after time, while asking or after a failure
|
|
134
165
|
this.queue = new Map(); // link id -> messages waiting for the next feed frame
|
|
135
166
|
this.flushTimer = null;
|
|
136
167
|
this.visits = new Map(); // link id -> Set of browser sockets (as guest)
|
|
137
168
|
this.pending = new Map(); // req id -> { resolve, reject, timer } (as guest)
|
|
169
|
+
this.away = new Map(); // our job id -> { sid, access } while one of our agents works in a host's project
|
|
138
170
|
}
|
|
139
171
|
|
|
140
172
|
// Every req, res and feed frame on a guest link comes through here.
|
|
@@ -199,9 +231,21 @@ export class Guests {
|
|
|
199
231
|
staff: this.office.staff(),
|
|
200
232
|
modes: Object.fromEntries(Object.entries(this.office.modes()).filter(([k]) => CLEARANCE.indexOf(k) <= CLEARANCE.indexOf(p.clearance))),
|
|
201
233
|
jobs: this.jobsOf(link.id).map((j) => guestJob(j)),
|
|
202
|
-
policy: {
|
|
234
|
+
policy: {
|
|
235
|
+
repos: p.repos, clearance: p.clearance, tokensPerDay: p.tokensPerDay, tokensToday: this.tokensToday(link.id),
|
|
236
|
+
ourAgents: p.ourAgents !== false, theirAgents: this.copies ? p.theirAgents || 'off' : 'off',
|
|
237
|
+
},
|
|
203
238
|
};
|
|
239
|
+
case 'visit.open': case 'visit.fs': case 'visit.close': {
|
|
240
|
+
if (!this.copies) throw fail(400, 'Guests can’t do that here.');
|
|
241
|
+
if (op === 'visit.open') return this.copies.open(link, p, args);
|
|
242
|
+
// Every file call is held to the limits as they are now, not as they
|
|
243
|
+
// were when the work started.
|
|
244
|
+
if (!['read', 'edit'].includes(p.theirAgents)) throw fail(403, 'This office no longer lets your agents work in its projects.');
|
|
245
|
+
return op === 'visit.fs' ? this.copies.fs(link, args, p) : this.copies.close(link, args);
|
|
246
|
+
}
|
|
204
247
|
case 'createJob': {
|
|
248
|
+
if (p.ourAgents === false) throw fail(403, 'This office lets your own agents work in its projects, but doesn’t lend its agents. Send one of yours instead.');
|
|
205
249
|
if (!p.repos.includes(args.repo)) throw fail(403, 'That project isn’t shared with you.');
|
|
206
250
|
const mode = this.capMode(p, args.mode);
|
|
207
251
|
this.checkTokens(link, p);
|
|
@@ -214,6 +258,7 @@ export class Guests {
|
|
|
214
258
|
return { job: guestJob(job) };
|
|
215
259
|
}
|
|
216
260
|
case 'reply': {
|
|
261
|
+
if (p.ourAgents === false) throw fail(403, 'This office doesn’t lend its agents any more.');
|
|
217
262
|
const job = this.ownJob(link, args.job);
|
|
218
263
|
const mode = args.mode === undefined ? undefined : this.capMode(p, args.mode);
|
|
219
264
|
this.checkTokens(link, p);
|
|
@@ -254,7 +299,9 @@ export class Guests {
|
|
|
254
299
|
for (const [id, u] of Object.entries(this.used)) if (u.day !== day) delete this.used[id];
|
|
255
300
|
try {
|
|
256
301
|
fs.mkdirSync(path.dirname(this.file), { recursive: true });
|
|
257
|
-
|
|
302
|
+
const threads = Object.entries(this.asked.threads);
|
|
303
|
+
if (threads.length > MAX_THREADS) this.asked.threads = Object.fromEntries(threads.slice(-MAX_THREADS));
|
|
304
|
+
fs.writeFileSync(this.file + '.tmp', JSON.stringify({ used: this.used, asked: this.asked }));
|
|
258
305
|
fs.renameSync(this.file + '.tmp', this.file);
|
|
259
306
|
} catch (err) { console.error('[guests] could not save', err.message); }
|
|
260
307
|
}
|
|
@@ -305,6 +352,7 @@ export class Guests {
|
|
|
305
352
|
for (const ws of socks) ws.close(4404, 'This office unlinked you.');
|
|
306
353
|
this.visits.delete(linkId);
|
|
307
354
|
}
|
|
355
|
+
this.syncRemote();
|
|
308
356
|
}
|
|
309
357
|
|
|
310
358
|
// ---------- as guest ----------
|
|
@@ -343,11 +391,13 @@ export class Guests {
|
|
|
343
391
|
}
|
|
344
392
|
|
|
345
393
|
fromHost(link, msg) {
|
|
394
|
+
if (!Array.isArray(msg.msgs)) return;
|
|
346
395
|
const socks = this.visits.get(link.id);
|
|
347
|
-
if (!socks?.size || !Array.isArray(msg.msgs)) return;
|
|
348
396
|
for (const raw of msg.msgs.slice(0, 500)) {
|
|
349
397
|
const m = cleanFeed(raw);
|
|
350
398
|
if (!m) continue;
|
|
399
|
+
this.remoteFeed(link, m);
|
|
400
|
+
if (!socks?.size) continue;
|
|
351
401
|
const data = JSON.stringify(m);
|
|
352
402
|
for (const ws of socks) if (ws.readyState === 1) ws.send(data);
|
|
353
403
|
}
|
|
@@ -370,6 +420,7 @@ export class Guests {
|
|
|
370
420
|
link: link.id, host: link.peer,
|
|
371
421
|
repos: (Array.isArray(policy.repos) ? policy.repos : []).filter((r) => typeof r === 'string' && REPO.test(r)).slice(0, 50),
|
|
372
422
|
clearance, tokensPerDay: count(policy.tokensPerDay), tokensToday: count(policy.tokensToday),
|
|
423
|
+
ourAgents: policy.ourAgents !== false, theirAgents: ['read', 'edit'].includes(policy.theirAgents) ? policy.theirAgents : 'off',
|
|
373
424
|
},
|
|
374
425
|
};
|
|
375
426
|
}
|
|
@@ -388,4 +439,261 @@ export class Guests {
|
|
|
388
439
|
if (ws.readyState !== 1) return;
|
|
389
440
|
ws.send(JSON.stringify({ ...extra(), ...this.cleanHello(link, hello) }));
|
|
390
441
|
}
|
|
442
|
+
|
|
443
|
+
// ---------- a host's agents, from our own office ----------
|
|
444
|
+
|
|
445
|
+
// Ask every host that made us its guest for its hello, once per link (and
|
|
446
|
+
// again after a failure, no sooner than HELLO_RETRY_MS). Cheap to call on
|
|
447
|
+
// every links change: it does nothing for hosts it already knows.
|
|
448
|
+
syncRemote() {
|
|
449
|
+
const hosts = this.links.links.filter((l) => l.kind === 'guest' && l.role === 'guest');
|
|
450
|
+
let gone = false;
|
|
451
|
+
for (const id of this.remote.keys()) if (!hosts.some((l) => l.id === id)) { this.remote.delete(id); gone = true; }
|
|
452
|
+
if (gone) this.office.send({ t: 'remote', offices: this.remoteStatus(), away: this.awayFolders() });
|
|
453
|
+
if (!this.links.status().connected) return;
|
|
454
|
+
for (const l of hosts) {
|
|
455
|
+
if (this.remote.has(l.id) || (this.helloing.get(l.id) || 0) > Date.now()) continue;
|
|
456
|
+
this.remoteHello(l).catch(() => {});
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
async remoteHello(link) {
|
|
461
|
+
this.helloing.set(link.id, Infinity);
|
|
462
|
+
try {
|
|
463
|
+
const hello = this.cleanHello(link, await this.request(link.id, 'hello'));
|
|
464
|
+
const entry = {
|
|
465
|
+
link: link.id, host: link.peer, staff: hello.staff, repos: hello.visiting.repos, clearance: hello.visiting.clearance,
|
|
466
|
+
ourAgents: hello.visiting.ourAgents, theirAgents: hello.visiting.theirAgents,
|
|
467
|
+
jobs: new Map(hello.jobs.map((j) => [j.id, j])),
|
|
468
|
+
};
|
|
469
|
+
if (!this.guestLinkOrNull(link.id)) return null; // unlinked while we asked
|
|
470
|
+
this.remote.set(link.id, entry);
|
|
471
|
+
this.helloing.delete(link.id);
|
|
472
|
+
this.office.send({ t: 'remote', offices: this.remoteStatus(), away: this.awayFolders() });
|
|
473
|
+
for (const j of entry.jobs.values()) this.office.send({ t: 'job', job: this.localJob(entry, j) });
|
|
474
|
+
return entry;
|
|
475
|
+
} catch (err) {
|
|
476
|
+
this.helloing.set(link.id, Date.now() + HELLO_RETRY_MS);
|
|
477
|
+
throw err;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
guestLinkOrNull(linkId) {
|
|
482
|
+
try { return this.guestLink(linkId); } catch { return null; }
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// A host's job as our page sees it: namespaced ids, where it was asked
|
|
486
|
+
// for, and which office it runs in. `from` is us, so it's dropped.
|
|
487
|
+
localJob(entry, j, tail = 40) {
|
|
488
|
+
const key = `${entry.link}.${j.id}`;
|
|
489
|
+
const { guest: _g, from: _f, ...rest } = j;
|
|
490
|
+
return {
|
|
491
|
+
...rest, id: key, agentId: j.agentId ? `${entry.link}.${j.agentId}` : null,
|
|
492
|
+
remote: { link: entry.link, host: entry.host }, ...this.asked.threads[key],
|
|
493
|
+
log: (j.log || []).slice(-tail),
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// The host's agents, for our page. `here` says who it has on the floor,
|
|
498
|
+
// from its presence; staff it hasn't hired can't be tagged.
|
|
499
|
+
remoteStatus() {
|
|
500
|
+
return [...this.remote.values()].map((e) => {
|
|
501
|
+
const present = this.links.presentAt(e.link);
|
|
502
|
+
const here = present && new Set(present.map((a) => a.id));
|
|
503
|
+
return {
|
|
504
|
+
link: e.link, host: e.host, repos: e.repos, clearance: e.clearance, theirAgents: e.theirAgents || 'off',
|
|
505
|
+
// A host that only lets our own agents in has none of its to tag.
|
|
506
|
+
staff: e.ourAgents === false ? [] : e.staff.map((s) => ({ ...s, id: `${e.link}.${s.id}`, here: here ? here.has(s.id) : true })),
|
|
507
|
+
};
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
remoteJobs() {
|
|
512
|
+
return [...this.remote.values()].flatMap((e) => [...e.jobs.values()].map((j) => this.localJob(e, j)));
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// One feed message from a host, folded into our office.
|
|
516
|
+
remoteFeed(link, m) {
|
|
517
|
+
if (m.t === 'policy') {
|
|
518
|
+
this.remote.delete(link.id);
|
|
519
|
+
this.helloing.delete(link.id);
|
|
520
|
+
this.syncRemote();
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
if (m.t === 'visit') {
|
|
524
|
+
this.office.awayStopped?.(m.job, link.id);
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
const entry = this.remote.get(link.id);
|
|
528
|
+
if (!entry) return;
|
|
529
|
+
if (m.t === 'job') {
|
|
530
|
+
const prev = entry.jobs.get(m.job.id);
|
|
531
|
+
const job = { ...m.job, log: prev?.log || [] };
|
|
532
|
+
entry.jobs.set(job.id, job);
|
|
533
|
+
this.office.send({ t: 'job', job: this.localJob(entry, job, 0) });
|
|
534
|
+
if (prev?.status === 'working' && job.status !== 'working' && job.status !== 'queued') this.office.remoteEnded?.(this.localJob(entry, job, 1), prev);
|
|
535
|
+
} else if (m.t === 'log') {
|
|
536
|
+
const job = entry.jobs.get(m.jobId);
|
|
537
|
+
if (!job) return;
|
|
538
|
+
job.log.push(m.entry);
|
|
539
|
+
if (job.log.length > REMOTE_LOG) job.log.splice(0, job.log.length - REMOTE_LOG);
|
|
540
|
+
this.office.send({ t: 'log', jobId: `${entry.link}.${job.id}`, entry: m.entry });
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// `<link>.<host job id>` -> the host and its id, or null if it isn't one.
|
|
545
|
+
remoteKey(key) {
|
|
546
|
+
const m = /^([A-Za-z0-9_-]{1,64})\.([A-Za-z0-9_-]{1,64})$/.exec(String(key ?? ''));
|
|
547
|
+
const entry = m && this.remote.get(m[1]);
|
|
548
|
+
return entry ? { entry, id: m[2] } : null;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// The remote job a chat thread is about, if any.
|
|
552
|
+
threadJob(root) {
|
|
553
|
+
const key = Object.keys(this.asked.threads).findLast((k) => this.asked.threads[k].thread === root);
|
|
554
|
+
return key && this.remoteKey(key) ? key : null;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// The highest clearance at or below `mode` that the host allows.
|
|
558
|
+
capRemote(entry, mode) {
|
|
559
|
+
const want = CLEARANCE.includes(mode) ? mode : 'edit';
|
|
560
|
+
return CLEARANCE[Math.min(CLEARANCE.indexOf(want), CLEARANCE.indexOf(entry.clearance))];
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// What /api/jobs/<link>.<id> does for a host's job.
|
|
564
|
+
async remoteOp(key, op, args = {}) {
|
|
565
|
+
const r = this.remoteKey(key);
|
|
566
|
+
if (!r) throw fail(404, 'No such job.');
|
|
567
|
+
const { entry, id } = r;
|
|
568
|
+
if (op === 'job') {
|
|
569
|
+
const { job } = await this.request(entry.link, 'job', { job: id });
|
|
570
|
+
entry.jobs.set(job.id, job);
|
|
571
|
+
return this.localJob(entry, job, 500);
|
|
572
|
+
}
|
|
573
|
+
if (op === 'diff') return this.request(entry.link, 'diff', { job: id, path: String(args.path ?? '') });
|
|
574
|
+
if (op === 'stop') return this.request(entry.link, 'stop', { job: id });
|
|
575
|
+
if (op === 'reply') {
|
|
576
|
+
if (Array.isArray(args.files) && args.files.length) throw fail(400, `Files can’t go to @${entry.host}’s office. Paste the text instead.`);
|
|
577
|
+
const text = String(args.message ?? args.text ?? '').trim();
|
|
578
|
+
if (!text) throw fail(400, 'Say something first.');
|
|
579
|
+
return this.request(entry.link, 'reply', { job: id, text, mode: args.mode === undefined ? undefined : this.capRemote(entry, args.mode) });
|
|
580
|
+
}
|
|
581
|
+
throw fail(400, 'Can’t do that with a linked office’s job.');
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// A host agent tagged in `text`. `@Name@host` always means the host's;
|
|
585
|
+
// a bare `@Name` only when none of ours answers to it (isLocal).
|
|
586
|
+
tagged(text, isLocal) {
|
|
587
|
+
const norm = (v) => String(v).toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
588
|
+
const offices = this.remoteStatus();
|
|
589
|
+
const find = (name, host) => {
|
|
590
|
+
for (const o of offices) {
|
|
591
|
+
if (host && norm(o.host) !== norm(host)) continue;
|
|
592
|
+
const s = o.staff.find((x) => x.here && norm(x.name) === norm(name));
|
|
593
|
+
if (s) return { link: o.link, host: o.host, staff: s };
|
|
594
|
+
}
|
|
595
|
+
return null;
|
|
596
|
+
};
|
|
597
|
+
for (const m of text.matchAll(/@([\w-]+)@([\w-]+)/g)) {
|
|
598
|
+
const hit = find(m[1], m[2]);
|
|
599
|
+
if (hit) return { ...hit, clean: text.replace(m[0], ' ').replace(/[ \t]+/g, ' ').trim() };
|
|
600
|
+
}
|
|
601
|
+
for (const m of text.matchAll(/(^|[^\w@])@([\w-]+)(?![\w@-])/g)) {
|
|
602
|
+
if (isLocal(m[2])) continue;
|
|
603
|
+
const hit = find(m[2]);
|
|
604
|
+
if (hit) {
|
|
605
|
+
const re = new RegExp(`@${m[2]}(?![\\w@-])`);
|
|
606
|
+
return { ...hit, clean: text.replace(re, ' ').replace(/[ \t]+/g, ' ').trim() };
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
return null;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// The project a job at a host goes in: the only one it shares, one tagged
|
|
613
|
+
// or named in the brief, or the one used there last time.
|
|
614
|
+
remoteRepo(entry, text) {
|
|
615
|
+
const norm = (v) => String(v).toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
616
|
+
const { repos } = entry;
|
|
617
|
+
if (!repos.length) throw fail(403, `@${entry.host} hasn’t shared any projects with you.`);
|
|
618
|
+
if (repos.length === 1) return { repo: repos[0], text };
|
|
619
|
+
const tags = [...text.matchAll(/(^|\s)#([\w.-]+)/g)];
|
|
620
|
+
for (const t of tags) {
|
|
621
|
+
const repo = repos.find((r) => norm(r) === norm(t[2]));
|
|
622
|
+
if (repo) return { repo, text: text.replace(t[0], ' ').replace(/[ \t]+/g, ' ').trim() };
|
|
623
|
+
}
|
|
624
|
+
const said = norm(text);
|
|
625
|
+
const named = repos.filter((r) => norm(r).length >= 3 && said.includes(norm(r)));
|
|
626
|
+
if (named.length === 1) return { repo: named[0], text };
|
|
627
|
+
const last = this.asked.lastRepo[entry.link];
|
|
628
|
+
if (repos.includes(last)) return { repo: last, text };
|
|
629
|
+
throw fail(400, `Which of @${entry.host}’s projects is that for? Say ${repos.map((r) => r).join(', ')} in the message.`);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// ---------- our agents in a host's projects ----------
|
|
633
|
+
//
|
|
634
|
+
// A host can let our own agents work in its projects: the agent runs here,
|
|
635
|
+
// on our account, and its file tools are requests to the host (visit.*),
|
|
636
|
+
// which works on a copy and answers in text. See visits.js for the host's side.
|
|
637
|
+
|
|
638
|
+
// `#web@friend`: a host's project our agents may work in, or null.
|
|
639
|
+
awayFolder(repo, host) {
|
|
640
|
+
const norm = (v) => String(v).toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
641
|
+
for (const e of this.remote.values()) {
|
|
642
|
+
if (norm(e.host) !== norm(host) || !['read', 'edit'].includes(e.theirAgents)) continue;
|
|
643
|
+
const r = e.repos.find((x) => norm(x) === norm(repo));
|
|
644
|
+
if (r) return { link: e.link, host: e.host, repo: r, access: e.theirAgents };
|
|
645
|
+
}
|
|
646
|
+
return null;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// Every host project our agents may work in, for the page's # menu.
|
|
650
|
+
awayFolders() {
|
|
651
|
+
return [...this.remote.values()].filter((e) => ['read', 'edit'].includes(e.theirAgents))
|
|
652
|
+
.flatMap((e) => e.repos.map((repo) => ({ link: e.link, host: e.host, repo, access: e.theirAgents })));
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// One file call from our agent's job, as a request to the host. The first
|
|
656
|
+
// opens the session there; a session the host has lost is opened again once.
|
|
657
|
+
async awayFs(job, agent, op, args) {
|
|
658
|
+
const { link, repo } = job.away;
|
|
659
|
+
const open = async () => {
|
|
660
|
+
const s = await this.request(link, 'visit.open', { repo, job: job.id, title: job.title, agent });
|
|
661
|
+
this.away.set(job.id, s);
|
|
662
|
+
job.away.access = s.access;
|
|
663
|
+
return s;
|
|
664
|
+
};
|
|
665
|
+
let s = this.away.get(job.id) || await open();
|
|
666
|
+
try {
|
|
667
|
+
return (await this.request(link, 'visit.fs', { ...args, sid: s.sid, op })).text;
|
|
668
|
+
} catch (err) {
|
|
669
|
+
if (err.code !== 404 || !/session has ended/.test(err.message)) throw err;
|
|
670
|
+
this.away.delete(job.id);
|
|
671
|
+
s = await open();
|
|
672
|
+
return (await this.request(link, 'visit.fs', { ...args, sid: s.sid, op })).text;
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// Our job ended: the host closes its side and works out what changed.
|
|
677
|
+
async awayClose(job, status, summary) {
|
|
678
|
+
const s = this.away.get(job.id);
|
|
679
|
+
if (!s) return;
|
|
680
|
+
this.away.delete(job.id);
|
|
681
|
+
await this.request(job.away.link, 'visit.close', { sid: s.sid, status, summary: String(summary || '').slice(0, 2000) });
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// Start a job with one of a host's agents, from our chat.
|
|
685
|
+
async startRemote({ link, staff, text, mode, model, thread, channel }) {
|
|
686
|
+
const entry = this.remote.get(link) || await this.remoteHello(this.guestLink(link));
|
|
687
|
+
const { repo, text: brief } = this.remoteRepo(entry, text);
|
|
688
|
+
if (!brief) throw fail(400, 'Say what the job is, as well as who it’s for.');
|
|
689
|
+
const agentId = staff.id.slice(link.length + 1);
|
|
690
|
+
const { job } = await this.request(link, 'createJob', { repo, prompt: brief, mode: this.capRemote(entry, mode), model, agentId });
|
|
691
|
+
this.asked.threads[`${link}.${job.id}`] = { thread, channel };
|
|
692
|
+
this.asked.lastRepo[link] = repo;
|
|
693
|
+
this.save();
|
|
694
|
+
entry.jobs.set(job.id, job);
|
|
695
|
+
const local = this.localJob(entry, job);
|
|
696
|
+
this.office.send({ t: 'job', job: local });
|
|
697
|
+
return { job: local, entry, repo, capped: this.capRemote(entry, mode) !== mode };
|
|
698
|
+
}
|
|
391
699
|
}
|