openzoo 0.50.96 → 0.50.97
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/bin/openzoo.js +26 -2
- package/lib/cursorbackend.js +5 -5
- package/lib/cursorbackend.js.bak-hiddenbots +4072 -0
- package/lib/grokbotAccount.js +4 -2
- package/lib/grokbotAccount.js.bak-hiddenbots +386 -0
- package/lib/grokcli.js +89 -7
- package/lib/launch.js +36 -5
- package/lib/modelroute/README.md +1 -0
- package/lib/modelroute/catalog.json +1 -0
- package/lib/modelroute/outcomes.json +1566 -0
- package/lib/modelroute/router.json +1 -0
- package/lib/modelroute.js +737 -0
- package/lib/models.js +30 -0
- package/lib/podagent.mjs +1425 -0
- package/lib/proxy.js +72 -2
- package/lib/runguard.js +31 -0
- package/lib/spill.js +2031 -0
- package/lib/subscription.js +207 -0
- package/lib/websearch.js +31 -0
- package/lib/worktree.mjs +424 -0
- package/package.json +1 -1
package/lib/grokbotAccount.js
CHANGED
|
@@ -250,6 +250,7 @@ export function shapeAgent(raw = {}) {
|
|
|
250
250
|
updatedAt: Number(a.updatedAt) || Date.now(),
|
|
251
251
|
hasUnread: !!a.hasUnread,
|
|
252
252
|
unreadCount: Number(a.unreadCount) || 0,
|
|
253
|
+
hidden: a.hidden === true || a.hiddenFromSidebar === true, // GROKROOM-HIDDEN-BOTS
|
|
253
254
|
notificationsEnabled: a.notificationsEnabled !== false,
|
|
254
255
|
notifyOnUpdatesEnabled: a.notifyOnUpdatesEnabled !== false,
|
|
255
256
|
isGroup,
|
|
@@ -267,8 +268,9 @@ export function shapeAgent(raw = {}) {
|
|
|
267
268
|
* Full sidebar roster for SSE `agents` / listAgents. Sorts by activity and
|
|
268
269
|
* stamps unread -- does not slice. The old 80-cap hid agents past the tray.
|
|
269
270
|
*/
|
|
270
|
-
export function rosterForEvent(list, activity) {
|
|
271
|
-
|
|
271
|
+
export function rosterForEvent(list, activity, opts) {
|
|
272
|
+
// GROKROOM-HIDDEN-BOTS: client-facing rosters never include hidden bots; persistence passes { includeHidden: true }
|
|
273
|
+
const arr = (Array.isArray(list) ? [...list] : []).filter((a) => (opts && opts.includeHidden) || !a?.hidden);
|
|
272
274
|
arr.sort((x, y) => activityTs(y, activity) - activityTs(x, activity));
|
|
273
275
|
return arr.map((agent) => {
|
|
274
276
|
const rec = activityRec(agent, activity) || {};
|
|
@@ -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
|
+
}
|
package/lib/grokcli.js
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
// "payment required".
|
|
17
17
|
|
|
18
18
|
import { spawn, execSync } from 'node:child_process';
|
|
19
|
-
import { existsSync, readFileSync, writeFileSync, copyFileSync, mkdirSync } from 'node:fs';
|
|
19
|
+
import { existsSync, readFileSync, writeFileSync, copyFileSync, mkdirSync, readdirSync, chmodSync } from 'node:fs';
|
|
20
20
|
import { homedir } from 'node:os';
|
|
21
21
|
import { dirname, join } from 'node:path';
|
|
22
22
|
import { fileURLToPath } from 'node:url';
|
|
@@ -31,7 +31,68 @@ import {
|
|
|
31
31
|
|
|
32
32
|
const GROK_HOME = process.env.GROK_HOME || join(homedir(), '.grok');
|
|
33
33
|
const CONFIG_PATH = join(GROK_HOME, 'config.toml');
|
|
34
|
-
const APP = '/Applications/Grok Bot.app';
|
|
34
|
+
const APP = process.env.GROK_APP || '/Applications/Grok Bot.app';
|
|
35
|
+
|
|
36
|
+
/* LINUX/WIN DISCOVERY — the multiarch release ships Grok_Bot_<v>_<plat>-<arch>.AppImage
|
|
37
|
+
* / .exe. GROKBOT_BIN env always wins. */
|
|
38
|
+
function linuxAppImage() {
|
|
39
|
+
const home = homedir();
|
|
40
|
+
const roots = [
|
|
41
|
+
join(home, 'Applications'),
|
|
42
|
+
join(home, '.local', 'bin'),
|
|
43
|
+
join(home, 'Downloads'),
|
|
44
|
+
'/opt',
|
|
45
|
+
'/usr/local/bin',
|
|
46
|
+
'/usr/bin',
|
|
47
|
+
];
|
|
48
|
+
for (const dir of roots) {
|
|
49
|
+
try {
|
|
50
|
+
const hit = readdirSync(dir)
|
|
51
|
+
.filter((f) => /^grok[ _-]?bot.*\.appimage$/i.test(f))
|
|
52
|
+
.sort();
|
|
53
|
+
if (hit.length) return join(dir, hit[hit.length - 1]);
|
|
54
|
+
} catch { /* unreadable dir */ }
|
|
55
|
+
}
|
|
56
|
+
return join(home, '.local', 'bin', 'Grok_Bot.AppImage');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function windowsExe() {
|
|
60
|
+
const home = homedir();
|
|
61
|
+
const dirs = [
|
|
62
|
+
join(home, 'AppData', 'Local', 'Programs', 'Grok Bot'),
|
|
63
|
+
join(home, 'AppData', 'Local', 'Programs', 'grok-bot'),
|
|
64
|
+
'C:\\Program Files\\Grok Bot',
|
|
65
|
+
];
|
|
66
|
+
for (const dir of dirs) {
|
|
67
|
+
for (const name of ['Grok Bot.exe', 'Grok_Bot.exe', 'grokbot.exe']) {
|
|
68
|
+
const p = join(dir, name);
|
|
69
|
+
if (existsSync(p)) return p;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return join(home, 'AppData', 'Local', 'Programs', 'Grok Bot', 'Grok Bot.exe');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Cross-platform Grok Bot binary. GROKBOT_BIN env always wins. */
|
|
76
|
+
export function grokBotBinary() {
|
|
77
|
+
if (process.env.GROKBOT_BIN) return process.env.GROKBOT_BIN;
|
|
78
|
+
if (process.platform === 'darwin') return `${APP}/Contents/MacOS/Grok Bot`;
|
|
79
|
+
if (process.platform === 'linux') return linuxAppImage();
|
|
80
|
+
if (process.platform === 'win32') return windowsExe();
|
|
81
|
+
return `${APP}/Contents/MacOS/Grok Bot`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Cross-platform quit: osascript on mac, SIGTERM pids elsewhere. */
|
|
85
|
+
function quitGrokBot() {
|
|
86
|
+
try {
|
|
87
|
+
if (process.platform === 'darwin') {
|
|
88
|
+
quitGrokBot();
|
|
89
|
+
} else {
|
|
90
|
+
for (const pid of grokBotPids()) {
|
|
91
|
+
try { process.kill(Number(pid), 'SIGTERM'); } catch { /* gone */ }
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
} catch { /* ok */ }
|
|
95
|
+
}
|
|
35
96
|
|
|
36
97
|
/** Grok ids as the zoo serves them. Verified against GET /v1/models at write
|
|
37
98
|
* time so a renamed/retired model never lands in the picker as a dead row. */
|
|
@@ -105,7 +166,7 @@ ${body}
|
|
|
105
166
|
`;
|
|
106
167
|
}
|
|
107
168
|
|
|
108
|
-
const GROK_BOT_BIN =
|
|
169
|
+
const GROK_BOT_BIN = grokBotBinary();
|
|
109
170
|
|
|
110
171
|
/** pids whose command is the Grok Bot main binary (not grep itself). */
|
|
111
172
|
export function grokBotPids(run = execSync) {
|
|
@@ -120,6 +181,14 @@ export function grokBotPids(run = execSync) {
|
|
|
120
181
|
function pidHasNeedle(pid, needle, run = execSync) {
|
|
121
182
|
const n = Number(pid);
|
|
122
183
|
if (!Number.isInteger(n) || n <= 0) return false;
|
|
184
|
+
if (process.platform === 'linux') {
|
|
185
|
+
try {
|
|
186
|
+
// /proc/<pid>/environ is NUL-separated; flatten and test.
|
|
187
|
+
const env = readFileSync(`/proc/${n}/environ`, 'utf8');
|
|
188
|
+
if (env.replace(/\0/g, '\n').includes(needle)) return true;
|
|
189
|
+
} catch { /* gone */ }
|
|
190
|
+
}
|
|
191
|
+
if (process.platform === 'win32') return false;
|
|
123
192
|
for (const cmd of [`ps -Eww -p ${n} -o command=`, `ps eww -p ${n}`]) {
|
|
124
193
|
try {
|
|
125
194
|
if (run(cmd, { encoding: 'utf8', timeout: 2000 }).includes(needle)) return true;
|
|
@@ -130,6 +199,14 @@ function pidHasNeedle(pid, needle, run = execSync) {
|
|
|
130
199
|
|
|
131
200
|
function somethingTalksToPort(port, run = execSync) {
|
|
132
201
|
try {
|
|
202
|
+
if (process.platform === 'linux') {
|
|
203
|
+
const out = run('ss -tnp', { encoding: 'utf8', timeout: 2000 });
|
|
204
|
+
return out.includes(':' + Number(port)) && /grok|electron|bot/i.test(out);
|
|
205
|
+
}
|
|
206
|
+
if (process.platform === 'win32') {
|
|
207
|
+
const out = run('netstat -ano', { encoding: 'utf8', timeout: 5000 });
|
|
208
|
+
return new RegExp(':' + Number(port) + '[^0-9].*ESTABLISHED', 'i').test(out);
|
|
209
|
+
}
|
|
133
210
|
const out = run(`lsof -nP -iTCP:${Number(port)} -sTCP:ESTABLISHED`, {
|
|
134
211
|
encoding: 'utf8', timeout: 2000,
|
|
135
212
|
});
|
|
@@ -208,9 +285,11 @@ export async function runBot(argv = []) {
|
|
|
208
285
|
}
|
|
209
286
|
const port = 8443;
|
|
210
287
|
const url = `https://127.0.0.1:${port}`;
|
|
211
|
-
const bin =
|
|
288
|
+
const bin = GROK_BOT_BIN;
|
|
212
289
|
if (!existsSync(bin)) {
|
|
213
|
-
console.error(
|
|
290
|
+
console.error(`openzoo: Grok Bot binary not found — looked for ${bin}`);
|
|
291
|
+
console.error(' set GROKBOT_BIN=/path/to/Grok_Bot_0.36.0_linux-x64.AppImage');
|
|
292
|
+
console.error(' or drop the AppImage in ~/Applications, ~/.local/bin, /opt, ~/Downloads');
|
|
214
293
|
process.exit(1);
|
|
215
294
|
}
|
|
216
295
|
|
|
@@ -294,13 +373,16 @@ export async function runBot(argv = []) {
|
|
|
294
373
|
});
|
|
295
374
|
console.error(`openzoo: grokbot ${plan.reason}`);
|
|
296
375
|
if (plan.quit) {
|
|
297
|
-
try {
|
|
376
|
+
try { quitGrokBot(); } catch { /* ok */ }
|
|
298
377
|
await new Promise((r) => setTimeout(r, 1500));
|
|
299
378
|
}
|
|
300
379
|
|
|
301
380
|
const launchGrokBot = () => {
|
|
302
381
|
console.error('openzoo: launching Grok Bot');
|
|
303
382
|
console.error(` CURSOR_API_BASE_URL=${url}`);
|
|
383
|
+
if (process.platform === 'linux' && /\.appimage$/i.test(bin)) {
|
|
384
|
+
try { chmodSync(bin, 0o755); } catch { /* read-only */ }
|
|
385
|
+
}
|
|
304
386
|
spawn(bin, grokBotChromiumArgs(), {
|
|
305
387
|
stdio: 'ignore',
|
|
306
388
|
detached: true,
|
|
@@ -481,7 +563,7 @@ export async function setupGrokBot(argv = []) {
|
|
|
481
563
|
// api2.cursor.sh. MEASURED: the backend logged "listening" and then zero
|
|
482
564
|
// requests, while the app carried on answering normally.
|
|
483
565
|
try {
|
|
484
|
-
|
|
566
|
+
quitGrokBot();
|
|
485
567
|
} catch { /* not running is fine */ }
|
|
486
568
|
// give it a moment to actually exit before relaunching
|
|
487
569
|
await new Promise((r) => setTimeout(r, 2500));
|
package/lib/launch.js
CHANGED
|
@@ -164,7 +164,30 @@ export function claudeZooEnv(baseEnv = process.env, { base, port } = {}) {
|
|
|
164
164
|
// Correct here for the same reason as everything else in this block: the
|
|
165
165
|
// proxy binds the prefix and forwards a bounded tail, so the request that
|
|
166
166
|
// leaves this machine stays small no matter how long the conversation runs.
|
|
167
|
-
|
|
167
|
+
//
|
|
168
|
+
// OPT-IN NOW, BECAUSE THE PRECONDITION IS NOT REAL.
|
|
169
|
+
//
|
|
170
|
+
// Everything above is correct ONLY IF something bounds the body that leaves
|
|
171
|
+
// this machine. Two comments here named that something — `spillTranscript`,
|
|
172
|
+
// and the knobs `OPENZOO_TAIL_MAX_CHARS` / `OPENZOO_KEEP_TAIL_MSGS`. None of
|
|
173
|
+
// the three exists: `grep -rn spillTranscript` matches nothing but these
|
|
174
|
+
// comments, in this package and in the gateway. Checked 2026-09-03.
|
|
175
|
+
//
|
|
176
|
+
// So the shipped behaviour was: compaction off, ceiling raised to 1M, and
|
|
177
|
+
// NOTHING trimming the transcript — Claude Code accumulates without limit and
|
|
178
|
+
// re-sends the whole thing every turn. The gateway's leCore cannot rescue it
|
|
179
|
+
// either: an agent body is few-and-huge, so `msgs.length <= KEEP_TAIL` leaves
|
|
180
|
+
// nothing "older than the live window", and what bulk there is sits in the
|
|
181
|
+
// system block and first user turn, both deliberately never spilled.
|
|
182
|
+
//
|
|
183
|
+
// MEASURED on a user's session: 20 calls, $13.86, `spilled 0/20`, 1.0159x vs
|
|
184
|
+
// direct — paying almost exactly retail to send an ever-growing transcript.
|
|
185
|
+
//
|
|
186
|
+
// Until a real bound ships, default to Claude Code's own behaviour, which is
|
|
187
|
+
// bounded and known-good. OPENZOO_UNBOUNDED_CONTEXT=1 restores the old
|
|
188
|
+
// settings for anyone who wants them back.
|
|
189
|
+
const unboundedContext = baseEnv.OPENZOO_UNBOUNDED_CONTEXT === '1';
|
|
190
|
+
if (unboundedContext && baseEnv.OPENZOO_KEEP_COMPACT !== '1') {
|
|
168
191
|
env.DISABLE_COMPACT = baseEnv.DISABLE_COMPACT || '1';
|
|
169
192
|
env.DISABLE_AUTO_COMPACT = baseEnv.DISABLE_AUTO_COMPACT || '1';
|
|
170
193
|
}
|
|
@@ -177,9 +200,9 @@ export function claudeZooEnv(baseEnv = process.env, { base, port } = {}) {
|
|
|
177
200
|
// sent. Disabling auto-compact WITHOUT raising this made it worse: it used to
|
|
178
201
|
// compact and carry on, and instead it just stopped.
|
|
179
202
|
//
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
if (baseEnv.OPENZOO_KEEP_COMPACT !== '1' && !baseEnv.CLAUDE_CODE_MAX_CONTEXT_TOKENS) {
|
|
203
|
+
// WAS justified by `spillTranscript` / OPENZOO_TAIL_MAX_CHARS — neither of
|
|
204
|
+
// which exists. Now gated behind OPENZOO_UNBOUNDED_CONTEXT; see below.
|
|
205
|
+
if (unboundedContext && baseEnv.OPENZOO_KEEP_COMPACT !== '1' && !baseEnv.CLAUDE_CODE_MAX_CONTEXT_TOKENS) {
|
|
183
206
|
env.CLAUDE_CODE_MAX_CONTEXT_TOKENS = baseEnv.OPENZOO_CLAUDE_CONTEXT_TOKENS || '1000000';
|
|
184
207
|
}
|
|
185
208
|
return env;
|
|
@@ -360,7 +383,15 @@ export async function launchClaude(argv) {
|
|
|
360
383
|
// side by side is the only way the markup is visible while it is happening.
|
|
361
384
|
+ 'const ac=j.actual||{};'
|
|
362
385
|
+ 'const real=(ac.calls>0)?(" \\u00b7 $"+Number(ac.upstreamUsd||0).toFixed(4)+" real"+(ac.markupX?(" ("+ac.markupX+"x)"):"")):"";'
|
|
363
|
-
|
|
386
|
+
// TWO NUMBERS, NEVER ONE. `spent` is cumulative across every session on
|
|
387
|
+
// this machine (restored from ~/.openzoo/session.json); `wallet` is what
|
|
388
|
+
// is left to pay with. Shown unlabelled, the first was read as the
|
|
389
|
+
// second — "$13.86" on screen while the wallet held $0.18 and every call
|
|
390
|
+
// 402'd underfunded. The words `spent` and `wallet` are the whole fix.
|
|
391
|
+
+ 'const wu=Number(j.walletUsd);'
|
|
392
|
+
+ 'const wcol=(!isFinite(wu)||wu<=0)?"\\x1b[31m":(wu<0.5?"\\x1b[33m":"\\x1b[90m");'
|
|
393
|
+
+ 'const wal=isFinite(wu)?(" \\u00b7 "+wcol+"wallet $"+wu.toFixed(2)+"\\x1b[0m"):"";'
|
|
394
|
+
+ 'process.stdout.write("\\x1b[38;5;208m\\u25cf\\x1b[0m openzoo spent $"+(Number(j.spendUsd)||0).toFixed(4)+" all-time"+wal+" \\u00b7 "+(j.paidCalls||0)+" call"+((j.paidCalls||0)===1?"":"s")+save+real+spill+cr+" \\u00b7 x402")}'
|
|
364
395
|
+ 'catch{process.stdout.write("\\x1b[38;5;208m\\u25cf\\x1b[0m openzoo \\u00b7 x402")}})\'\n');
|
|
365
396
|
fs.chmodSync(scriptPath, 0o755);
|
|
366
397
|
const settingsPath = path.join(os.homedir(), '.claude', 'settings.json');
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Measured route table. catalog.json + router.json + outcomes.json. Regenerated from leCore bench_ledger_hard.jsonl. Do not edit by hand.
|