agent-office-studio 0.6.0 → 0.8.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.
Files changed (40) hide show
  1. package/CHANGELOG.md +18 -1
  2. package/README.md +15 -1
  3. package/bin/agentoffice.js +6 -1
  4. package/dist/assets/index-BAvX7Imu.css +1 -0
  5. package/dist/assets/index-C01GpSoa.js +4999 -0
  6. package/dist/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
  7. package/dist/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  8. package/dist/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
  9. package/dist/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  10. package/dist/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  11. package/dist/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  12. package/dist/assets/{jetbrains-mono-cyrillic-400-normal-CIEtRijs.woff → jetbrains-mono-cyrillic-400-normal-ugxPyKxw.woff} +0 -0
  13. package/dist/assets/{jetbrains-mono-greek-400-normal-CNojz0t3.woff → jetbrains-mono-greek-400-normal-B9oWc5Lo.woff} +0 -0
  14. package/dist/assets/{jetbrains-mono-latin-400-normal-B11XCQ5g.woff → jetbrains-mono-latin-400-normal-6-qcROiO.woff} +0 -0
  15. package/dist/assets/{jetbrains-mono-latin-ext-400-normal-DHMOLxAi.woff → jetbrains-mono-latin-ext-400-normal-fXTG6kC5.woff} +0 -0
  16. package/dist/assets/{jetbrains-mono-vietnamese-400-normal-BeU3T8wf.woff → jetbrains-mono-vietnamese-400-normal-CqNFfHCs.woff} +0 -0
  17. package/dist/index.html +11 -2
  18. package/docs/linked-offices.md +11 -0
  19. package/docs/phone.md +51 -3
  20. package/docs/security.md +8 -3
  21. package/package.json +4 -2
  22. package/server/cloudflared.js +84 -0
  23. package/server/economy.js +455 -0
  24. package/server/guests.js +67 -13
  25. package/server/index.js +371 -21
  26. package/server/journeys.js +101 -0
  27. package/server/office-mcp.js +19 -0
  28. package/server/phone.js +70 -0
  29. package/server/settings.js +3 -0
  30. package/server/talk.js +51 -0
  31. package/server/tunnel.js +136 -0
  32. package/shared/shop.js +127 -0
  33. package/dist/assets/index-B9ZRfVrF.js +0 -4475
  34. package/dist/assets/index-BiK6nglq.css +0 -1
  35. package/dist/assets/inter-cyrillic-ext-wght-normal-B2xhLi22.woff2 +0 -0
  36. package/dist/assets/inter-cyrillic-wght-normal-CMZtQduZ.woff2 +0 -0
  37. package/dist/assets/inter-greek-ext-wght-normal-CGAr0uHJ.woff2 +0 -0
  38. package/dist/assets/inter-greek-wght-normal-CaVNZxsx.woff2 +0 -0
  39. package/dist/assets/inter-latin-ext-wght-normal-CFHvXkgd.woff2 +0 -0
  40. package/dist/assets/inter-latin-wght-normal-C2S99t-D.woff2 +0 -0
@@ -0,0 +1,455 @@
1
+ // The office economy: pay, the office pot, the shop and achievements.
2
+ //
3
+ // Agents are paid when a job finishes, by the active minute and scaled by the
4
+ // model that did the work (see shared/shop.js). Of every naira earned, the
5
+ // pot takes its share for the boss to spend on the office; the agent keeps
6
+ // the rest and can spend it on their own desk. The boss can also buy an
7
+ // agent something, out of the pot.
8
+ //
9
+ // Everything lives in one plain object, kept in state.json with the rest of
10
+ // the office, and every change to a balance goes through credit() or charge()
11
+ // and leaves a line in the ledger, so the books always add up.
12
+
13
+ import { PAY, ITEMS, ACHIEVEMENTS, DEFAULTS, SINGLE, itemById, levelOf, streakBoost, TIP, modelPay, modelTier, split, naira } from '../shared/shop.js';
14
+
15
+ const LEDGER_MAX = 400;
16
+ // Weeks start on Monday 00:00 UTC (1970-01-01 was a Thursday).
17
+ const weekStart = (ms) => { const d = Math.floor(ms / 86400000); return (d - ((d + 3) % 7)) * 86400000; };
18
+ // Bets are small and slow on purpose. A stake is locked out of the wallet the
19
+ // moment it is offered, so a wallet can never go below zero.
20
+ export const BET = { minStake: 100, maxStake: 5000, maxOpen: 3, maxMinutes: 60, openMs: 6 * 3600000, liveMs: 24 * 3600000, keep: 40 };
21
+ export const GOAL = { minTarget: 1000, maxTarget: 1e9, maxName: 40 };
22
+ const day = (ms) => new Date(ms).toISOString().slice(0, 10);
23
+
24
+ export function emptyBooks() {
25
+ return { wallets: {}, pot: 0, stats: {}, desks: {}, office: [], using: { ...DEFAULTS }, achievements: {}, kudos: [], ledger: [], weekly: {}, totals: { earned: 0, spent: 0 },
26
+ // The team goal ({} when none), the goals already reached, agent-to-agent
27
+ // bets, and the stakes held on them (wallets + pot + escrow = earned - spent).
28
+ goal: {}, goalsDone: [], bets: [], escrow: 0 };
29
+ }
30
+
31
+ const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
32
+ const isNum = (v) => typeof v === 'number' && Number.isFinite(v);
33
+
34
+ export class Economy {
35
+ // `books` is the stored object, filled in where older state lacks a part.
36
+ // `onChange` gets each ledger entry as it happens, to save and broadcast.
37
+ constructor(books, { now = () => Date.now(), onChange = () => {} } = {}) {
38
+ const b = isObj(books) ? books : {};
39
+ const fresh = emptyBooks();
40
+ // typeof says 'object' for null and arrays and 'number' for NaN, so check
41
+ // the real shape of each part, and start that part over if it is damaged.
42
+ for (const k of Object.keys(fresh)) {
43
+ const ok = Array.isArray(fresh[k]) ? Array.isArray(b[k]) : isObj(fresh[k]) ? isObj(b[k]) : isNum(b[k]);
44
+ if (!ok) b[k] = fresh[k];
45
+ }
46
+ for (const id of Object.keys(b.wallets)) if (!isNum(b.wallets[id])) b.wallets[id] = 0;
47
+ // Books from before the running totals: what is held now counts as earned.
48
+ if (!isNum(b.totals.earned) || !isNum(b.totals.spent)) {
49
+ b.totals = { earned: b.pot + Object.values(b.wallets).reduce((a, x) => a + x, 0), spent: 0 };
50
+ }
51
+ b.using = { ...DEFAULTS, ...b.using };
52
+ this.b = b;
53
+ this.now = now;
54
+ this.onChange = onChange;
55
+ }
56
+
57
+ get books() { return this.b; }
58
+ wallet(id) { return this.b.wallets[id] || 0; }
59
+ statsOf(id) {
60
+ return (this.b.stats[id] ||= { jobs: 0, earned: 0, byTier: {}, kudos: 0, bought: 0, streak: 0, lastDay: '' });
61
+ }
62
+
63
+ record(entry) {
64
+ const e = { at: this.now(), ...entry };
65
+ this.b.ledger.push(e);
66
+ if (this.b.ledger.length > LEDGER_MAX) this.b.ledger.splice(0, this.b.ledger.length - LEDGER_MAX);
67
+ this.onChange(e);
68
+ return e;
69
+ }
70
+
71
+ // Money in: split between the agent and the pot.
72
+ credit(agentId, gross, entry) {
73
+ const s = split(gross);
74
+ if (!s.gross) return null;
75
+ this.b.wallets[agentId] = this.wallet(agentId) + s.agent;
76
+ this.b.pot += s.pot;
77
+ this.b.totals.earned += s.gross;
78
+ this.statsOf(agentId).earned += s.gross;
79
+ const e = this.record({ ...entry, agentId, gross: s.gross, agent: s.agent, pot: s.pot });
80
+ this.checkGoal();
81
+ return e;
82
+ }
83
+
84
+ // A turn of work, as it ends: `ms` of model time on `model`. Kept on the
85
+ // job until it finishes, since only a finished job pays.
86
+ accrue(job, ms, model) {
87
+ const min = Math.max(0, Number(ms) || 0) / 60000;
88
+ if (!min) return;
89
+ job.payMinutes = (job.payMinutes || 0) + min;
90
+ job.payWeighted = (job.payWeighted || 0) + min * modelPay(model);
91
+ }
92
+
93
+ // What a finished job earns, without paying it.
94
+ quote(job) {
95
+ const minutes = job.payMinutes || 0;
96
+ const capped = minutes > PAY.maxMinutes ? (job.payWeighted || 0) * (PAY.maxMinutes / minutes) : job.payWeighted || 0;
97
+ const mult = modelPay(job.model);
98
+ return { minutes, tier: modelTier(job.model), mult, wage: capped * PAY.perMinute, bonus: PAY.finishBonus * mult };
99
+ }
100
+
101
+ // A job finished. Pays it, counts it, and checks for milestones.
102
+ pay(job) {
103
+ if (!job?.agentId) return null;
104
+ const q = this.quote(job);
105
+ job.payMinutes = 0;
106
+ job.payWeighted = 0;
107
+ // A reply can reopen a finished job. The new minutes are paid, but the
108
+ // finish bonus, the job count and the milestones are for the first finish.
109
+ const again = !!job.payCounted;
110
+ job.payCounted = true;
111
+ const s = this.statsOf(job.agentId);
112
+ let boost = 0;
113
+ if (!again) {
114
+ // The day's first finish keeps or restarts the streak, and the
115
+ // streak lifts this job's wage.
116
+ const today = day(this.now());
117
+ if (s.lastDay !== today) {
118
+ const yesterday = day(this.now() - 86400000);
119
+ s.streak = s.lastDay === yesterday ? s.streak + 1 : 1;
120
+ s.lastDay = today;
121
+ }
122
+ boost = streakBoost(s.streak);
123
+ }
124
+ const e = this.credit(job.agentId, q.wage * (1 + boost) + (again ? 0 : q.bonus), { kind: 'pay', jobId: job.id, title: job.title, minutes: Math.round(q.minutes * 10) / 10, tier: q.tier, mult: q.mult, ...(boost ? { streak: s.streak, boost } : {}) });
125
+ if (e) job.earned = (job.earned || 0) + e.gross;
126
+ if (again) return { pay: e, achievements: this.milestones(job.agentId) };
127
+ // A bet that cannot be settled cleanly must never hold up the pay.
128
+ try { this.settleBets(job, q.minutes); } catch (err) { this.record({ kind: 'bet', phase: 'error', jobId: job.id, why: err.message }); }
129
+ s.jobs++;
130
+ if (q.tier) s.byTier[q.tier] = (s.byTier[q.tier] || 0) + 1;
131
+ const won = this.milestones(job.agentId, q);
132
+ return { pay: e, achievements: won };
133
+ }
134
+
135
+ // Unlocked milestones pay their bonus once each.
136
+ milestones(agentId, job) {
137
+ const have = (this.b.achievements[agentId] ||= []);
138
+ const won = [];
139
+ // Twice round, since a bonus can push `earned` past the next milestone.
140
+ for (let pass = 0; pass < 2; pass++) {
141
+ for (const a of ACHIEVEMENTS) {
142
+ if (have.includes(a.id) || !a.test(this.statsOf(agentId), job)) continue;
143
+ have.push(a.id);
144
+ won.push(a);
145
+ this.credit(agentId, a.bonus, { kind: 'achievement', achievement: a.id, title: a.name });
146
+ }
147
+ }
148
+ return won;
149
+ }
150
+
151
+ // The boss liked a finished job: its finish bonus again, once.
152
+ kudos(job) {
153
+ if (!job || job.status !== 'done' || job.awaitingBoss) throw new Error('Only a finished job can get kudos.');
154
+ if (job.guest) throw new Error('That job belongs to another office.');
155
+ if (job.kudosPaid || this.b.kudos.includes(job.id)) throw new Error('That job already got kudos.');
156
+ job.kudosPaid = true;
157
+ this.b.kudos.push(job.id);
158
+ if (this.b.kudos.length > 2000) this.b.kudos.splice(0, 1000);
159
+ this.statsOf(job.agentId).kudos++;
160
+ const e = this.credit(job.agentId, PAY.finishBonus * modelPay(job.model), { kind: 'kudos', jobId: job.id, title: job.title });
161
+ return { pay: e, achievements: this.milestones(job.agentId) };
162
+ }
163
+
164
+ owns(item, agentId) {
165
+ if (item.price === 0 && SINGLE.has(item.scope)) return true;
166
+ if (item.scope === 'desk') return (this.b.desks[agentId] || []).includes(item.id);
167
+ return this.b.office.includes(item.id);
168
+ }
169
+
170
+ // `by` is 'boss' or an agent's id; `forAgent` is whose desk a desk item
171
+ // goes on. Agents pay from their own wallet and only for their own desk;
172
+ // the boss pays from the pot.
173
+ buy(itemId, { by, forAgent } = {}) {
174
+ const item = itemById(itemId);
175
+ if (!item) throw new Error(`Nothing in the shop is called "${itemId}".`);
176
+ const boss = by === 'boss';
177
+ if (!boss && item.scope !== 'desk') throw new Error(`${item.name} is for the office, and only the boss can buy those, out of the pot.`);
178
+ const owner = item.scope === 'desk' ? (boss ? forAgent : by) : null;
179
+ if (item.scope === 'desk' && !owner) throw new Error('Say whose desk it is for.');
180
+ if (this.owns(item, owner)) throw new Error(owner ? `${item.name} is already on that desk.` : `The office already has ${item.name}.`);
181
+ const balance = boss ? this.b.pot : this.wallet(by);
182
+ if (balance < item.price) throw new Error(`${item.name} costs ${naira(item.price)} and ${boss ? 'the pot has' : 'you have'} ${naira(balance)}.`);
183
+ if (boss) this.b.pot -= item.price;
184
+ else this.b.wallets[by] = balance - item.price;
185
+ this.b.totals.spent += item.price;
186
+ if (owner) (this.b.desks[owner] ||= []).push(item.id);
187
+ else this.b.office.push(item.id);
188
+ // Buying a new look puts it straight into use.
189
+ if (SINGLE.has(item.scope)) this.b.using[item.scope] = item.id;
190
+ const e = this.record({ kind: 'buy', item: item.id, title: item.name, price: item.price, by, agentId: owner || undefined, from: boss ? 'pot' : 'wallet' });
191
+ let won = [];
192
+ if (owner && !boss) {
193
+ this.statsOf(owner).bought++;
194
+ won = this.milestones(owner);
195
+ }
196
+ return { buy: e, achievements: won };
197
+ }
198
+
199
+ // The boss tips an agent out of the pot. A plain transfer: it is not
200
+ // earned, so it neither splits nor counts toward totals.
201
+ tip(agentId, amount) {
202
+ const n = Math.floor(Number(amount));
203
+ if (!agentId) throw new Error('Say who the tip is for.');
204
+ if (!(n >= TIP.min && n <= TIP.max)) throw new Error(`A tip is between ${naira(TIP.min)} and ${naira(TIP.max)}.`);
205
+ if (this.b.pot < n) throw new Error(`The pot has ${naira(this.b.pot)}.`);
206
+ this.b.pot -= n;
207
+ this.b.wallets[agentId] = this.wallet(agentId) + n;
208
+ return this.record({ kind: 'tip', agentId, amount: n, by: 'boss', title: 'Tip from the boss' });
209
+ }
210
+
211
+ // Title and streak, per agent. A streak only counts while it is alive:
212
+ // finished a job today or yesterday.
213
+ perks(id) {
214
+ const st = this.b.stats[id] || {};
215
+ const live = st.lastDay === day(this.now()) || st.lastDay === day(this.now() - 86400000);
216
+ const streak = live ? st.streak || 0 : 0;
217
+ return { ...levelOf(st.earned || 0), streak, boost: streakBoost(streak) };
218
+ }
219
+
220
+ // Switch to a floor, wall or layout the office owns.
221
+ use(itemId) {
222
+ const item = itemById(itemId);
223
+ if (!item || !SINGLE.has(item.scope)) throw new Error('Only a floor, wall or layout can be put into use.');
224
+ if (!this.owns(item)) throw new Error(`The office doesn't own ${item.name} yet.`);
225
+ this.b.using[item.scope] = item.id;
226
+ this.record({ kind: 'use', item: item.id, title: item.name, by: 'boss' });
227
+ return this.b.using;
228
+ }
229
+
230
+ // Desk items an agent could buy right now.
231
+ affordable(agentId) {
232
+ return ITEMS.filter((i) => i.scope === 'desk' && i.price <= this.wallet(agentId) && !this.owns(i, agentId));
233
+ }
234
+
235
+ // The rank each agent held when the week began, kept in the books so a
236
+ // restart doesn't lose it. Older state has none: the first look takes one,
237
+ // and from then on it rolls over every Monday.
238
+ rollWeek() {
239
+ const w = this.b.weekly;
240
+ const start = weekStart(this.now());
241
+ const ok = isNum(w.start) && isObj(w.ranks);
242
+ if (ok && w.start >= start) return w;
243
+ const ranks = {};
244
+ this.richList().forEach((r, i) => { ranks[r.id] = i + 1; });
245
+ // The very first snapshot has nothing to compare against, so it is
246
+ // marked `first` and shows no movement until a week has passed.
247
+ this.b.weekly = { start, ranks, first: !ok };
248
+ return this.b.weekly;
249
+ }
250
+
251
+ // Net worth is what an agent holds plus what they have bought for their
252
+ // desk, at shop price. Ties go to whoever has earned more over their time.
253
+ richList() {
254
+ const b = this.b;
255
+ const ids = new Set([...Object.keys(b.wallets), ...Object.keys(b.desks), ...Object.keys(b.stats)]);
256
+ return [...ids].map((id) => {
257
+ const wallet = this.wallet(id);
258
+ const desk = (b.desks[id] || []).reduce((a, x) => a + (itemById(x)?.price || 0), 0);
259
+ const st = b.stats[id] || {};
260
+ const locked = this.staked(id);
261
+ return { id, worth: wallet + desk + locked, wallet, desk, earned: st.earned || 0, jobs: st.jobs || 0 };
262
+ }).sort((a, c) => c.worth - a.worth || c.earned - a.earned || a.id.localeCompare(c.id));
263
+ }
264
+
265
+ // The rich list with the week's movement: `moved` is places climbed since
266
+ // the week began (negative for places lost), `rival` the agents just above
267
+ // and below with the gap in naira, and `comeback` marks whoever climbed
268
+ // most (ties go to the higher rank; nobody if nobody climbed). Agents who
269
+ // joined the list this week, and the whole list in the first week, have
270
+ // `moved: null`.
271
+ weekly() {
272
+ const w = this.rollWeek();
273
+ const list = this.richList();
274
+ const rows = list.map((r, i) => {
275
+ const was = !w.first && isNum(w.ranks[r.id]) ? w.ranks[r.id] : null;
276
+ const above = list[i - 1], below = list[i + 1];
277
+ return {
278
+ ...r, rank: i + 1, was, moved: was === null ? null : was - (i + 1),
279
+ rival: { above: above ? { id: above.id, gap: above.worth - r.worth } : null, below: below ? { id: below.id, gap: r.worth - below.worth } : null },
280
+ comeback: false,
281
+ };
282
+ });
283
+ const best = rows.reduce((m, r) => (r.moved > (m?.moved || 0) ? r : m), null);
284
+ if (best) best.comeback = true;
285
+ return { since: w.start, first: !!w.first, rows };
286
+ }
287
+
288
+ // ---------- team goal ----------
289
+
290
+ // The boss names a target for the pot ("pool party", ₦5,000,000). Reaching
291
+ // it is celebrated once; the goal then stays as reached until replaced.
292
+ setGoal({ name, target } = {}) {
293
+ name = String(name || '').replace(/\s+/g, ' ').trim().slice(0, GOAL.maxName);
294
+ target = Math.round(Number(target));
295
+ if (!name) throw new Error('Give the goal a name.');
296
+ if (!Number.isFinite(target) || target < GOAL.minTarget) throw new Error(`The target must be at least ${naira(GOAL.minTarget)}.`);
297
+ if (target > GOAL.maxTarget) throw new Error(`The target can be at most ${naira(GOAL.maxTarget)}.`);
298
+ if (target <= this.b.pot) throw new Error(`The pot already has ${naira(this.b.pot)}. Aim higher than that.`);
299
+ this.b.goal = { name, target, setAt: this.now(), reachedAt: null };
300
+ this.record({ kind: 'goal', phase: 'set', title: name, target, by: 'boss' });
301
+ return this.goalStatus();
302
+ }
303
+
304
+ clearGoal() {
305
+ if (!this.b.goal.target) return this.goalStatus();
306
+ const was = this.b.goal;
307
+ this.b.goal = {};
308
+ this.record({ kind: 'goal', phase: 'cleared', title: was.name, target: was.target, by: 'boss' });
309
+ return this.goalStatus();
310
+ }
311
+
312
+ // Called whenever the pot grows. Fires once per goal: reachedAt is the latch,
313
+ // and it survives the pot being spent down afterwards.
314
+ checkGoal() {
315
+ const g = this.b.goal;
316
+ if (!g.target || g.reachedAt || this.b.pot < g.target) return null;
317
+ g.reachedAt = this.now();
318
+ this.b.goalsDone.push({ name: g.name, target: g.target, reachedAt: g.reachedAt });
319
+ if (this.b.goalsDone.length > 20) this.b.goalsDone.splice(0, this.b.goalsDone.length - 20);
320
+ return this.record({ kind: 'goal', phase: 'reached', title: g.name, target: g.target, pot: this.b.pot });
321
+ }
322
+
323
+ goalStatus() {
324
+ const g = this.b.goal;
325
+ if (!g.target) return null;
326
+ const pot = this.b.pot;
327
+ return { name: g.name, target: g.target, setAt: g.setAt, reachedAt: g.reachedAt || null, pot, progress: g.reachedAt ? 1 : Math.min(1, pot / g.target), done: this.b.goalsDone.length };
328
+ }
329
+
330
+ // ---------- bets ----------
331
+ //
332
+ // One agent (the backer) says another agent's next job will finish in under
333
+ // N active minutes and stakes naira on it; a second agent (the doubter) takes
334
+ // the other side with a matching stake. Both stakes are locked in escrow, and
335
+ // when the job first finishes the winner gets both. Nobody can be the subject
336
+ // of their own bet, so nobody gains by dragging a job out or rushing it for a
337
+ // bet. Money only ever moves wallet -> escrow -> wallet, never through the
338
+ // pot, so earned and spent do not change.
339
+
340
+ staked(agentId) {
341
+ return this.b.bets.reduce((a, x) => a + (x.state === 'open' && x.by === agentId ? x.stake : x.state === 'live' && (x.by === agentId || x.against === agentId) ? x.stake : 0), 0);
342
+ }
343
+
344
+ // Wallet -> escrow. Never lets a wallet go below zero.
345
+ charge(agentId, amount, entry) {
346
+ if (!(amount > 0) || this.wallet(agentId) < amount) throw new Error(`${naira(amount)} is more than ${agentId} has (${naira(this.wallet(agentId))}).`);
347
+ this.b.wallets[agentId] = this.wallet(agentId) - amount;
348
+ this.b.escrow += amount;
349
+ return this.record({ ...entry, agentId, amount: -amount });
350
+ }
351
+
352
+ // Escrow -> wallet.
353
+ release(agentId, amount, entry) {
354
+ if (!(amount > 0)) return null;
355
+ if (this.b.escrow < amount) throw new Error('The escrow is short; refusing to pay out more than is held.');
356
+ this.b.escrow -= amount;
357
+ this.b.wallets[agentId] = this.wallet(agentId) + amount;
358
+ return this.record({ ...entry, agentId, amount });
359
+ }
360
+
361
+ offerBet({ by, subject, minutes, stake } = {}) {
362
+ this.sweep();
363
+ minutes = Math.round(Number(minutes));
364
+ stake = Math.round(Number(stake));
365
+ if (!by || !subject) throw new Error('Say who is betting and on whom.');
366
+ if (by === subject) throw new Error('You cannot bet on your own job.');
367
+ if (!Number.isFinite(minutes) || minutes < 1 || minutes > BET.maxMinutes) throw new Error(`Pick a time between 1 and ${BET.maxMinutes} minutes.`);
368
+ if (!Number.isFinite(stake) || stake < BET.minStake || stake > BET.maxStake) throw new Error(`A stake is between ${naira(BET.minStake)} and ${naira(BET.maxStake)}.`);
369
+ const mine = this.b.bets.filter((x) => (x.state === 'open' || x.state === 'live') && (x.by === by || x.against === by)).length;
370
+ if (mine >= BET.maxOpen) throw new Error(`You already have ${BET.maxOpen} bets going. Wait for one to settle.`);
371
+ const bet = { id: 'b' + this.now().toString(36) + Math.random().toString(36).slice(2, 6), by, subject, minutes, stake, against: null, state: 'open', createdAt: this.now() };
372
+ this.charge(by, stake, { kind: 'bet', phase: 'stake', betId: bet.id, subject, minutes });
373
+ this.b.bets.push(bet);
374
+ return bet;
375
+ }
376
+
377
+ takeBet(id, { by } = {}) {
378
+ this.sweep();
379
+ const bet = this.b.bets.find((x) => x.id === id);
380
+ if (!bet || bet.state !== 'open') throw new Error('That bet is not open.');
381
+ if (by === bet.by) throw new Error('That is your own bet.');
382
+ if (by === bet.subject) throw new Error('You cannot bet against your own job.');
383
+ const mine = this.b.bets.filter((x) => (x.state === 'open' || x.state === 'live') && (x.by === by || x.against === by)).length;
384
+ if (mine >= BET.maxOpen) throw new Error(`You already have ${BET.maxOpen} bets going. Wait for one to settle.`);
385
+ this.charge(by, bet.stake, { kind: 'bet', phase: 'stake', betId: bet.id, subject: bet.subject, minutes: bet.minutes });
386
+ Object.assign(bet, { against: by, state: 'live', acceptedAt: this.now() });
387
+ return bet;
388
+ }
389
+
390
+ // The backer (or the boss) takes back an offer nobody has taken.
391
+ cancelBet(id, { by } = {}) {
392
+ const bet = this.b.bets.find((x) => x.id === id);
393
+ if (!bet || bet.state !== 'open') throw new Error('That bet is not open.');
394
+ if (by !== 'boss' && by !== bet.by) throw new Error('Only whoever offered it can take it back.');
395
+ return this.close(bet, 'void', 'taken back');
396
+ }
397
+
398
+ // Ends a bet and moves the money. Winner takes both stakes; a void gives
399
+ // each their own back. Idempotent: a bet that is closed stays closed.
400
+ close(bet, state, why, winner) {
401
+ if (bet.state !== 'open' && bet.state !== 'live') return bet;
402
+ const parties = [bet.by, bet.against].filter(Boolean);
403
+ if (this.b.escrow < bet.stake * (state === 'void' ? parties.length : 2)) throw new Error('The escrow is short; refusing to move any of it.');
404
+ const entry = { kind: 'bet', phase: state, betId: bet.id, subject: bet.subject, minutes: bet.minutes, stake: bet.stake, why };
405
+ if (state === 'void') for (const id of parties) this.release(id, bet.stake, { ...entry });
406
+ else this.release(winner, bet.stake * 2, { ...entry, winner, loser: parties.find((x) => x !== winner) });
407
+ Object.assign(bet, { state, why, winner: winner || null, settledAt: this.now() });
408
+ const old = this.b.bets.filter((x) => x.state !== 'open' && x.state !== 'live');
409
+ if (old.length > BET.keep) for (const x of old.slice(0, old.length - BET.keep)) this.b.bets.splice(this.b.bets.indexOf(x), 1);
410
+ return bet;
411
+ }
412
+
413
+ // A bet belongs to the subject's first job that started after it was taken.
414
+ boundTo(bet, job) {
415
+ return bet.state === 'live' && bet.subject === job.agentId && Number.isFinite(job.startedAt) && bet.acceptedAt <= job.startedAt;
416
+ }
417
+
418
+ // The job finished for the first time, in `minutes` of active work.
419
+ settleBets(job, minutes) {
420
+ this.sweep();
421
+ for (const bet of this.b.bets.filter((x) => this.boundTo(x, job))) {
422
+ if (!(minutes > 0)) { this.close(bet, 'void', 'no working time was recorded'); continue; }
423
+ const under = minutes < bet.minutes;
424
+ this.close(bet, under ? 'won' : 'lost', `${Math.round(minutes * 10) / 10} min on "${job.title}"`, under ? bet.by : bet.against);
425
+ }
426
+ }
427
+
428
+ // The job did not finish (failed, stopped, cancelled): stakes come back.
429
+ voidBets(job, why = 'the job did not finish') {
430
+ for (const bet of this.b.bets.filter((x) => this.boundTo(x, job))) this.close(bet, 'void', why);
431
+ }
432
+
433
+ // Bets nobody took, or that never settled, run out and refund.
434
+ sweep() {
435
+ const t = this.now();
436
+ for (const bet of this.b.bets.slice()) {
437
+ if (bet.state === 'open' && t - bet.createdAt > BET.openMs) this.close(bet, 'void', 'nobody took it');
438
+ else if (bet.state === 'live' && t - bet.acceptedAt > BET.liveMs) this.close(bet, 'void', 'the job never finished');
439
+ }
440
+ }
441
+
442
+ betsStatus() {
443
+ return this.b.bets.slice(-BET.keep - 12);
444
+ }
445
+
446
+ status() {
447
+ const wk = this.weekly();
448
+ const b = this.b;
449
+ return {
450
+ rich: wk.rows.map((r) => ({ ...r, ...this.perks(r.id) })), goal: this.goalStatus(), bets: this.betsStatus(), escrow: b.escrow, week: { since: wk.since, first: wk.first }, pay: { perMinute: PAY.perMinute, finishBonus: PAY.finishBonus },
451
+ pot: b.pot, wallets: b.wallets, stats: b.stats, desks: b.desks, office: b.office, using: b.using,
452
+ achievements: b.achievements, kudos: b.kudos.slice(-200), ledger: b.ledger.slice(-60),
453
+ };
454
+ }
455
+ }
package/server/guests.js CHANGED
@@ -22,6 +22,7 @@ export const CLEARANCE = ['plan', 'edit', 'auto'];
22
22
  const MAX_OPEN_JOBS = 3; // queued or working, per guest
23
23
  const MAX_PROMPT = 20_000;
24
24
  const REQ_TIMEOUT_MS = 15_000;
25
+ const CLOSE_TIMEOUT_MS = 60_000; // telling a host the work ended isn't on anyone's clock, and the relay can be slow to carry it
25
26
  const CONNECT_WAIT_MS = 5_000;
26
27
  const FEED_EVERY_MS = 250;
27
28
  const FEED_BYTES = 48 * 1024; // under the relay's 64 KB a frame
@@ -142,7 +143,7 @@ export function guestJob(j, tail = 40) {
142
143
  }
143
144
 
144
145
  export class Guests {
145
- // office: { jobs(), createJob(body, extra), replyJob(job, body), stopJob(job), diff(job, path), staff(), modes() }
146
+ // office: { jobs(), repoPath(name), createJob(body, extra), replyJob(job, body), stopJob(job), diff(job, path), staff(), modes() }
146
147
  // office also has send(msg), which broadcasts to our own browsers.
147
148
  // visits (as host): our side of a guest's own agents working in our
148
149
  // projects; see visits.js.
@@ -235,6 +236,8 @@ export class Guests {
235
236
  repos: p.repos, clearance: p.clearance, tokensPerDay: p.tokensPerDay, tokensToday: this.tokensToday(link.id),
236
237
  ourAgents: p.ourAgents !== false, theirAgents: this.copies ? p.theirAgents || 'off' : 'off',
237
238
  },
239
+ // We can run a lent agent in one of the guest's own projects (createJob's `home`).
240
+ home: true,
238
241
  };
239
242
  case 'visit.open': case 'visit.fs': case 'visit.close': {
240
243
  if (!this.copies) throw fail(400, 'Guests can’t do that here.');
@@ -246,14 +249,18 @@ export class Guests {
246
249
  }
247
250
  case 'createJob': {
248
251
  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.');
249
- if (!p.repos.includes(args.repo)) throw fail(403, 'That project isn’t shared with you.');
252
+ // `home`: the guest's own project, not one of ours. The agent runs here
253
+ // and its file tools go back to the guest over the link where we are
254
+ // theirs (an away job), so it needs that link too, and that project shared on it.
255
+ const home = typeof args.home === 'string' && args.home ? await this.homeFolder(link, args.home) : null;
256
+ if (!home && !p.repos.includes(args.repo)) throw fail(403, 'That project isn’t shared with you.');
250
257
  const mode = this.capMode(p, args.mode);
251
258
  this.checkTokens(link, p);
252
259
  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.`);
253
260
  const prompt = String(args.prompt ?? '').slice(0, MAX_PROMPT);
254
261
  const job = this.office.createJob(
255
- { repo: args.repo, prompt, mode, model: typeof args.model === 'string' ? args.model : '', agentId: typeof args.agentId === 'string' ? args.agentId : '' },
256
- { from: `@${link.peer}`, guest: { link: link.id } },
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 }) },
257
264
  );
258
265
  return { job: guestJob(job) };
259
266
  }
@@ -277,6 +284,21 @@ export class Guests {
277
284
  }
278
285
  }
279
286
 
287
+ // The guest's project `repo`, reached over the link where the guest is our
288
+ // host, as an away job's `away`. The relay names both ends of a link, so a
289
+ // guest can only point us at its own office.
290
+ 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));
294
+ if (back) e = await this.remoteHello(back).catch(() => null);
295
+ 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
+ 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));
298
+ if (!r) throw fail(403, `You haven’t shared ${repo} with @${this.links.status().handle || 'us'}. Add it where we’re your guest.`);
299
+ return { link: e.link, host: e.host, repo: r, access: e.theirAgents };
300
+ }
301
+
280
302
  // Tokens a guest job used: counted against its guest, and a guest over its
281
303
  // day's allowance has its running jobs stopped.
282
304
  addTokens(job, n) {
@@ -363,14 +385,14 @@ export class Guests {
363
385
  return link;
364
386
  }
365
387
 
366
- async request(linkId, op, args = {}) {
388
+ async request(linkId, op, args = {}, timeoutMs = REQ_TIMEOUT_MS) {
367
389
  const link = this.guestLink(linkId);
368
390
  // Just after linking (or a reconnect) the relay socket can still be
369
391
  // opening; a visit started then shouldn't fail for it.
370
392
  for (const end = Date.now() + CONNECT_WAIT_MS; !this.links.status().connected && Date.now() < end;) await new Promise((r) => setTimeout(r, 100));
371
393
  const id = crypto.randomUUID();
372
394
  return new Promise((resolve, reject) => {
373
- const timer = setTimeout(() => { this.pending.delete(id); reject(fail(504, `@${link.peer} didn’t answer.`)); }, REQ_TIMEOUT_MS);
395
+ const timer = setTimeout(() => { this.pending.delete(id); reject(fail(504, `@${link.peer} didn’t answer.`)); }, timeoutMs);
374
396
  const clean = CLEAN_BODY[op] || (() => ({}));
375
397
  this.pending.set(id, { resolve: (body) => { try { resolve(clean(body)); } catch (err) { reject(err); } }, reject, timer });
376
398
  if (!this.links.send({ t: 'req', link: link.id, id, op, args })) {
@@ -421,6 +443,7 @@ export class Guests {
421
443
  repos: (Array.isArray(policy.repos) ? policy.repos : []).filter((r) => typeof r === 'string' && REPO.test(r)).slice(0, 50),
422
444
  clearance, tokensPerDay: count(policy.tokensPerDay), tokensToday: count(policy.tokensToday),
423
445
  ourAgents: policy.ourAgents !== false, theirAgents: ['read', 'edit'].includes(policy.theirAgents) ? policy.theirAgents : 'off',
446
+ home: hello?.home === true,
424
447
  },
425
448
  };
426
449
  }
@@ -463,7 +486,7 @@ export class Guests {
463
486
  const hello = this.cleanHello(link, await this.request(link.id, 'hello'));
464
487
  const entry = {
465
488
  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,
489
+ ourAgents: hello.visiting.ourAgents, theirAgents: hello.visiting.theirAgents, home: hello.visiting.home,
467
490
  jobs: new Map(hello.jobs.map((j) => [j.id, j])),
468
491
  };
469
492
  if (!this.guestLinkOrNull(link.id)) return null; // unlinked while we asked
@@ -609,14 +632,34 @@ export class Guests {
609
632
  return null;
610
633
  }
611
634
 
635
+ // One of our own projects, for a host's lent agent: the host runs it and its
636
+ // file tools come back to us over the link where the host is our guest
637
+ // (visits.js), so that link must let their agents into this project.
638
+ lentHome(entry, repo) {
639
+ const norm = (v) => String(v).toLowerCase().replace(/[^a-z0-9]/g, '');
640
+ const h = entry.host;
641
+ 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));
643
+ const p = back && this.links.guestPolicy(back.id);
644
+ 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
+ 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));
647
+ 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
+ return r;
649
+ }
650
+
612
651
  // The project a job at a host goes in: the only one it shares, one tagged
613
652
  // or named in the brief, or the one used there last time.
614
653
  remoteRepo(entry, text) {
615
654
  const norm = (v) => String(v).toLowerCase().replace(/[^a-z0-9]/g, '');
616
655
  const { repos } = entry;
617
656
  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
657
  const tags = [...text.matchAll(/(^|\s)#([\w.-]+)/g)];
658
+ // A project of ours, not theirs: their agent would run in their folder
659
+ // 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]));
661
+ if (ours) return { repo: this.lentHome(entry, ours[2]), home: true, text: text.replace(ours[0], ' ').replace(/[ \t]+/g, ' ').trim() };
662
+ if (repos.length === 1) return { repo: repos[0], text };
620
663
  for (const t of tags) {
621
664
  const repo = repos.find((r) => norm(r) === norm(t[2]));
622
665
  if (repo) return { repo, text: text.replace(t[0], ' ').replace(/[ \t]+/g, ' ').trim() };
@@ -678,22 +721,33 @@ export class Guests {
678
721
  const s = this.away.get(job.id);
679
722
  if (!s) return;
680
723
  this.away.delete(job.id);
681
- await this.request(job.away.link, 'visit.close', { sid: s.sid, status, summary: String(summary || '').slice(0, 2000) });
724
+ const args = { sid: s.sid, status, summary: String(summary || '').slice(0, 2000) };
725
+ // A close that outruns the wait may still land, so ask again once. The
726
+ // host ends a visit once, and an answer that says it already has is fine.
727
+ for (let tries = 0; ; tries++) {
728
+ try {
729
+ await this.request(job.away.link, 'visit.close', args, CLOSE_TIMEOUT_MS);
730
+ return;
731
+ } catch (err) {
732
+ if (err.code === 404 || err.code === 410) return;
733
+ if (err.code !== 504 || tries) throw err;
734
+ }
735
+ }
682
736
  }
683
737
 
684
738
  // Start a job with one of a host's agents, from our chat.
685
739
  async startRemote({ link, staff, text, mode, model, thread, channel }) {
686
740
  const entry = this.remote.get(link) || await this.remoteHello(this.guestLink(link));
687
- const { repo, text: brief } = this.remoteRepo(entry, text);
741
+ const { repo, home, text: brief } = this.remoteRepo(entry, text);
688
742
  if (!brief) throw fail(400, 'Say what the job is, as well as who it’s for.');
689
743
  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 });
744
+ const { job } = await this.request(link, 'createJob', { ...(home ? { home: repo } : { repo }), prompt: brief, mode: this.capRemote(entry, mode), model, agentId });
691
745
  this.asked.threads[`${link}.${job.id}`] = { thread, channel };
692
- this.asked.lastRepo[link] = repo;
746
+ if (!home) this.asked.lastRepo[link] = repo;
693
747
  this.save();
694
748
  entry.jobs.set(job.id, job);
695
749
  const local = this.localJob(entry, job);
696
750
  this.office.send({ t: 'job', job: local });
697
- return { job: local, entry, repo, capped: this.capRemote(entry, mode) !== mode };
751
+ return { job: local, entry, repo, home: !!home, capped: this.capRemote(entry, mode) !== mode };
698
752
  }
699
753
  }