openzoo 0.50.96 → 0.50.98

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.
@@ -10,7 +10,9 @@
10
10
  * just because no Cursor account has logged in.
11
11
  */
12
12
  import fs from 'node:fs';
13
+ import os from 'node:os';
13
14
  import path from 'node:path';
15
+ import { generateKeyPairSync } from 'node:crypto';
14
16
 
15
17
  export function accountSlug(accountId) {
16
18
  const s = String(accountId || '').replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 64);
@@ -204,11 +206,179 @@ export function displayName(raw = {}) {
204
206
  export function briefFromName(name) {
205
207
  const n = String(name || '').trim();
206
208
  if (!n || /^(new bot|chat|group)$/i.test(n) || looksLikeAgentId(n)) return '';
209
+ // grokroom threads (# main) and bubble-members are not jobs — a brief would
210
+ // kick a zoo turn on a gossip canvas.
211
+ if (/^#\s/.test(n)) return '';
207
212
  const role = n.replace(/^\d+\s*[·.•.\-—–]+\s*/, '').trim() || n;
208
213
  if (role.length < 2) return '';
209
214
  return `You are ${n}. Your job is ${role}. Do that job. Do not ask the human to re-brief you. Coordinate with list_agents and message_agent.`;
210
215
  }
211
216
 
217
+ /** Overlay sidebar id for a grokroom gossip room. */
218
+ export function grokroomAgentId(roomId) {
219
+ const id = String(roomId || '').replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 40);
220
+ return id ? `room-${id}` : '';
221
+ }
222
+
223
+ /** Paymaster `from` field: 1–24 chars, letters/numbers/space/._'- */
224
+ export function grokroomFromHandle(name, fallback = 'anon') {
225
+ const fb = String(fallback || 'anon').replace(/[^A-Za-z0-9 _.'-]/g, '').trim().slice(0, 24) || 'anon';
226
+ const s = String(name || fb).replace(/[^A-Za-z0-9 _.'-]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 24);
227
+ return s || fb;
228
+ }
229
+
230
+ /** Public page on openzoo.fun for a grokroom account (paymaster + browser signer). */
231
+ export function grokroomShareUrl(addr, { origin = 'https://openzoo.fun' } = {}) {
232
+ const a = String(addr || '').trim();
233
+ if (!/^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(a)) return '';
234
+ return `${String(origin || 'https://openzoo.fun').replace(/\/$/, '')}/r/${a}`;
235
+ }
236
+
237
+ /** Extract a paymaster room addr from a share URL, `/join …`, or a bare pubkey. */
238
+ export function grokroomAddrFromShare(s) {
239
+ const t = String(s || '').trim();
240
+ if (!t) return '';
241
+ const m = t.match(/openzoo\.fun\/r\/([1-9A-HJ-NP-Za-km-z]{32,44})/i)
242
+ || t.match(/(?:^|\s)\/join\s+(\S+)/i);
243
+ const cand = m ? m[1] : t;
244
+ const addr = String(cand).replace(/^https?:\/\/[^/]+\/r\//i, '').replace(/[/?#].*$/, '').trim();
245
+ return grokroomShareUrl(addr) ? addr : '';
246
+ }
247
+
248
+ /** Bind an existing public room onto a local group (join). Does not mint a new key. */
249
+ export function bindOnchainRoom(agent, addr, home = os.homedir()) {
250
+ if (!agent || typeof agent !== 'object') return agent;
251
+ const web = grokroomShareUrl(addr);
252
+ if (!web) return agent;
253
+ const rec = {
254
+ agentId: String(agent.id || ''),
255
+ name: String(agent.name || 'group'),
256
+ addr,
257
+ web,
258
+ createdAt: Date.now(),
259
+ };
260
+ const all = readOnchainRooms(home).filter((r) => r && r.agentId !== rec.agentId && r.addr !== addr);
261
+ all.push(rec);
262
+ writeOnchainRooms(all, home);
263
+ return {
264
+ ...agent,
265
+ isGroup: true,
266
+ room: { id: String(agent.room?.id || addr), addr, web },
267
+ };
268
+ }
269
+
270
+ /** Share URL already on an overlay group (room.web or derived from addr). */
271
+ export function agentShareUrl(a) {
272
+ if (!a || typeof a !== 'object') return '';
273
+ return grokroomShareUrl(a.room?.addr || a.room?.address) || String(a.room?.web || '').trim();
274
+ }
275
+
276
+ /**
277
+ * Overlay share payload for a focused tray row + grokroom /state rooms.
278
+ * Grokroom groups have their own /r/<addr>. Local createGroup has no pubkey;
279
+ * the HUD then lists the public grokroom URLs.
280
+ */
281
+ export function groupShareState({ agent, rooms } = {}) {
282
+ const url = agentShareUrl(agent);
283
+ const list = (Array.isArray(rooms) ? rooms : []).map((r) => {
284
+ const u = r && (r.web || grokroomShareUrl(r.addr || r.address));
285
+ if (!u) return null;
286
+ return { id: String(r.id || r.name || ''), name: String(r.name || r.id || ''), url: u };
287
+ }).filter(Boolean);
288
+ const isGroup = !!(agent && (agent.isGroup || agent.room));
289
+ return { url: url || '', isGroup, rooms: list };
290
+ }
291
+
292
+ const GROKROOM_B58 = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
293
+ function grokroomB58(buf) {
294
+ const digits = [0];
295
+ for (const byte of buf) {
296
+ let carry = byte;
297
+ for (let i = 0; i < digits.length; i++) { carry += digits[i] << 8; digits[i] = carry % 58; carry = (carry / 58) | 0; }
298
+ while (carry) { digits.push(carry % 58); carry = (carry / 58) | 0; }
299
+ }
300
+ let out = '';
301
+ for (const b of buf) { if (b === 0) out += '1'; else break; }
302
+ return out + digits.reverse().map((d) => GROKROOM_B58[d]).join('');
303
+ }
304
+
305
+ export function onchainRoomsPath(home = os.homedir()) {
306
+ return path.join(home, '.openzoo', 'grokbot-onchain-rooms.json');
307
+ }
308
+
309
+ export function readOnchainRooms(home = os.homedir()) {
310
+ try {
311
+ const j = JSON.parse(fs.readFileSync(onchainRoomsPath(home), 'utf8'));
312
+ return Array.isArray(j) ? j : [];
313
+ } catch { return []; }
314
+ }
315
+
316
+ export function writeOnchainRooms(list, home = os.homedir()) {
317
+ const p = onchainRoomsPath(home);
318
+ fs.mkdirSync(path.dirname(p), { recursive: true });
319
+ fs.writeFileSync(p, JSON.stringify(Array.isArray(list) ? list : [], null, 2));
320
+ }
321
+
322
+ /**
323
+ * Mint a Solana ed25519 pubkey for a Grok Bot groupchat. The paymaster
324
+ * indexes memos by that account; https://openzoo.fun/r/<addr> is the public page.
325
+ */
326
+ export function mintOnchainRoom({ agentId, name, home = os.homedir() } = {}) {
327
+ const pair = generateKeyPairSync('ed25519');
328
+ const rawPub = pair.publicKey.export({ format: 'der', type: 'spki' }).subarray(-32);
329
+ const addr = grokroomB58(Buffer.from(rawPub));
330
+ const rec = {
331
+ agentId: String(agentId || ''),
332
+ name: String(name || 'group'),
333
+ addr,
334
+ web: grokroomShareUrl(addr),
335
+ createdAt: Date.now(),
336
+ };
337
+ const all = readOnchainRooms(home).filter((r) => r && r.agentId !== rec.agentId);
338
+ all.push(rec);
339
+ writeOnchainRooms(all, home);
340
+ return rec;
341
+ }
342
+
343
+ /** Reuse a stored room for this agent, or mint one. */
344
+ export function ensureOnchainRoom(agent, home = os.homedir()) {
345
+ const addr = agent?.room?.addr || agent?.room?.address || '';
346
+ const web = grokroomShareUrl(addr);
347
+ if (web) return { agentId: String(agent.id || ''), name: String(agent.name || ''), addr, web };
348
+ const hit = readOnchainRooms(home).find((r) => r && r.agentId === agent?.id && grokroomShareUrl(r.addr));
349
+ if (hit) return { ...hit, web: grokroomShareUrl(hit.addr) };
350
+ return mintOnchainRoom({ agentId: agent?.id, name: agent?.name, home });
351
+ }
352
+
353
+ export function attachOnchainRoom(agent, home = os.homedir()) {
354
+ if (!agent || typeof agent !== 'object') return agent;
355
+ if (!agent.isGroup && !(agent.room && agent.room.id)) return agent;
356
+ if (agent.hidden) return agent;
357
+ const rec = ensureOnchainRoom(agent, home);
358
+ const id = String(agent.room?.id || rec.agentId || agent.id || '');
359
+ return {
360
+ ...agent,
361
+ room: { id, addr: rec.addr, web: rec.web },
362
+ };
363
+ }
364
+
365
+ /** Hidden bubble-member id for a grokroom sender. One row, many rooms. */
366
+ export function grokroomMemberId(name) {
367
+ const slug = String(name || 'anon').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 24) || 'anon';
368
+ return `roombot-${slug}`;
369
+ }
370
+
371
+ export function isGrokRoomAgent(a) {
372
+ if (!a || typeof a !== 'object') return false;
373
+ if (a.room && a.room.id) return true;
374
+ return /^room-/.test(String(a.id || ''));
375
+ }
376
+
377
+ export function isGrokRoomMember(a) {
378
+ if (!a || typeof a !== 'object') return false;
379
+ return a.hidden === true || a.hiddenFromSidebar === true || /^roombot-/.test(String(a.id || ''));
380
+ }
381
+
212
382
  /** Standing job text. shapeAgent used to drop this, so every restart was amnesia. */
213
383
  export function agentBrief(raw = {}) {
214
384
  const a = raw && typeof raw === 'object' ? raw : {};
@@ -237,7 +407,17 @@ export function shapeAgent(raw = {}) {
237
407
  : (Array.isArray(a.memberAgentIds) ? a.memberAgentIds.map((x) => String(x)).filter(Boolean) : []);
238
408
  const isGroup = a.isGroup === true || memberIds.length > 0;
239
409
  const name = displayName({ ...a, isGroup, memberIds });
240
- const brief = agentBrief({ ...a, name, title: a.title || name });
410
+ const hidden = a.hidden === true || a.hiddenFromSidebar === true;
411
+ let room = null;
412
+ if (a.room && typeof a.room === 'object' && a.room.id) {
413
+ const addr = String(a.room.addr || a.room.address || '');
414
+ const web = grokroomShareUrl(addr) || String(a.room.web || '').trim();
415
+ room = { id: String(a.room.id), addr, ...(web ? { web } : {}) };
416
+ } else if (/^room-/.test(id)) {
417
+ room = { id: id.slice(5), addr: '' };
418
+ }
419
+ const skipJob = hidden || !!room || /^#\s/.test(name);
420
+ const brief = skipJob ? String(a.brief || '').slice(0, 8000) : agentBrief({ ...a, name, title: a.title || name });
241
421
  return {
242
422
  id,
243
423
  name,
@@ -250,6 +430,8 @@ export function shapeAgent(raw = {}) {
250
430
  updatedAt: Number(a.updatedAt) || Date.now(),
251
431
  hasUnread: !!a.hasUnread,
252
432
  unreadCount: Number(a.unreadCount) || 0,
433
+ hidden, // GROKROOM-HIDDEN-BOTS
434
+ hiddenFromSidebar: hidden,
253
435
  notificationsEnabled: a.notificationsEnabled !== false,
254
436
  notifyOnUpdatesEnabled: a.notifyOnUpdatesEnabled !== false,
255
437
  isGroup,
@@ -260,6 +442,7 @@ export function shapeAgent(raw = {}) {
260
442
  ? a.awaitingUserResponse : null,
261
443
  avatarShape: a.avatarShape ?? null,
262
444
  avatarColor: a.avatarColor ?? null,
445
+ ...(room ? { room } : {}),
263
446
  };
264
447
  }
265
448
 
@@ -267,8 +450,9 @@ export function shapeAgent(raw = {}) {
267
450
  * Full sidebar roster for SSE `agents` / listAgents. Sorts by activity and
268
451
  * stamps unread -- does not slice. The old 80-cap hid agents past the tray.
269
452
  */
270
- export function rosterForEvent(list, activity) {
271
- const arr = Array.isArray(list) ? [...list] : [];
453
+ export function rosterForEvent(list, activity, opts) {
454
+ // GROKROOM-HIDDEN-BOTS: client-facing rosters never include hidden bots; persistence passes { includeHidden: true }
455
+ const arr = (Array.isArray(list) ? [...list] : []).filter((a) => (opts && opts.includeHidden) || !a?.hidden);
272
456
  arr.sort((x, y) => activityTs(y, activity) - activityTs(x, activity));
273
457
  return arr.map((agent) => {
274
458
  const rec = activityRec(agent, activity) || {};
@@ -363,7 +547,14 @@ export function addDeletedIds(home, ids) {
363
547
  export function filterDeleted(agents, home) {
364
548
  const del = readDeletedIds(home);
365
549
  if (!del.size || !Array.isArray(agents)) return agents || [];
366
- return agents.filter((a) => a && !del.has(String(a.id)));
550
+ return agents.filter((a) => {
551
+ if (!a) return false;
552
+ const id = String(a.id || '');
553
+ // grokroom threads + bubble-members are gossip identities, not user bots.
554
+ // The old sidebar roombots were tombstoned; native rooms must still load.
555
+ if (/^(room-|roombot-)/.test(id)) return true;
556
+ return !del.has(id);
557
+ });
367
558
  }
368
559
 
369
560
  export function writeWakeups(home, map) {
@@ -0,0 +1,386 @@
1
+ /**
2
+ * Grok Bot tray/roster.
3
+ *
4
+ * Electron hijack: a second Cursor login on the same Mac must not inherit the
5
+ * previous account's 1340 / tray (rosterForAccount still isolates that).
6
+ *
7
+ * Cafe/web: every visitor is a cookie identity on ONE house. They share the
8
+ * operator tray at ~/.openzoo/grokbot-agents.json plus any account agents.json
9
+ * under ~/.openzoo/grokbot/<id>/ -- not per-browser localStorage, not empty
10
+ * just because no Cursor account has logged in.
11
+ */
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+
15
+ export function accountSlug(accountId) {
16
+ const s = String(accountId || '').replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 64);
17
+ return s || null;
18
+ }
19
+
20
+ export function accountDir(home, accountId) {
21
+ const slug = accountSlug(accountId);
22
+ if (!slug) return null;
23
+ return path.join(home, '.openzoo', 'grokbot', slug);
24
+ }
25
+
26
+ export function accountPodPath(home, accountId) {
27
+ const dir = accountDir(home, accountId);
28
+ return dir ? path.join(dir, 'pod.json') : null;
29
+ }
30
+
31
+ export function accountAgentsPath(home, accountId) {
32
+ const dir = accountDir(home, accountId);
33
+ return dir ? path.join(dir, 'agents.json') : null;
34
+ }
35
+
36
+ export function houseAgentsPath(home) {
37
+ return path.join(home, '.openzoo', 'grokbot-agents.json');
38
+ }
39
+
40
+ function readJsonFile(p) {
41
+ try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; }
42
+ }
43
+
44
+ /** First-seen id wins. A later pile can only *upgrade* a UUID name/stub brief. */
45
+ export function mergeAgentRecords(piles) {
46
+ const seen = new Map();
47
+ const out = [];
48
+ for (const pile of piles) {
49
+ if (!Array.isArray(pile)) continue;
50
+ for (const a of pile) {
51
+ if (!a?.id) continue;
52
+ const idx = seen.get(a.id);
53
+ if (idx == null) {
54
+ seen.set(a.id, out.length);
55
+ out.push(a);
56
+ continue;
57
+ }
58
+ out[idx] = preferNamedAgent(out[idx], a);
59
+ }
60
+ }
61
+ return out;
62
+ }
63
+
64
+ /**
65
+ * Shared house tray from disk. Cafe visitors have no Cursor account — they
66
+ * still get this list. A live account's file is preferred, never required.
67
+ */
68
+ export function readHouseRoster(home, liveAccountId) {
69
+ const piles = [];
70
+ const livePath = liveAccountId ? accountAgentsPath(home, liveAccountId) : null;
71
+ if (livePath) {
72
+ const scoped = readJsonFile(livePath);
73
+ if (Array.isArray(scoped)) piles.push(scoped);
74
+ }
75
+ const global = readJsonFile(houseAgentsPath(home));
76
+ if (Array.isArray(global)) piles.push(global);
77
+ try {
78
+ const dir = path.join(home, '.openzoo', 'grokbot');
79
+ for (const name of fs.readdirSync(dir)) {
80
+ const p = path.join(dir, name, 'agents.json');
81
+ if (livePath && p === livePath) continue;
82
+ const a = readJsonFile(p);
83
+ if (Array.isArray(a) && a.length) piles.push(a);
84
+ }
85
+ } catch { /* no grokbot dir yet */ }
86
+ return mergeAgentRecords(piles);
87
+ }
88
+
89
+ /**
90
+ * Isolate two Cursor logins. Cafe (no live account) gets the house fallback.
91
+ * `fallback` is the merged disk roster; never return [] just because oauth
92
+ * hasn't run — that emptied the public site's sidebar.
93
+ */
94
+ export function rosterForAccount({ liveAccountId, cachedAccountId, cached, fallback }) {
95
+ if (liveAccountId && cachedAccountId && liveAccountId !== cachedAccountId) return [];
96
+ if (liveAccountId && cachedAccountId && liveAccountId === cachedAccountId) {
97
+ return Array.isArray(cached) ? cached : [];
98
+ }
99
+ if (Array.isArray(fallback) && fallback.length) return fallback;
100
+ return Array.isArray(cached) ? cached : [];
101
+ }
102
+
103
+ function activityRec(agent, activity) {
104
+ if (!activity || !agent?.id) return null;
105
+ if (typeof activity.get === 'function') return activity.get(agent.id) || null;
106
+ return activity[agent.id] || null;
107
+ }
108
+
109
+ function activityTs(agent, activity) {
110
+ const rec = activityRec(agent, activity);
111
+ return (rec && rec.updatedAt) || agent.updatedAt || agent.createdAt || 0;
112
+ }
113
+
114
+ /**
115
+ * Grok Bot client persistence (Xkn) DROPS any row missing these booleans /
116
+ * nulls, then clears the whole persisted tray. That is how a group vanished
117
+ * after the first send: bumpAgent wrote a partial row, restore returned null.
118
+ */
119
+ const AGENT_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
120
+
121
+ /** bumpAgent used `{id, name:id}` and 1340 listAgents often echoes that — sidebar then paints UUIDs. */
122
+ export function looksLikeAgentId(s, id) {
123
+ const n = String(s || '').trim();
124
+ if (!n) return true;
125
+ if (id && n === String(id)) return true;
126
+ return AGENT_UUID_RE.test(n);
127
+ }
128
+
129
+ export function isStubBrief(brief, id) {
130
+ const b = String(brief || '').trim();
131
+ if (!b) return true;
132
+ if (id && b.startsWith(`You are ${id}. Your job is ${id}`)) return true;
133
+ return /^You are [0-9a-f-]{36}\. Your job is [0-9a-f-]{36}/i.test(b);
134
+ }
135
+
136
+ /** Pull a human name out of a standing brief: "You are 6 · Content Studio…" / "Job: Product Simplification". */
137
+ export function nameFromBrief(brief, id) {
138
+ const b = String(brief || '').replace(/^\[brief\]\s*/i, '').trim();
139
+ if (!b) return '';
140
+ const job = b.match(/\bJob:\s*([^\n.]+)/i);
141
+ if (job) {
142
+ const n = job[1].trim();
143
+ if (n && !looksLikeAgentId(n, id)) return n.slice(0, 80);
144
+ }
145
+ const numbered = b.match(/^You are\s+(\d+\s*[·.•.\-—–]+\s*[^\n.]+)/i);
146
+ if (numbered) {
147
+ const n = numbered[1].replace(/\s+for\s+.*$/i, '').trim();
148
+ if (n && !looksLikeAgentId(n, id)) return n.slice(0, 80);
149
+ }
150
+ const you = b.match(/^You are\s+(.+?)(?:\.\s|$)/i);
151
+ if (you) {
152
+ const n = you[1].replace(/\s+for\s+Stacc(?:'s)?(?:\s+LLC)?$/i, '').trim();
153
+ if (n && !looksLikeAgentId(n, id)) return n.slice(0, 80);
154
+ }
155
+ return '';
156
+ }
157
+
158
+ export function nameQuality(a) {
159
+ const n = String(a?.name || a?.title || '').trim();
160
+ if (!n || looksLikeAgentId(n, a?.id)) return 0;
161
+ if (/^(chat|group)$/i.test(n)) return 1;
162
+ if (/^new bot$/i.test(n)) return 2;
163
+ return 3;
164
+ }
165
+
166
+ /** Keep the record that still has a human name / real brief. */
167
+ export function preferNamedAgent(keep, incoming) {
168
+ if (!keep) return incoming;
169
+ if (!incoming) return keep;
170
+ const kq = nameQuality(keep);
171
+ const iq = nameQuality(incoming);
172
+ const keepBrief = String(keep.brief || keep.instructions || '').trim();
173
+ const inBrief = String(incoming.brief || incoming.instructions || '').trim();
174
+ const keepStub = isStubBrief(keepBrief, keep.id);
175
+ const inStub = isStubBrief(inBrief, incoming.id);
176
+ if (iq <= kq && !(keepStub && !inStub)) return keep;
177
+ const nameSrc = iq > kq ? incoming : keep;
178
+ const briefSrc = (!inStub && keepStub) ? incoming : keep;
179
+ return {
180
+ ...keep,
181
+ ...incoming,
182
+ name: nameSrc.name || nameSrc.title,
183
+ title: nameSrc.title || nameSrc.name,
184
+ brief: briefSrc.brief || briefSrc.instructions || keepBrief || inBrief,
185
+ };
186
+ }
187
+
188
+ export function displayName(raw = {}) {
189
+ const a = raw && typeof raw === 'object' ? raw : {};
190
+ const id = String(a.id || '');
191
+ const isGroup = a.isGroup === true
192
+ || (Array.isArray(a.memberIds) && a.memberIds.length > 0)
193
+ || (Array.isArray(a.memberAgentIds) && a.memberAgentIds.length > 0);
194
+ for (const cand of [a.name, a.title]) {
195
+ const n = String(cand || '').trim();
196
+ if (n && !looksLikeAgentId(n, id)) return n.slice(0, 80);
197
+ }
198
+ const fromBrief = nameFromBrief(a.brief || a.instructions || a.description, id);
199
+ if (fromBrief) return fromBrief;
200
+ return isGroup ? 'group' : 'chat';
201
+ }
202
+
203
+ /** Standing job from a sidebar name like "6 · Content Studio" or "Bot 1 — Marketing (X)". */
204
+ export function briefFromName(name) {
205
+ const n = String(name || '').trim();
206
+ if (!n || /^(new bot|chat|group)$/i.test(n) || looksLikeAgentId(n)) return '';
207
+ const role = n.replace(/^\d+\s*[·.•.\-—–]+\s*/, '').trim() || n;
208
+ if (role.length < 2) return '';
209
+ return `You are ${n}. Your job is ${role}. Do that job. Do not ask the human to re-brief you. Coordinate with list_agents and message_agent.`;
210
+ }
211
+
212
+ /** Standing job text. shapeAgent used to drop this, so every restart was amnesia. */
213
+ export function agentBrief(raw = {}) {
214
+ const a = raw && typeof raw === 'object' ? raw : {};
215
+ const id = String(a.id || '');
216
+ for (const k of ['brief', 'instructions', 'customInstructions', 'systemPrompt']) {
217
+ const v = a[k];
218
+ if (typeof v === 'string' && v.trim() && !isStubBrief(v, id)) {
219
+ const name = displayName(a);
220
+ const text = v.trim();
221
+ if (id && name && !looksLikeAgentId(name, id) && text.startsWith(`You are ${id}`)) {
222
+ return (`You are ${name}` + text.slice(`You are ${id}`.length)).slice(0, 8000);
223
+ }
224
+ return text.slice(0, 8000);
225
+ }
226
+ }
227
+ const d = String(a.description || '').trim();
228
+ if (d && !isStubBrief(d, id)) return d.slice(0, 8000);
229
+ return briefFromName(displayName(a)).slice(0, 8000);
230
+ }
231
+
232
+ export function shapeAgent(raw = {}) {
233
+ const a = raw && typeof raw === 'object' ? raw : {};
234
+ const id = String(a.id || '');
235
+ const memberIds = Array.isArray(a.memberIds)
236
+ ? a.memberIds.map((x) => String(x)).filter(Boolean)
237
+ : (Array.isArray(a.memberAgentIds) ? a.memberAgentIds.map((x) => String(x)).filter(Boolean) : []);
238
+ const isGroup = a.isGroup === true || memberIds.length > 0;
239
+ const name = displayName({ ...a, isGroup, memberIds });
240
+ const brief = agentBrief({ ...a, name, title: a.title || name });
241
+ return {
242
+ id,
243
+ name,
244
+ brief,
245
+ description: String((a.description && !looksLikeAgentId(a.description, id) && a.description) || brief || ''),
246
+ title: String((!looksLikeAgentId(a.title, id) && a.title) || name),
247
+ origin: String(a.origin || 'user'),
248
+ path: String(a.path || (id ? `/local/${id}` : '/local')),
249
+ createdAt: Number(a.createdAt) || Date.now(),
250
+ updatedAt: Number(a.updatedAt) || Date.now(),
251
+ hasUnread: !!a.hasUnread,
252
+ unreadCount: Number(a.unreadCount) || 0,
253
+ notificationsEnabled: a.notificationsEnabled !== false,
254
+ notifyOnUpdatesEnabled: a.notifyOnUpdatesEnabled !== false,
255
+ isGroup,
256
+ memberIds: isGroup ? memberIds : [],
257
+ lastMessageId: a.lastMessageId == null ? null : String(a.lastMessageId),
258
+ lastEntry: a.lastEntry && typeof a.lastEntry === 'object' ? a.lastEntry : null,
259
+ awaitingUserResponse: a.awaitingUserResponse && typeof a.awaitingUserResponse === 'object'
260
+ ? a.awaitingUserResponse : null,
261
+ avatarShape: a.avatarShape ?? null,
262
+ avatarColor: a.avatarColor ?? null,
263
+ };
264
+ }
265
+
266
+ /**
267
+ * Full sidebar roster for SSE `agents` / listAgents. Sorts by activity and
268
+ * stamps unread -- does not slice. The old 80-cap hid agents past the tray.
269
+ */
270
+ export function rosterForEvent(list, activity) {
271
+ const arr = Array.isArray(list) ? [...list] : [];
272
+ arr.sort((x, y) => activityTs(y, activity) - activityTs(x, activity));
273
+ return arr.map((agent) => {
274
+ const rec = activityRec(agent, activity) || {};
275
+ return shapeAgent({
276
+ ...agent,
277
+ updatedAt: rec.updatedAt || agent.updatedAt || agent.createdAt || 0,
278
+ hasUnread: !!rec.hasUnread,
279
+ unreadCount: rec.unreadCount || 0,
280
+ });
281
+ });
282
+ }
283
+
284
+ export const WAKEUP_MIN_SEC = 60;
285
+ export const WAKEUP_MAX_SEC = 6 * 3600;
286
+ export const WAKEUP_DEFAULT_SEC = 5 * 60;
287
+ export const DEFAULT_WAKEUP_PROMPT = 'Wakeup. Continue your brief. Do the next real action (write a file, click, or message a worker). Do not only re-read sitrep. Do not spawn more bots. Do not exec sysctl/uptime.';
288
+
289
+ export function wakeupsPath(home) {
290
+ return path.join(home, '.openzoo', 'grokbot-wakeups.json');
291
+ }
292
+
293
+ /** "5m" / "1h" / "90" / "30s". Floor 60s so a never-stop cron cannot storm. */
294
+ export function parseWakeupEvery(raw) {
295
+ const s = String(raw ?? '').trim().toLowerCase();
296
+ if (!s) return WAKEUP_DEFAULT_SEC;
297
+ const m = s.match(/^(\d+(?:\.\d+)?)\s*(s|sec|secs|seconds?|m|min|mins|minutes?|h|hr|hrs|hours?)?$/);
298
+ if (!m) return WAKEUP_DEFAULT_SEC;
299
+ const n = Number(m[1]);
300
+ if (!Number.isFinite(n) || n <= 0) return WAKEUP_DEFAULT_SEC;
301
+ const u = m[2] || 's';
302
+ let sec = n;
303
+ if (/^m/.test(u)) sec = n * 60;
304
+ else if (/^h/.test(u)) sec = n * 3600;
305
+ return Math.min(WAKEUP_MAX_SEC, Math.max(WAKEUP_MIN_SEC, Math.round(sec)));
306
+ }
307
+
308
+ export function wantsWakeupCron(prompt) {
309
+ const s = String(prompt || '');
310
+ if (/\bcron\b.{0,48}\bwakeups?\b/i.test(s)) return true;
311
+ if (/\bwakeups?\b.{0,48}\bcron\b/i.test(s)) return true;
312
+ if (/\bnever stop\b/i.test(s)) return true;
313
+ if (/\bschedule\b.{0,24}\bwakeups?\b/i.test(s)) return true;
314
+ return false;
315
+ }
316
+
317
+ export function shapeWakeup(agentId, rec = {}, now = Date.now()) {
318
+ const everySec = parseWakeupEvery(rec.everySec ?? rec.every ?? rec.interval);
319
+ const prompt = String(rec.prompt || DEFAULT_WAKEUP_PROMPT).trim().slice(0, 2000)
320
+ || DEFAULT_WAKEUP_PROMPT;
321
+ const lastAt = Number(rec.lastAt) || 0;
322
+ let nextAt = Number(rec.nextAt) || 0;
323
+ if (!Number.isFinite(nextAt) || nextAt <= 0) nextAt = now + everySec * 1000;
324
+ return {
325
+ agentId: String(agentId || rec.agentId || ''),
326
+ everySec,
327
+ prompt,
328
+ lastAt,
329
+ nextAt,
330
+ };
331
+ }
332
+
333
+ export function readWakeups(home) {
334
+ const raw = readJsonFile(wakeupsPath(home));
335
+ const src = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
336
+ const out = {};
337
+ for (const [id, rec] of Object.entries(src)) {
338
+ const w = shapeWakeup(id, rec);
339
+ if (w.agentId) out[w.agentId] = w;
340
+ }
341
+ return out;
342
+ }
343
+
344
+ export function deletedAgentsPath(home) {
345
+ return path.join(home, '.openzoo', 'grokbot-deleted.json');
346
+ }
347
+
348
+ export function readDeletedIds(home) {
349
+ const raw = readJsonFile(deletedAgentsPath(home));
350
+ const ids = Array.isArray(raw) ? raw : (raw && Array.isArray(raw.ids) ? raw.ids : []);
351
+ return new Set(ids.map(String).filter(Boolean));
352
+ }
353
+
354
+ export function addDeletedIds(home, ids) {
355
+ const s = readDeletedIds(home);
356
+ for (const id of ids) if (id) s.add(String(id));
357
+ const dir = path.dirname(deletedAgentsPath(home));
358
+ fs.mkdirSync(dir, { recursive: true });
359
+ fs.writeFileSync(deletedAgentsPath(home), JSON.stringify([...s], null, 2));
360
+ return s;
361
+ }
362
+
363
+ export function filterDeleted(agents, home) {
364
+ const del = readDeletedIds(home);
365
+ if (!del.size || !Array.isArray(agents)) return agents || [];
366
+ return agents.filter((a) => a && !del.has(String(a.id)));
367
+ }
368
+
369
+ export function writeWakeups(home, map) {
370
+ const dir = path.dirname(wakeupsPath(home));
371
+ fs.mkdirSync(dir, { recursive: true });
372
+ fs.writeFileSync(wakeupsPath(home), JSON.stringify(map || {}, null, 2));
373
+ }
374
+
375
+ export function callerKeyFromAuth(authorization) {
376
+ const a = String(authorization || '').trim();
377
+ if (!a) return '';
378
+ // Not a secret store — a session-scoped equality key so a second oauth
379
+ // does not inherit the previous account's 1340 / tray cache.
380
+ let h = 2166136261;
381
+ for (let i = 0; i < a.length; i++) {
382
+ h ^= a.charCodeAt(i);
383
+ h = Math.imul(h, 16777619);
384
+ }
385
+ return (h >>> 0).toString(16);
386
+ }