openzoo 0.50.37 → 0.50.39

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.
@@ -1,10 +1,15 @@
1
1
  /**
2
- * Grok Bot tray/roster is per Cursor account, not per Mac.
2
+ * Grok Bot tray/roster.
3
3
  *
4
- * A second login on the same hijack was served ~/.openzoo/grokbot-agents.json
5
- * (or an empty stub) because pod + roster were machine-global. Historical
6
- * chats live on that account's cursorvm 1340 — never another account's cache.
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.
7
11
  */
12
+ import fs from 'node:fs';
8
13
  import path from 'node:path';
9
14
 
10
15
  export function accountSlug(accountId) {
@@ -28,13 +33,134 @@ export function accountAgentsPath(home, accountId) {
28
33
  return dir ? path.join(dir, 'agents.json') : null;
29
34
  }
30
35
 
31
- /** Only serve a cached tray when it belongs to the live EnsureSandBox account. */
32
- export function rosterForAccount({ liveAccountId, cachedAccountId, cached }) {
33
- if (!liveAccountId || !cachedAccountId) return [];
34
- if (liveAccountId !== cachedAccountId) return [];
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. Account file, then house file, then other account dirs. */
45
+ export function mergeAgentRecords(piles) {
46
+ const seen = new Set();
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 || seen.has(a.id)) continue;
52
+ seen.add(a.id);
53
+ out.push(a);
54
+ }
55
+ }
56
+ return out;
57
+ }
58
+
59
+ /**
60
+ * Shared house tray from disk. Cafe visitors have no Cursor account — they
61
+ * still get this list. A live account's file is preferred, never required.
62
+ */
63
+ export function readHouseRoster(home, liveAccountId) {
64
+ const piles = [];
65
+ const livePath = liveAccountId ? accountAgentsPath(home, liveAccountId) : null;
66
+ if (livePath) {
67
+ const scoped = readJsonFile(livePath);
68
+ if (Array.isArray(scoped)) piles.push(scoped);
69
+ }
70
+ const global = readJsonFile(houseAgentsPath(home));
71
+ if (Array.isArray(global)) piles.push(global);
72
+ try {
73
+ const dir = path.join(home, '.openzoo', 'grokbot');
74
+ for (const name of fs.readdirSync(dir)) {
75
+ const p = path.join(dir, name, 'agents.json');
76
+ if (livePath && p === livePath) continue;
77
+ const a = readJsonFile(p);
78
+ if (Array.isArray(a) && a.length) piles.push(a);
79
+ }
80
+ } catch { /* no grokbot dir yet */ }
81
+ return mergeAgentRecords(piles);
82
+ }
83
+
84
+ /**
85
+ * Isolate two Cursor logins. Cafe (no live account) gets the house fallback.
86
+ * `fallback` is the merged disk roster; never return [] just because oauth
87
+ * hasn't run — that emptied the public site's sidebar.
88
+ */
89
+ export function rosterForAccount({ liveAccountId, cachedAccountId, cached, fallback }) {
90
+ if (liveAccountId && cachedAccountId && liveAccountId !== cachedAccountId) return [];
91
+ if (liveAccountId && cachedAccountId && liveAccountId === cachedAccountId) {
92
+ return Array.isArray(cached) ? cached : [];
93
+ }
94
+ if (Array.isArray(fallback) && fallback.length) return fallback;
35
95
  return Array.isArray(cached) ? cached : [];
36
96
  }
37
97
 
98
+ function activityRec(agent, activity) {
99
+ if (!activity || !agent?.id) return null;
100
+ if (typeof activity.get === 'function') return activity.get(agent.id) || null;
101
+ return activity[agent.id] || null;
102
+ }
103
+
104
+ function activityTs(agent, activity) {
105
+ const rec = activityRec(agent, activity);
106
+ return (rec && rec.updatedAt) || agent.updatedAt || agent.createdAt || 0;
107
+ }
108
+
109
+ /**
110
+ * Grok Bot client persistence (Xkn) DROPS any row missing these booleans /
111
+ * nulls, then clears the whole persisted tray. That is how a group vanished
112
+ * after the first send: bumpAgent wrote a partial row, restore returned null.
113
+ */
114
+ export function shapeAgent(raw = {}) {
115
+ const a = raw && typeof raw === 'object' ? raw : {};
116
+ const id = String(a.id || '');
117
+ const memberIds = Array.isArray(a.memberIds)
118
+ ? a.memberIds.map((x) => String(x)).filter(Boolean)
119
+ : (Array.isArray(a.memberAgentIds) ? a.memberAgentIds.map((x) => String(x)).filter(Boolean) : []);
120
+ const isGroup = a.isGroup === true || memberIds.length > 0;
121
+ const name = String(a.name || a.title || (isGroup ? 'group' : 'chat'));
122
+ return {
123
+ id,
124
+ name,
125
+ description: String(a.description || ''),
126
+ title: String(a.title || name),
127
+ origin: String(a.origin || 'user'),
128
+ path: String(a.path || (id ? `/local/${id}` : '/local')),
129
+ createdAt: Number(a.createdAt) || Date.now(),
130
+ updatedAt: Number(a.updatedAt) || Date.now(),
131
+ hasUnread: !!a.hasUnread,
132
+ unreadCount: Number(a.unreadCount) || 0,
133
+ notificationsEnabled: a.notificationsEnabled !== false,
134
+ notifyOnUpdatesEnabled: a.notifyOnUpdatesEnabled !== false,
135
+ isGroup,
136
+ memberIds: isGroup ? memberIds : [],
137
+ lastMessageId: a.lastMessageId == null ? null : String(a.lastMessageId),
138
+ lastEntry: a.lastEntry && typeof a.lastEntry === 'object' ? a.lastEntry : null,
139
+ awaitingUserResponse: a.awaitingUserResponse && typeof a.awaitingUserResponse === 'object'
140
+ ? a.awaitingUserResponse : null,
141
+ avatarShape: a.avatarShape ?? null,
142
+ avatarColor: a.avatarColor ?? null,
143
+ };
144
+ }
145
+
146
+ /**
147
+ * Full sidebar roster for SSE `agents` / listAgents. Sorts by activity and
148
+ * stamps unread -- does not slice. The old 80-cap hid agents past the tray.
149
+ */
150
+ export function rosterForEvent(list, activity) {
151
+ const arr = Array.isArray(list) ? [...list] : [];
152
+ arr.sort((x, y) => activityTs(y, activity) - activityTs(x, activity));
153
+ return arr.map((agent) => {
154
+ const rec = activityRec(agent, activity) || {};
155
+ return shapeAgent({
156
+ ...agent,
157
+ updatedAt: rec.updatedAt || agent.updatedAt || agent.createdAt || 0,
158
+ hasUnread: !!rec.hasUnread,
159
+ unreadCount: rec.unreadCount || 0,
160
+ });
161
+ });
162
+ }
163
+
38
164
  export function callerKeyFromAuth(authorization) {
39
165
  const a = String(authorization || '').trim();
40
166
  if (!a) return '';
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Grok Bot paste/upload store.
3
+ *
4
+ * Electron stages bytes on disk, then commitStagedAttachments POSTs
5
+ * /api/uploadAttachment {filename, bytesBase64} and requires `.path` on the
6
+ * reply. A stub `{ok:true}` made commit return null → send/attachment-commit-failed
7
+ * → i18n wx1EG9 ("Couldn't send your message. Check your connection").
8
+ */
9
+ import fs from 'node:fs';
10
+ import os from 'node:os';
11
+ import path from 'node:path';
12
+ import { randomUUID } from 'node:crypto';
13
+
14
+ const IMAGE_EXT = /\.(png|jpe?g|gif|webp|bmp|heic|svg)$/i;
15
+ const IMAGE_MAGIC = [
16
+ [Buffer.from([0x89, 0x50, 0x4e, 0x47]), 'image/png'],
17
+ [Buffer.from([0xff, 0xd8, 0xff]), 'image/jpeg'],
18
+ [Buffer.from('GIF8'), 'image/gif'],
19
+ [Buffer.from([0x52, 0x49, 0x46, 0x46]), 'image/webp'],
20
+ ];
21
+ const MIME_BY_EXT = {
22
+ '.png': 'image/png',
23
+ '.jpg': 'image/jpeg',
24
+ '.jpeg': 'image/jpeg',
25
+ '.gif': 'image/gif',
26
+ '.webp': 'image/webp',
27
+ '.bmp': 'image/bmp',
28
+ '.heic': 'image/heic',
29
+ '.svg': 'image/svg+xml',
30
+ };
31
+ function maxBytes() {
32
+ return Number(process.env.OZ_GROKBOT_UPLOAD_MAX || 20 * 1024 * 1024);
33
+ }
34
+
35
+ const store = new Map();
36
+
37
+ export function uploadDir() {
38
+ return process.env.OZ_GROKBOT_UPLOAD_DIR
39
+ || path.join(os.homedir(), '.openzoo', 'grokbot-uploads');
40
+ }
41
+
42
+ export function mimeFromBytes(buf, p = '') {
43
+ const b = Buffer.isBuffer(buf) ? buf : Buffer.from(buf || []);
44
+ if (IMAGE_EXT.test(p)) {
45
+ const ext = path.extname(p).toLowerCase();
46
+ if (MIME_BY_EXT[ext]) return MIME_BY_EXT[ext];
47
+ }
48
+ for (const [magic, mime] of IMAGE_MAGIC) {
49
+ if (b.length >= magic.length && b.subarray(0, magic.length).equals(magic)) {
50
+ if (mime === 'image/webp' && b.length >= 12 && b.subarray(8, 12).toString('ascii') !== 'WEBP') {
51
+ continue;
52
+ }
53
+ return mime;
54
+ }
55
+ }
56
+ return null;
57
+ }
58
+
59
+ function safeName(filename) {
60
+ const base = path.basename(String(filename || 'image.png')) || 'image.png';
61
+ const cleaned = base.replace(/[^\w.\-]+/g, '_').replace(/^\.+/, '') || 'image.png';
62
+ return cleaned.slice(0, 120);
63
+ }
64
+
65
+ function asBuffer({ bytes, bytesBase64 } = {}) {
66
+ if (Buffer.isBuffer(bytes)) return bytes;
67
+ if (bytes instanceof Uint8Array) return Buffer.from(bytes);
68
+ if (ArrayBuffer.isView(bytes)) {
69
+ return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
70
+ }
71
+ if (bytes instanceof ArrayBuffer) return Buffer.from(bytes);
72
+ if (typeof bytesBase64 === 'string' && bytesBase64.length) {
73
+ return Buffer.from(bytesBase64, 'base64');
74
+ }
75
+ return null;
76
+ }
77
+
78
+ function remember(rec) {
79
+ store.set(rec.path, rec);
80
+ store.set(rec.abs, rec);
81
+ store.set(path.basename(rec.abs), rec);
82
+ return rec;
83
+ }
84
+
85
+ export function ingestUpload(input = {}) {
86
+ const buf = asBuffer(input);
87
+ if (!buf || !buf.length) return { ok: false, reason: 'failed' };
88
+ if (buf.length > maxBytes()) return { ok: false, reason: 'too-large' };
89
+ const filename = safeName(input.filename);
90
+ const id = randomUUID();
91
+ const rel = `${id}-${filename}`;
92
+ const dir = uploadDir();
93
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
94
+ const abs = path.join(dir, rel);
95
+ fs.writeFileSync(abs, buf);
96
+ const storedPath = `/openzoo-uploads/${rel}`;
97
+ const mime = mimeFromBytes(buf, filename) || 'application/octet-stream';
98
+ const rec = remember({
99
+ path: storedPath,
100
+ abs,
101
+ filename,
102
+ mime,
103
+ buf,
104
+ bytes: buf.length,
105
+ });
106
+ return { ok: true, path: rec.path, abs: rec.abs, mime: rec.mime, filename: rec.filename, bytes: rec.bytes };
107
+ }
108
+
109
+ export function lookupUpload(p) {
110
+ const s = String(p || '');
111
+ if (!s) return null;
112
+ if (store.has(s)) return store.get(s);
113
+ const base = s.split(/[/\\]/).pop();
114
+ if (base && store.has(base)) return store.get(base);
115
+ try {
116
+ if ((s.startsWith('/') || /^[A-Za-z]:[\\/]/.test(s)) && fs.existsSync(s) && fs.statSync(s).isFile()) {
117
+ const buf = fs.readFileSync(s);
118
+ return {
119
+ path: s,
120
+ abs: s,
121
+ filename: path.basename(s),
122
+ mime: mimeFromBytes(buf, s) || 'application/octet-stream',
123
+ buf,
124
+ bytes: buf.length,
125
+ };
126
+ }
127
+ } catch { /* */ }
128
+ return null;
129
+ }
130
+
131
+ export function readUploadChunk({ path: p, offset = 0, length = 0 } = {}) {
132
+ const rec = lookupUpload(p);
133
+ if (!rec) return null;
134
+ const totalSize = rec.buf.length;
135
+ const off = Math.max(0, Number(offset) || 0);
136
+ const want = Number(length);
137
+ const end = !Number.isFinite(want) || want <= 0 ? off : Math.min(totalSize, off + want);
138
+ const slice = rec.buf.subarray(Math.min(off, totalSize), end);
139
+ return {
140
+ bytesBase64: slice.toString('base64'),
141
+ totalSize,
142
+ mime: rec.mime || null,
143
+ };
144
+ }
145
+
146
+ export function readUploadText(p) {
147
+ const rec = lookupUpload(p);
148
+ if (!rec) return null;
149
+ return { text: rec.buf.toString('utf8') };
150
+ }
151
+
152
+ export function readUploadImage(p) {
153
+ const rec = lookupUpload(p);
154
+ if (!rec) return null;
155
+ const mime = rec.mime && rec.mime.startsWith('image/') ? rec.mime : mimeFromBytes(rec.buf, rec.filename);
156
+ if (!mime || !mime.startsWith('image/')) return null;
157
+ return {
158
+ dataUrl: `data:${mime};base64,${rec.buf.toString('base64')}`,
159
+ mime,
160
+ width: null,
161
+ height: null,
162
+ };
163
+ }
164
+
165
+ /** Test helper — does not delete files on disk. */
166
+ export function resetUploadStore() {
167
+ store.clear();
168
+ }