agent-office-studio 0.2.0 → 0.3.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.
@@ -0,0 +1,350 @@
1
+ // Linked offices, from this machine's side.
2
+ //
3
+ // One socket, opened outwards to the relay (the leaderboard service), carries
4
+ // everything: our presence out to linked offices, theirs back in, and links
5
+ // being made and broken. Nothing listens for the outside world. What goes out
6
+ // is names, roles, looks and states, so a linked office sees whether an agent
7
+ // is busy but not on what; job titles only with OFFICE_SHARE_JOB_TITLES=1
8
+ // (presence() in index.js decides). No repos, logs, prompts or chat. Everything that comes in is checked against the shapes in
9
+ // docs/proposals/linked-offices.md and cut down to size before the browser
10
+ // sees it.
11
+ //
12
+ // Guest frames (req, res, feed) are handed to `onGuest`; this file only
13
+ // carries them.
14
+
15
+ import fs from 'node:fs';
16
+ import path from 'node:path';
17
+ import WebSocket from 'ws';
18
+
19
+ const PRESENCE_GAP_MS = 5000;
20
+ const BACKOFF_MIN_MS = 1000;
21
+ const BACKOFF_MAX_MS = 60_000;
22
+ const REPLACED = 4000; // the relay closes with this when another copy of this office connects
23
+ const DELETED = 4001; // and with this when the office deleted itself on the service
24
+ const PING_MS = 30_000; // the relay answers { t: 'ping' } without waking up; silence means a dead socket
25
+ const MAX_AGENTS = 16;
26
+ const STATES = new Set(['working', 'idle', 'break', 'meeting', 'away']);
27
+ const CODE_RE = /^[0-9A-HJKMNP-TV-Z]{8}$/;
28
+ const ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
29
+ const HEX_RE = /^#[0-9a-f]{6}$/i;
30
+ const CLEARANCES = new Set(['plan', 'edit', 'auto']); // MODES in index.js: auto is Full autonomy
31
+
32
+ const fail = (code, message) => Object.assign(new Error(message), { code });
33
+ // Plain text of at most `n` characters, with control characters dropped.
34
+ const text = (v, n) => (typeof v === 'string' ? v.replace(/[\u0000-\u001f\u007f]/g, ' ').trim().slice(0, n) : '');
35
+
36
+ // Codes are typed by people: any case, dashes and spaces are fine, and the
37
+ // letters people mix up with digits read as those digits (Crockford).
38
+ export function normaliseCode(code) {
39
+ return String(code ?? '').toUpperCase().replace(/[\s-]/g, '').replace(/[IL]/g, '1').replace(/O/g, '0');
40
+ }
41
+
42
+ function cleanLink(l) {
43
+ if (!l || !ID_RE.test(String(l.id))) return null;
44
+ return {
45
+ id: String(l.id),
46
+ peer: text(l.peer, 20),
47
+ kind: l.kind === 'guest' ? 'guest' : 'link',
48
+ role: ['host', 'guest'].includes(l.role) ? l.role : 'peer',
49
+ invite: ID_RE.test(String(l.invite ?? '')) ? String(l.invite) : null,
50
+ createdAt: Number.isFinite(l.createdAt) ? l.createdAt : null,
51
+ };
52
+ }
53
+
54
+ function cleanAgent(a) {
55
+ if (!a || !ID_RE.test(String(a.id))) return null;
56
+ const look = {};
57
+ for (const k of ['shirt', 'hair', 'skin']) if (HEX_RE.test(a.look?.[k] ?? '')) look[k] = a.look[k];
58
+ const title = text(a.job?.title, 120);
59
+ return {
60
+ id: String(a.id),
61
+ name: text(a.name, 40) || String(a.id),
62
+ role: text(a.role, 60),
63
+ look,
64
+ state: STATES.has(a.state) ? a.state : 'idle',
65
+ job: title ? { title } : null,
66
+ };
67
+ }
68
+
69
+ export class Links {
70
+ // board: the Leaderboard, which owns the handle, the secret and the service address.
71
+ // presence(): this office's agents, as the presence frame lists them.
72
+ constructor(dataDir, { board, presence, onChange = () => {}, onGuest = () => {} }) {
73
+ this.file = path.join(dataDir, 'links.json');
74
+ this.board = board;
75
+ this.presence = presence;
76
+ this.onChange = onChange;
77
+ this.onGuest = onGuest;
78
+ // What we keep: the last list the relay gave us, so the panel has
79
+ // something to show offline; guest limits by invite id, then by link id
80
+ // once the guest arrives; and when our newest open invite runs out, so we
81
+ // stay connected to hear it being used.
82
+ this.saved = { links: [], guests: {}, invitesUntil: 0 };
83
+ try { this.saved = { ...this.saved, ...JSON.parse(fs.readFileSync(this.file, 'utf8')) }; } catch {}
84
+ this.visitors = new Map(); // link id -> { link, handle, agents }
85
+ this.ws = null;
86
+ this.listed = false; // the relay has sent this socket its list of links
87
+ this.backoff = BACKOFF_MIN_MS;
88
+ this.retry = null;
89
+ this.replaced = false;
90
+ this.lastSent = '';
91
+ this.sentAt = 0;
92
+ this.presenceTimer = null;
93
+ this.pingTimer = null;
94
+ this.error = null;
95
+ this.connect();
96
+ }
97
+
98
+ get links() { return this.saved.links; }
99
+
100
+ status() {
101
+ return {
102
+ handle: this.board.me.handle || null,
103
+ connected: this.ws?.readyState === WebSocket.OPEN,
104
+ error: this.error,
105
+ links: this.links.map((l) => ({ ...l, guest: l.role === 'host' ? this.saved.guests[l.id] || null : undefined })),
106
+ };
107
+ }
108
+
109
+ visiting() {
110
+ return [...this.visitors.values()];
111
+ }
112
+
113
+ // A guest link's limits, as the host set them. Null if this isn't one we host.
114
+ guestPolicy(linkId) {
115
+ const l = this.links.find((x) => x.id === linkId);
116
+ return l?.role === 'host' ? this.saved.guests[l.id] || null : null;
117
+ }
118
+
119
+ save() {
120
+ fs.mkdirSync(path.dirname(this.file), { recursive: true });
121
+ const tmp = this.file + '.tmp';
122
+ fs.writeFileSync(tmp, JSON.stringify(this.saved), { mode: 0o600 });
123
+ fs.renameSync(tmp, this.file);
124
+ }
125
+
126
+ // ---------- the socket ----------
127
+
128
+ // Worth holding a socket only if there is someone to talk to, or someone
129
+ // who might turn up with one of our codes.
130
+ get wanted() {
131
+ return this.board.named && !this.replaced && (this.links.length > 0 || this.saved.invitesUntil > Date.now());
132
+ }
133
+
134
+ connect() {
135
+ clearTimeout(this.retry);
136
+ if (this.ws || !this.wanted) return;
137
+ let base;
138
+ try { base = this.board.endpoint(); } catch (err) { this.error = err.message; return; }
139
+ const url = base.replace(/^http/, 'ws') + '/v1/socket';
140
+ this.listed = false;
141
+ const ws = new WebSocket(url, { headers: { authorization: `Bearer ${this.board.me.secret}` }, maxPayload: 64 * 1024, handshakeTimeout: 10_000 });
142
+ this.ws = ws;
143
+ ws.on('open', () => {
144
+ let unanswered = 0;
145
+ ws.on('message', () => { unanswered = 0; });
146
+ this.pingTimer = setInterval(() => {
147
+ if (++unanswered > 2) return ws.terminate();
148
+ ws.send('{"t":"ping"}');
149
+ }, PING_MS);
150
+ this.pingTimer.unref?.();
151
+ this.backoff = BACKOFF_MIN_MS;
152
+ this.error = null;
153
+ this.lastSent = '';
154
+ this.sendPresence(true);
155
+ this.onChange();
156
+ });
157
+ ws.on('message', (data) => {
158
+ let msg;
159
+ try { msg = JSON.parse(data); } catch { return; }
160
+ if (msg && typeof msg === 'object') this.receive(msg);
161
+ });
162
+ ws.on('unexpected-response', (_req, res) => {
163
+ this.error = res.statusCode === 401 ? 'The relay doesn’t know this office.' : `The relay answered ${res.statusCode}.`;
164
+ });
165
+ ws.on('error', () => { this.error ||= 'Couldn’t reach the relay.'; });
166
+ ws.on('close', (code) => {
167
+ clearInterval(this.pingTimer);
168
+ if (this.ws === ws) this.ws = null;
169
+ if (code === REPLACED || code === DELETED) {
170
+ // Another copy of this office took over, and connecting again would
171
+ // only knock it off in turn; or the office is gone from the service.
172
+ this.replaced = true;
173
+ this.error = code === DELETED ? 'This office was deleted from the relay.' : 'Another copy of this office is connected to the relay.';
174
+ }
175
+ this.visitors.clear();
176
+ this.onChange();
177
+ if (!this.wanted) return;
178
+ const wait = this.backoff * (0.75 + Math.random() / 2);
179
+ this.backoff = Math.min(this.backoff * 2, BACKOFF_MAX_MS);
180
+ this.retry = setTimeout(() => this.connect(), wait);
181
+ this.retry.unref?.();
182
+ });
183
+ }
184
+
185
+ disconnect() {
186
+ clearTimeout(this.retry);
187
+ clearInterval(this.pingTimer);
188
+ const ws = this.ws;
189
+ this.ws = null;
190
+ ws?.close(1000);
191
+ this.visitors.clear();
192
+ }
193
+
194
+ send(frame) {
195
+ if (this.ws?.readyState !== WebSocket.OPEN) return false;
196
+ this.ws.send(JSON.stringify(frame));
197
+ return true;
198
+ }
199
+
200
+ receive(msg) {
201
+ switch (msg.t) {
202
+ case 'links':
203
+ this.listed = true;
204
+ this.setLinks((Array.isArray(msg.links) ? msg.links : []).map(cleanLink).filter(Boolean));
205
+ break;
206
+ case 'link': {
207
+ const l = cleanLink(msg.link);
208
+ if (l) this.setLinks([...this.links.filter((x) => x.id !== l.id), l]);
209
+ break;
210
+ }
211
+ case 'unlink':
212
+ this.setLinks(this.links.filter((x) => x.id !== String(msg.link?.id)));
213
+ break;
214
+ case 'presence': {
215
+ const link = this.links.find((x) => x.id === String(msg.link));
216
+ if (!link) return;
217
+ const agents = (Array.isArray(msg.agents) ? msg.agents : []).slice(0, MAX_AGENTS).map(cleanAgent).filter(Boolean);
218
+ this.visitors.set(link.id, { link: link.id, handle: text(msg.from, 20) || link.peer, agents });
219
+ break;
220
+ }
221
+ case 'want-presence':
222
+ this.sendPresence(true);
223
+ return;
224
+ case 'req': case 'res': case 'feed': {
225
+ // Only on a guest link the relay listed for this socket, and only the
226
+ // way round the roles allow: what we saved from an earlier socket may
227
+ // hold a link that has since been ended.
228
+ if (!this.listed) return;
229
+ const link = this.links.find((x) => x.id === String(msg.link));
230
+ if (link?.kind !== 'guest') return;
231
+ if (msg.t === 'req' ? link.role !== 'host' : link.role !== 'guest') return;
232
+ this.onGuest(link, msg);
233
+ return;
234
+ }
235
+ case 'error':
236
+ console.warn('[links] the relay dropped a frame:', text(msg.error, 200));
237
+ return;
238
+ default:
239
+ return;
240
+ }
241
+ this.onChange();
242
+ }
243
+
244
+ // The relay's list is the truth. Guest limits move from the invite that
245
+ // made a link to the link itself, and go when the link does.
246
+ setLinks(links) {
247
+ const guests = {};
248
+ for (const l of links) {
249
+ if (l.role !== 'host') continue;
250
+ const policy = this.saved.guests[l.id] || (l.invite && this.saved.guests[`invite:${l.invite}`]);
251
+ if (policy) guests[l.id] = policy;
252
+ }
253
+ for (const [k, v] of Object.entries(this.saved.guests)) {
254
+ if (k.startsWith('invite:') && v.expiresAt > Date.now() && !links.some((l) => `invite:${l.invite}` === k)) guests[k] = v;
255
+ }
256
+ const ids = new Set(links.map((l) => l.id));
257
+ for (const id of this.visitors.keys()) if (!ids.has(id)) this.visitors.delete(id);
258
+ const grew = links.length > this.links.length;
259
+ this.saved = { ...this.saved, links, guests };
260
+ this.save();
261
+ if (grew) this.sendPresence(true);
262
+ if (!this.wanted) this.disconnect();
263
+ }
264
+
265
+ // Called whenever something an agent is doing might have changed. Sends
266
+ // only real changes, at most once per PRESENCE_GAP_MS, unless `now`.
267
+ sendPresence(now = false) {
268
+ if (!this.links.length) return;
269
+ const agents = this.presence().slice(0, MAX_AGENTS).map(cleanAgent).filter(Boolean);
270
+ const body = JSON.stringify(agents);
271
+ if (!now && body === this.lastSent) return;
272
+ clearTimeout(this.presenceTimer);
273
+ const wait = now ? 0 : this.sentAt + PRESENCE_GAP_MS - Date.now();
274
+ if (wait > 0) {
275
+ this.presenceTimer = setTimeout(() => this.sendPresence(), wait);
276
+ this.presenceTimer.unref?.();
277
+ return;
278
+ }
279
+ if (this.send({ t: 'presence', all: true, agents })) {
280
+ this.lastSent = body;
281
+ this.sentAt = Date.now();
282
+ }
283
+ }
284
+
285
+ // ---------- what the panel asks for ----------
286
+
287
+ async ensureHandle(handle) {
288
+ if (!this.board.named) await this.board.claim(handle);
289
+ if (!this.board.named) throw fail(400, 'Pick a handle first.');
290
+ }
291
+
292
+ async refresh() {
293
+ if (!this.board.named) return this.status();
294
+ const out = await this.board.call('GET', '/v1/links');
295
+ this.setLinks((out.links || []).map(cleanLink).filter(Boolean));
296
+ this.connect();
297
+ return this.status();
298
+ }
299
+
300
+ async invite({ kind = 'link', guest, handle } = {}) {
301
+ await this.ensureHandle(handle);
302
+ kind = kind === 'guest' ? 'guest' : 'link';
303
+ let policy = null;
304
+ if (kind === 'guest') {
305
+ const repos = Array.isArray(guest?.repos) ? [...new Set(guest.repos.map(String).filter(Boolean))] : [];
306
+ if (!repos.length) throw fail(400, 'Pick at least one project the guest can use.');
307
+ const clearance = CLEARANCES.has(guest?.clearance) ? guest.clearance : 'edit';
308
+ const tokensPerDay = Number.isInteger(guest?.tokensPerDay) && guest.tokensPerDay > 0 ? guest.tokensPerDay : 2_000_000;
309
+ policy = { repos, clearance, tokensPerDay };
310
+ }
311
+ const out = await this.board.call('POST', '/v1/invites', { body: { kind } });
312
+ const expiresAt = Number(out.expiresAt) || Date.now() + 86_400_000;
313
+ if (policy) this.saved.guests[`invite:${out.id}`] = { ...policy, expiresAt };
314
+ this.saved.invitesUntil = Math.max(this.saved.invitesUntil, expiresAt);
315
+ this.save();
316
+ this.connect();
317
+ const code = normaliseCode(out.code);
318
+ return { id: out.id, code: `${code.slice(0, 4)}-${code.slice(4)}`, kind, expiresAt, guest: policy };
319
+ }
320
+
321
+ async redeem({ code, handle } = {}) {
322
+ code = normaliseCode(code);
323
+ if (!CODE_RE.test(code)) throw fail(400, 'A code is 8 letters and numbers, like K7QX-M2PD.');
324
+ await this.ensureHandle(handle);
325
+ const out = await this.board.call('POST', '/v1/invites/redeem', { body: { code } });
326
+ const link = cleanLink(out.link);
327
+ if (link) this.setLinks([...this.links.filter((x) => x.id !== link.id), link]);
328
+ this.connect();
329
+ return this.status();
330
+ }
331
+
332
+ async unlink(id) {
333
+ id = String(id);
334
+ if (!this.links.some((l) => l.id === id)) throw fail(404, 'No such link.');
335
+ try { await this.board.call('DELETE', `/v1/links/${encodeURIComponent(id)}`); }
336
+ catch (err) { if (err.code !== 404) throw err; } // already gone on the relay's side
337
+ this.setLinks(this.links.filter((l) => l.id !== id));
338
+ this.onChange();
339
+ return this.status();
340
+ }
341
+
342
+ // The handle was forgotten (left the board without keeping it): the relay
343
+ // has dropped our links with it.
344
+ forget() {
345
+ this.disconnect();
346
+ this.saved = { links: [], guests: {}, invitesUntil: 0 };
347
+ try { fs.rmSync(this.file, { force: true }); } catch {}
348
+ this.onChange();
349
+ }
350
+ }
@@ -9,6 +9,7 @@ import { spawn } from 'node:child_process';
9
9
  import { fileURLToPath } from 'node:url';
10
10
 
11
11
  const RESTART = 75;
12
+ const FATAL = 78; // a setup problem (port taken) that another go won't fix
12
13
  const SERVER = path.join(path.dirname(fileURLToPath(import.meta.url)), 'index.js');
13
14
  // A server that falls over this soon after a restart won't fare better on
14
15
  // the next go, so a few of those in a row ends it rather than spinning.
@@ -31,6 +32,7 @@ function run() {
31
32
  console.log('Restarting the office…');
32
33
  return run();
33
34
  }
35
+ if (code === FATAL) return process.exit(1);
34
36
  // Ctrl-C reaches the server too, and it exits 0: that's closing time.
35
37
  if (code === 0 || signal) return process.exit(code ?? 0);
36
38
  if (Date.now() - startedAt < BOOT_MS && ++quickFails >= MAX_QUICK_FAILS) {
package/server/voice.js CHANGED
@@ -89,7 +89,9 @@ export async function transcribe(file, { dataDir, prompt = '', threads = Math.ma
89
89
  if (!model) throw new Error(`No speech model installed. ${MODEL_HINT}`);
90
90
  const wav = path.join(path.dirname(file), 'voice.wav');
91
91
  try {
92
- await run(FFMPEG, ['-nostdin', '-loglevel', 'error', '-y', '-i', file, '-ac', '1', '-ar', '16000', '-c:a', 'pcm_s16le', wav]);
92
+ // Local files and pipes only: a crafted upload (an HLS playlist, say) could
93
+ // otherwise have ffmpeg fetch URLs, from this machine's network position.
94
+ await run(FFMPEG, ['-nostdin', '-loglevel', 'error', '-y', '-protocol_whitelist', 'file,pipe', '-i', file, '-ac', '1', '-ar', '16000', '-c:a', 'pcm_s16le', wav]);
93
95
  const out = await run(WHISPER, [
94
96
  '-m', model, '-f', wav,
95
97
  '-nt', '-np', '-l', 'en', '-sns', '-t', String(threads),