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.
- package/CHANGELOG.md +18 -1
- package/README.md +15 -1
- package/bin/agentoffice.js +6 -1
- package/dist/assets/index-BAvX7Imu.css +1 -0
- package/dist/assets/index-C01GpSoa.js +4999 -0
- package/dist/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
- package/dist/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
- package/dist/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
- package/dist/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
- package/dist/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
- package/dist/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
- package/dist/assets/{jetbrains-mono-cyrillic-400-normal-CIEtRijs.woff → jetbrains-mono-cyrillic-400-normal-ugxPyKxw.woff} +0 -0
- package/dist/assets/{jetbrains-mono-greek-400-normal-CNojz0t3.woff → jetbrains-mono-greek-400-normal-B9oWc5Lo.woff} +0 -0
- package/dist/assets/{jetbrains-mono-latin-400-normal-B11XCQ5g.woff → jetbrains-mono-latin-400-normal-6-qcROiO.woff} +0 -0
- package/dist/assets/{jetbrains-mono-latin-ext-400-normal-DHMOLxAi.woff → jetbrains-mono-latin-ext-400-normal-fXTG6kC5.woff} +0 -0
- package/dist/assets/{jetbrains-mono-vietnamese-400-normal-BeU3T8wf.woff → jetbrains-mono-vietnamese-400-normal-CqNFfHCs.woff} +0 -0
- package/dist/index.html +11 -2
- package/docs/linked-offices.md +11 -0
- package/docs/phone.md +51 -3
- package/docs/security.md +8 -3
- package/package.json +4 -2
- package/server/cloudflared.js +84 -0
- package/server/economy.js +455 -0
- package/server/guests.js +67 -13
- package/server/index.js +371 -21
- package/server/journeys.js +101 -0
- package/server/office-mcp.js +19 -0
- package/server/phone.js +70 -0
- package/server/settings.js +3 -0
- package/server/talk.js +51 -0
- package/server/tunnel.js +136 -0
- package/shared/shop.js +127 -0
- package/dist/assets/index-B9ZRfVrF.js +0 -4475
- package/dist/assets/index-BiK6nglq.css +0 -1
- package/dist/assets/inter-cyrillic-ext-wght-normal-B2xhLi22.woff2 +0 -0
- package/dist/assets/inter-cyrillic-wght-normal-CMZtQduZ.woff2 +0 -0
- package/dist/assets/inter-greek-ext-wght-normal-CGAr0uHJ.woff2 +0 -0
- package/dist/assets/inter-greek-wght-normal-CaVNZxsx.woff2 +0 -0
- package/dist/assets/inter-latin-ext-wght-normal-CFHvXkgd.woff2 +0 -0
- 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
|
-
|
|
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.`)); },
|
|
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
|
-
|
|
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
|
}
|