ocean-crew 0.0.0-stage → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +156 -2
  3. package/THIRD_PARTY_NOTICES.md +159 -0
  4. package/client.js +31389 -0
  5. package/cordis.patch.yml +6 -0
  6. package/dist/host/chat/btw.js +436 -0
  7. package/dist/host/chat/channel.js +1241 -0
  8. package/dist/host/chat/chat-index.js +344 -0
  9. package/dist/host/chat/delivery.js +231 -0
  10. package/dist/host/chat/feed.js +165 -0
  11. package/dist/host/chat/handoffs.js +625 -0
  12. package/dist/host/chat/parts.js +2033 -0
  13. package/dist/host/chat/requests.js +155 -0
  14. package/dist/host/chat/resume.js +364 -0
  15. package/dist/host/chat/side.js +457 -0
  16. package/dist/host/chat/steer.js +346 -0
  17. package/dist/host/chat/turns.js +1457 -0
  18. package/dist/host/connectors/catalog.js +331 -0
  19. package/dist/host/connectors/gateway.js +339 -0
  20. package/dist/host/connectors/index.js +1235 -0
  21. package/dist/host/connectors/mail/index.js +912 -0
  22. package/dist/host/connectors/mail/presets.js +53 -0
  23. package/dist/host/connectors/mail/transport.js +519 -0
  24. package/dist/host/connectors/oauth.js +337 -0
  25. package/dist/host/core/constants.js +123 -0
  26. package/dist/host/core/diagnostics.js +310 -0
  27. package/dist/host/core/dsh-update.js +212 -0
  28. package/dist/host/core/events.js +16 -0
  29. package/dist/host/core/files.js +37 -0
  30. package/dist/host/core/folders.js +283 -0
  31. package/dist/host/core/home-lock.js +137 -0
  32. package/dist/host/core/links.js +52 -0
  33. package/dist/host/core/runtime.js +5 -0
  34. package/dist/host/core/store.js +662 -0
  35. package/dist/host/core/text.js +59 -0
  36. package/dist/host/core/thinking.js +60 -0
  37. package/dist/host/core/time-context.js +69 -0
  38. package/dist/host/core/update.js +466 -0
  39. package/dist/host/groups/calls.js +684 -0
  40. package/dist/host/groups/index.js +79 -0
  41. package/dist/host/groups/log.js +334 -0
  42. package/dist/host/groups/prompt.js +124 -0
  43. package/dist/host/groups/relay.js +489 -0
  44. package/dist/host/groups/round.js +992 -0
  45. package/dist/host/groups/tasks.js +217 -0
  46. package/dist/host/groups/tools.js +478 -0
  47. package/dist/host/groups/types.js +8 -0
  48. package/dist/host/im/format.js +228 -0
  49. package/dist/host/im/index.js +2461 -0
  50. package/dist/host/im/platforms/feishu.js +1134 -0
  51. package/dist/host/im/platforms/index.js +6 -0
  52. package/dist/host/im/platforms/media-type.js +70 -0
  53. package/dist/host/im/platforms/weixin.js +1117 -0
  54. package/dist/host/im/reaction.js +51 -0
  55. package/dist/host/im/supervisor.js +56 -0
  56. package/dist/host/team/agents.js +161 -0
  57. package/dist/host/team/models.js +427 -0
  58. package/dist/host/team/permissions.js +263 -0
  59. package/dist/host/team/privacy.js +108 -0
  60. package/dist/host/team/prompt.js +86 -0
  61. package/dist/host/team/providers.js +461 -0
  62. package/dist/host/team/roster.js +1011 -0
  63. package/dist/host/team/tools.js +1638 -0
  64. package/dist/host/work/approvals.js +290 -0
  65. package/dist/host/work/avatar-library.js +155 -0
  66. package/dist/host/work/avatar.js +132 -0
  67. package/dist/host/work/capabilities.js +862 -0
  68. package/dist/host/work/companion.js +306 -0
  69. package/dist/host/work/goal-skills.js +91 -0
  70. package/dist/host/work/goals.js +1337 -0
  71. package/dist/host/work/images.js +283 -0
  72. package/dist/host/work/learning.js +1021 -0
  73. package/dist/host/work/memory-pending.js +108 -0
  74. package/dist/host/work/memory.js +3237 -0
  75. package/dist/host/work/notify.js +146 -0
  76. package/dist/host/work/reset.js +43 -0
  77. package/dist/host/work/schedules.js +309 -0
  78. package/dist/host/work/secrets.js +702 -0
  79. package/dist/host/work/steward.js +166 -0
  80. package/dist/host/work/tts.js +192 -0
  81. package/dist/host/work/usage.js +84 -0
  82. package/dist/index.js +137 -0
  83. package/dist/shared/api.js +32 -0
  84. package/dist/shared/companion.js +110 -0
  85. package/dist/shared/config.js +3 -0
  86. package/dist/shared/coordination.js +6 -0
  87. package/dist/shared/group-replies.js +95 -0
  88. package/dist/shared/im-platforms.js +35 -0
  89. package/dist/shared/im.js +3 -0
  90. package/dist/shared/mentions.js +85 -0
  91. package/dist/shared/state.js +25 -0
  92. package/package.json +81 -5
@@ -0,0 +1,290 @@
1
+ // Approvals. A member's group Session is hidden, so DSH's approval card never shows there
2
+ // and the turn would wait forever: Ocean Crew answers those requests itself, from the
3
+ // group chat. A Bot's own chat keeps DSH's card, but Ocean Crew can answer those too
4
+ // (IM chats, the phone app): the request is raced — whichever side answers first wins,
5
+ // and the other card withdraws.
6
+ //
7
+ // arguments hash, target, audience) and the permission revision of the Session it came
8
+ // from. Answering reloads the world first: an expired card, a Session that changed
9
+ // hands, changed permissions, or a mismatched ref is settled as cancelled and refused as
10
+ // stale, so a click can never approve an action other than the one it was shown for.
11
+ import { createHash, randomUUID } from "node:crypto";
12
+ const OUTCOMES = { allow: "allowed-once", reject: "rejected" };
13
+ // A card that waits this long is settled unanswerable; the tool call is asked again.
14
+ const DEFAULT_TTL_MS = 15 * 60_000;
15
+ // A stable hash of the action's arguments: two approvals of the same tool with different
16
+ // arguments must never share a ref, and the same arguments must always hash the same.
17
+ export function argsHashOf(value) {
18
+ const seen = new Set();
19
+ const stable = (input) => {
20
+ if (input === null || typeof input !== "object")
21
+ return input;
22
+ if (seen.has(input))
23
+ return "[circular]";
24
+ seen.add(input);
25
+ if (Array.isArray(input))
26
+ return input.map(stable);
27
+ const out = {};
28
+ for (const key of Object.keys(input).sort())
29
+ out[key] = stable(input[key]);
30
+ return out;
31
+ };
32
+ let text;
33
+ try {
34
+ text = JSON.stringify(stable(value) ?? "");
35
+ }
36
+ catch {
37
+ text = String(value);
38
+ }
39
+ return createHash("sha256").update(text).digest("hex").slice(0, 16);
40
+ }
41
+ const refOf = (request, owner, permissionRevision, now, ttlMs) => {
42
+ const args = request.args ?? request.arguments ?? request.input;
43
+ const targetInput = request.target;
44
+ const target = typeof targetInput === "string"
45
+ ? targetInput
46
+ : targetInput !== null && typeof targetInput === "object"
47
+ ? JSON.stringify(targetInput)
48
+ : undefined;
49
+ return {
50
+ ...(typeof request.workId === "string" ? { workId: request.workId } : {}),
51
+ ...(typeof request.runId === "string" ? { runId: request.runId } : {}),
52
+ toolCallId: typeof request.callId === "string" ? request.callId : "",
53
+ argsHash: argsHashOf(args),
54
+ ...(target !== undefined ? { target } : {}),
55
+ audience: owner === undefined ? "bot" : "group",
56
+ ...(permissionRevision !== undefined ? { permissionRevision } : {}),
57
+ expiry: now + ttlMs,
58
+ };
59
+ };
60
+ export function install(rt) {
61
+ const runtime = rt;
62
+ const { ctx } = rt;
63
+ const pending = new Map();
64
+ const approvalListeners = new Set();
65
+ const onApproval = (listener) => {
66
+ approvalListeners.add(listener);
67
+ return () => approvalListeners.delete(listener);
68
+ };
69
+ const emit = (kind, data) => {
70
+ for (const listener of approvalListeners) {
71
+ try {
72
+ listener(kind, data);
73
+ }
74
+ catch (error) {
75
+ rt.warn("ocean-crew: approval listener failed: %s", error);
76
+ }
77
+ }
78
+ };
79
+ const ttlOf = () => {
80
+ const configured = rt.config?.approvalTtlMs;
81
+ return typeof configured === "number" && Number.isFinite(configured) && configured > 0
82
+ ? configured
83
+ : DEFAULT_TTL_MS;
84
+ };
85
+ // Settles an entry: it leaves the pending set, the approval note goes, and listeners
86
+ // hear how it ended. Idempotent; false when it was already settled elsewhere.
87
+ const settle = (entry, outcome) => {
88
+ if (!pending.has(entry.id))
89
+ return false;
90
+ pending.delete(entry.id);
91
+ runtime.noteApproval?.(entry.sessionId, undefined);
92
+ emit("settled", { id: entry.id, outcome });
93
+ return true;
94
+ };
95
+ // Resolves the DSH request with one outcome: the card withdraws for that answer.
96
+ const respond = (entry, outcome) => {
97
+ if (!settle(entry, outcome))
98
+ return;
99
+ entry.resolve?.(outcome);
100
+ entry.cleanup?.();
101
+ };
102
+ // The same, settled under a different name for the window (expired, refused).
103
+ const withdraw = (entry, settledAs) => {
104
+ if (!pending.has(entry.id))
105
+ return;
106
+ pending.delete(entry.id);
107
+ runtime.noteApproval?.(entry.sessionId, undefined);
108
+ emit("settled", { id: entry.id, outcome: settledAs });
109
+ entry.resolve?.("cancelled");
110
+ entry.cleanup?.();
111
+ };
112
+ // Why an open approval can no longer be trusted, or undefined while it still holds.
113
+ // Checked both by the window's poll (a stale card is withdrawn) and by the answer.
114
+ const staleReason = (entry, now = Date.now()) => {
115
+ if (entry.ref.expiry <= now)
116
+ return "This approval expired; ask for it again.";
117
+ const owner = runtime.groupOwners.get(entry.sessionId);
118
+ const chatOwner = runtime.chatOwners.get(entry.sessionId);
119
+ if (entry.roomId !== undefined && (owner === undefined || owner.roomId !== entry.roomId))
120
+ return "This approval is no longer open: its group chat changed.";
121
+ if (entry.roomId === undefined && chatOwner !== entry.botId)
122
+ return "This approval is no longer open: its conversation changed.";
123
+ if (entry.ref.permissionRevision !== undefined && typeof runtime.permissionRevision === "function") {
124
+ const current = runtime.permissionRevision(entry.sessionId);
125
+ if (current !== entry.ref.permissionRevision)
126
+ return "The Bot's permissions changed after it asked; ask again.";
127
+ }
128
+ return undefined;
129
+ };
130
+ const sweep = () => {
131
+ const now = Date.now();
132
+ for (const entry of [...pending.values()]) {
133
+ if (staleReason(entry, now) !== undefined)
134
+ withdraw(entry, "expired");
135
+ }
136
+ };
137
+ // Ahead of DSH's own answerer, which would hold a hidden Session's request unseen.
138
+ ctx.on("approval/request", (request, next) => {
139
+ const sessionId = request.agent?.session?.id ?? request.agent?.id;
140
+ const owner = sessionId === undefined ? undefined : runtime.groupOwners.get(sessionId);
141
+ const botId = owner?.botId ?? (sessionId === undefined ? undefined : runtime.chatOwners.get(sessionId));
142
+ if (botId === undefined || sessionId === undefined)
143
+ return next();
144
+ const now = Date.now();
145
+ const id = randomUUID();
146
+ const entry = {
147
+ id,
148
+ sessionId,
149
+ botId,
150
+ ...(owner ? { roomId: owner.roomId } : {}),
151
+ tool: String(request.toolName ?? ""),
152
+ reason: String(request.reason ?? "").slice(0, 500),
153
+ detail: runtime.callDetail?.(sessionId, String(request.callId ?? "")) ?? {},
154
+ at: now,
155
+ ref: refOf(request, owner, runtime.permissionRevision?.(sessionId), now, ttlOf()),
156
+ };
157
+ pending.set(id, entry);
158
+ runtime.noteApproval?.(sessionId, { tool: entry.tool, reason: entry.reason.slice(0, 200) });
159
+ emit("open", { ...entry, answerable: true });
160
+ if (owner === undefined) {
161
+ // An own-chat approval races DSH's card. The card sees `linked`, our own
162
+ // AbortSignal: answering here resolves the request, then withdraws the card;
163
+ // an answer there settles `next()` first and is adopted. An abort of the
164
+ // original signal cancels either way.
165
+ const original = request.signal;
166
+ const linked = new AbortController();
167
+ request.signal = linked.signal;
168
+ return new Promise((resolve, reject) => {
169
+ const complete = (outcome) => {
170
+ respond(entry, outcome);
171
+ };
172
+ entry.answer = complete;
173
+ entry.resolve = (outcome) => resolve(outcome);
174
+ entry.cleanup = () => {
175
+ linked.abort();
176
+ if (request.signal === linked.signal)
177
+ request.signal = original;
178
+ };
179
+ if (original?.aborted)
180
+ entry.answer("cancelled");
181
+ else
182
+ original?.addEventListener("abort", () => entry.answer?.("cancelled"), { once: true });
183
+ Promise.resolve(next()).then((outcome) => {
184
+ if (!pending.has(id))
185
+ return;
186
+ complete(String(outcome));
187
+ },
188
+ // DSH's answerer threw: close the entry and give the caller the error
189
+ // the way the pre-race code did, instead of hanging forever.
190
+ (error) => {
191
+ if (!settle(entry, "cancelled"))
192
+ return;
193
+ reject(error);
194
+ entry.cleanup?.();
195
+ });
196
+ });
197
+ }
198
+ runtime.yieldRoomTurn?.(sessionId);
199
+ return new Promise((resolve) => {
200
+ entry.answer = (outcome) => {
201
+ respond(entry, outcome);
202
+ };
203
+ entry.resolve = (outcome) => resolve(outcome);
204
+ if (request.signal?.aborted)
205
+ entry.answer("cancelled");
206
+ request.signal?.addEventListener("abort", () => entry.answer?.("cancelled"), { once: true });
207
+ });
208
+ }, { prepend: true });
209
+ const approvalsView = () => {
210
+ sweep();
211
+ return [...pending.values()].map((entry) => {
212
+ const { answer, resolve: _resolve, cleanup: _cleanup, ...card } = entry;
213
+ return { ...card, answerable: answer !== undefined };
214
+ });
215
+ };
216
+ const waitsForApproval = (sessionId) => [...pending.values()].some((entry) => entry.sessionId === sessionId);
217
+ // The user answered a card. The world is reloaded before the answer is applied: the
218
+ // action must still be the one the card was shown for, with the same permissions and
219
+ // in the same conversation. A card that no longer holds is withdrawn and refused.
220
+ function answerApproval(id, choice, expected) {
221
+ const entry = pending.get(String(id ?? ""));
222
+ if (entry?.answer === undefined)
223
+ throw Object.assign(new Error("This approval is no longer open"), { code: "bot/stale" });
224
+ const outcome = OUTCOMES[choice];
225
+ if (outcome === undefined)
226
+ throw Object.assign(new Error("Choose allow or reject"), { code: "bot/invalid" });
227
+ const stale = staleReason(entry);
228
+ if (stale !== undefined) {
229
+ withdraw(entry, "expired");
230
+ throw Object.assign(new Error(stale), { code: "bot/stale" });
231
+ }
232
+ if (expected !== undefined) {
233
+ if (expected.toolCallId !== undefined && expected.toolCallId !== entry.ref.toolCallId) {
234
+ withdraw(entry, "mismatched");
235
+ throw Object.assign(new Error("This card no longer matches the action it was asked for; ask again."), {
236
+ code: "bot/stale",
237
+ });
238
+ }
239
+ if (expected.argsHash !== undefined && expected.argsHash !== entry.ref.argsHash) {
240
+ withdraw(entry, "mismatched");
241
+ throw Object.assign(new Error("The action changed since this card was shown; ask again."), {
242
+ code: "bot/stale",
243
+ });
244
+ }
245
+ if (expected.target !== undefined && expected.target !== entry.ref.target) {
246
+ withdraw(entry, "mismatched");
247
+ throw Object.assign(new Error("The target changed since this card was shown; ask again."), {
248
+ code: "bot/stale",
249
+ });
250
+ }
251
+ if (expected.permissionRevision !== undefined &&
252
+ expected.permissionRevision !== entry.ref.permissionRevision) {
253
+ withdraw(entry, "expired");
254
+ throw Object.assign(new Error("The Bot's permissions changed after it asked; ask again."), {
255
+ code: "bot/stale",
256
+ });
257
+ }
258
+ }
259
+ entry.answer(outcome);
260
+ }
261
+ // A group Session that leaves its group has no card left to answer from.
262
+ const cancelApprovals = (sessionId) => {
263
+ for (const entry of pending.values()) {
264
+ if (entry.sessionId === sessionId)
265
+ entry.answer?.("cancelled");
266
+ }
267
+ };
268
+ // A permission change after a card was shown invalidates it: the grant cannot outlive
269
+ // the authority it was asked under. The turn gets `cancelled`, so tightening takes
270
+ // effect before the action runs.
271
+ const invalidateApprovals = (sessionIds) => {
272
+ const wanted = new Set(sessionIds);
273
+ for (const entry of [...pending.values()]) {
274
+ if (wanted.has(entry.sessionId))
275
+ withdraw(entry, "expired");
276
+ }
277
+ };
278
+ // What a turn's work view shows for a Bot waiting on one of its approvals.
279
+ const approvalFor = (botId) => approvalsView().find((entry) => entry.botId === botId);
280
+ Object.assign(rt, {
281
+ approvalsView,
282
+ waitsForApproval,
283
+ answerApproval,
284
+ cancelApprovals,
285
+ invalidateApprovals,
286
+ onApproval,
287
+ approvalFor,
288
+ approvalRefOf: (entry) => entry.ref,
289
+ });
290
+ }
@@ -0,0 +1,155 @@
1
+ // The avatar library: photos the user imported once, kept in the Bot data folder so the
2
+ // same picture can be picked for several Bots and outlives the Bot that used it. Every
3
+ // image is named by the hash of its bytes, so the same picture is stored once, and the
4
+ // index and the images are written whole, so a half-written file is never read.
5
+ import { createHash, randomUUID } from "node:crypto";
6
+ import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
7
+ import { join } from "node:path";
8
+ import { replaceFile } from "../core/files.js";
9
+ import { avatarBytes } from "./avatar.js";
10
+ const EXTENSIONS = {
11
+ "image/png": "png",
12
+ "image/jpeg": "jpg",
13
+ "image/webp": "webp",
14
+ "image/gif": "gif",
15
+ };
16
+ const HASH = /^[0-9a-f]{64}$/;
17
+ const MAX_PHOTOS = 500;
18
+ // How many photos one answer carries. A window asks for the rest when it wants them,
19
+ // so a big library does not become one huge reply.
20
+ const MAX_LISTED = 48;
21
+ const NAME_MAX = 120;
22
+ const INDEX_VERSION = 1;
23
+ const messageOf = (error) => typeof error === "object" && error !== null && "message" in error && typeof error.message === "string"
24
+ ? error.message
25
+ : String(error);
26
+ export function install(rt) {
27
+ const root = typeof rt.home === "string" ? join(rt.home, "avatars") : undefined;
28
+ const files = root === undefined ? undefined : join(root, "files");
29
+ const indexPath = root === undefined ? undefined : join(root, "index.json");
30
+ const fileOf = (entry) => join(files, `${entry.id}.${EXTENSIONS[entry.mime]}`);
31
+ // One write at a time: two imports that overlap would each read the index, and the
32
+ // second write would drop what the first one added.
33
+ let queue = Promise.resolve();
34
+ const serial = (task) => {
35
+ const run = queue.then(task, task);
36
+ queue = run.then(() => { }, () => { });
37
+ return run;
38
+ };
39
+ // The index on disk, with the entries that can be trusted: an id that is a hash, a
40
+ // known type, and a name that is text.
41
+ const readIndex = async () => {
42
+ if (indexPath === undefined)
43
+ return [];
44
+ let parsed;
45
+ try {
46
+ parsed = JSON.parse(await readFile(indexPath, "utf8"));
47
+ }
48
+ catch {
49
+ return [];
50
+ }
51
+ const items = Array.isArray(parsed?.items) ? parsed.items : [];
52
+ return items
53
+ .filter((entry) => typeof entry?.id === "string" &&
54
+ HASH.test(entry.id) &&
55
+ EXTENSIONS[entry.mime ?? ""] !== undefined)
56
+ .map((entry) => ({
57
+ id: entry.id,
58
+ name: String(entry.name ?? "").slice(0, NAME_MAX),
59
+ mime: entry.mime,
60
+ bytes: Number(entry.bytes) || 0,
61
+ createdAt: Number(entry.createdAt) || 0,
62
+ }));
63
+ };
64
+ const writeIndex = async (items) => {
65
+ await mkdir(root, { recursive: true });
66
+ const temp = `${indexPath}.${process.pid}.${randomUUID()}.tmp`;
67
+ await writeFile(temp, JSON.stringify({ version: INDEX_VERSION, items }, null, 2), "utf8");
68
+ await replaceFile(temp, indexPath);
69
+ };
70
+ const writeImage = async (entry, bytes) => {
71
+ await mkdir(files, { recursive: true });
72
+ const path = fileOf(entry);
73
+ const temp = `${path}.${process.pid}.${randomUUID()}.tmp`;
74
+ await writeFile(temp, bytes);
75
+ await replaceFile(temp, path);
76
+ };
77
+ const there = async (path) => stat(path).then(() => true, () => false);
78
+ // One photo at a time, so a photo that cannot be read does not lose the others. The
79
+ // picture is checked as an image, and the same bytes are kept once.
80
+ const importPhotos = (items) => serial(async () => {
81
+ if (root === undefined)
82
+ throw new Error("This host keeps no Bot data folder, so it has no avatar library.");
83
+ const index = await readIndex();
84
+ const result = { imported: [], duplicates: [], failed: [] };
85
+ for (const item of items) {
86
+ const name = String(item?.name ?? "")
87
+ .trim()
88
+ .slice(0, NAME_MAX);
89
+ try {
90
+ const { bytes, mime } = avatarBytes(item?.image);
91
+ const id = createHash("sha256").update(bytes).digest("hex");
92
+ const known = index.find((entry) => entry.id === id);
93
+ if (known) {
94
+ // The same picture again: the one that is there stays, and its file is put
95
+ // back when it went missing.
96
+ if (!(await there(fileOf(known))))
97
+ await writeImage(known, bytes);
98
+ result.duplicates.push({ id: known.id, name: known.name });
99
+ continue;
100
+ }
101
+ if (index.length >= MAX_PHOTOS)
102
+ throw new Error(`The avatar library holds at most ${MAX_PHOTOS} photos.`);
103
+ const entry = {
104
+ id,
105
+ name: name || "photo",
106
+ mime: mime,
107
+ bytes: bytes.length,
108
+ createdAt: Date.now(),
109
+ };
110
+ await writeImage(entry, bytes);
111
+ await writeIndex([...index, entry]);
112
+ index.push(entry);
113
+ result.imported.push(entry);
114
+ }
115
+ catch (error) {
116
+ result.failed.push({ name, message: messageOf(error) });
117
+ }
118
+ }
119
+ return result;
120
+ });
121
+ // The photos the user can pick, with the image itself, so the window needs one
122
+ // request to draw the library. The newest come first, and a library with many photos
123
+ // is asked for page by page.
124
+ const libraryView = async ({ offset = 0, limit = MAX_LISTED, } = {}) => {
125
+ const index = await readIndex();
126
+ const ordered = [...index].reverse().sort((a, b) => b.createdAt - a.createdAt);
127
+ const start = Math.max(0, Math.floor(Number(offset)) || 0);
128
+ const count = Math.min(MAX_LISTED, Math.max(1, Math.floor(Number(limit)) || MAX_LISTED));
129
+ const items = [];
130
+ const page = ordered.slice(start, start + count);
131
+ for (const entry of page) {
132
+ const bytes = await readFile(fileOf(entry)).catch(() => undefined);
133
+ if (bytes === undefined)
134
+ continue;
135
+ items.push({ ...entry, image: `data:${entry.mime};base64,${bytes.toString("base64")}` });
136
+ }
137
+ // The next page starts where this one stopped, even when a photo went missing.
138
+ return { items, total: ordered.length, next: start + page.length };
139
+ };
140
+ // A photo leaves the library; the Bots that use it keep their own copy.
141
+ const deletePhoto = (id) => serial(async () => {
142
+ const index = await readIndex();
143
+ const entry = index.find((item) => item.id === id);
144
+ if (entry === undefined)
145
+ return false;
146
+ await writeIndex(index.filter((item) => item.id !== id));
147
+ await rm(fileOf(entry), { force: true });
148
+ return true;
149
+ });
150
+ Object.assign(rt, {
151
+ avatarLibraryView: libraryView,
152
+ avatarLibraryImport: importPhotos,
153
+ avatarLibraryDelete: deletePhoto,
154
+ });
155
+ }
@@ -0,0 +1,132 @@
1
+ // Avatar input for create_bot and update_bot: a local image path or a data URL, read
2
+ // into the data URL a Bot keeps in avatar.image. There is no image library in the host,
3
+ // so images are not resized; a small square image is asked for, and every kind of input
4
+ // is capped at what the stored format holds.
5
+ import { readFile, realpath, stat } from "node:fs/promises";
6
+ import { homedir } from "node:os";
7
+ import { join, resolve } from "node:path";
8
+ import { MAX_AVATAR_IMAGE } from "../core/constants.js";
9
+ // Base64 is four characters for every three bytes, and the data URL adds a mime
10
+ // prefix, so a raw image must stay under this to fit MAX_AVATAR_IMAGE once stored.
11
+ const LONGEST_PREFIX = "data:image/jpeg;base64,";
12
+ const MAX_BYTES = Math.floor((MAX_AVATAR_IMAGE - LONGEST_PREFIX.length) / 4) * 3;
13
+ const HINT = "Use a square image; 256x256 is recommended.";
14
+ const MIMES = new Set(["image/png", "image/jpeg", "image/webp", "image/gif"]);
15
+ const ascii = (bytes, start, end) => bytes.subarray(start, end).toString("latin1");
16
+ // The real format, from the bytes themselves: an extension, a Content-Type header, or
17
+ // the mime in a data URL can all be wrong.
18
+ function mimeOf(bytes) {
19
+ if (bytes.length >= 8 && ascii(bytes, 0, 8) === "\x89PNG\r\n\x1a\n")
20
+ return "image/png";
21
+ if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff)
22
+ return "image/jpeg";
23
+ if (bytes.length >= 6 && /^GIF8[79]a$/.test(ascii(bytes, 0, 6)))
24
+ return "image/gif";
25
+ if (bytes.length >= 12 && ascii(bytes, 0, 4) === "RIFF" && ascii(bytes, 8, 12) === "WEBP")
26
+ return "image/webp";
27
+ return undefined;
28
+ }
29
+ const tooLarge = (size) => `The avatar image is too large: ${size} bytes, and the limit is ${MAX_BYTES} bytes (about 120 KB). ${HINT}`;
30
+ const NOT_IMAGE = `The avatar must be a PNG, JPEG, WebP, or GIF image. ${HINT}`;
31
+ function toDataUrl(bytes) {
32
+ if (bytes.length > MAX_BYTES)
33
+ throw new Error(tooLarge(bytes.length));
34
+ const mime = mimeOf(bytes);
35
+ if (mime === undefined)
36
+ throw new Error(NOT_IMAGE);
37
+ const url = `data:${mime};base64,${bytes.toString("base64")}`;
38
+ if (url.length > MAX_AVATAR_IMAGE)
39
+ throw new Error(tooLarge(bytes.length));
40
+ return url;
41
+ }
42
+ const expandHome = (text) => {
43
+ if (text === "~")
44
+ return homedir();
45
+ if (/^~[\\/]/.test(text))
46
+ return join(homedir(), text.slice(2));
47
+ return text;
48
+ };
49
+ // The `code` an OS error carries; a thrown value is unknown to TypeScript.
50
+ const errorCode = (error) => {
51
+ if (typeof error !== "object" || error === null)
52
+ return undefined;
53
+ const code = error.code;
54
+ return typeof code === "string" ? code : undefined;
55
+ };
56
+ const errorMessage = (error) => (error instanceof Error ? error.message : String(error));
57
+ async function readLocal(path, baseDir, isData) {
58
+ const full = resolve(baseDir ?? process.cwd(), expandHome(path));
59
+ // Checked and read at the real path, where links lead, so a link cannot reach into
60
+ // DSH's data, and one changed after the check is not followed.
61
+ let real;
62
+ let info;
63
+ try {
64
+ real = await realpath(full);
65
+ info = await stat(real);
66
+ }
67
+ catch (error) {
68
+ if (errorCode(error) === "ENOENT")
69
+ throw new Error(`There is no image file at ${full}. Pass the path of an existing image file.`);
70
+ throw new Error(`Cannot read the image at ${full}: ${errorCode(error) === "EACCES" ? "permission denied" : errorMessage(error)}`);
71
+ }
72
+ if (!info.isFile())
73
+ throw new Error(`${full} is not an image file. Pass the path of an image file.`);
74
+ if (await isData?.(real))
75
+ throw new Error(`${full} is in DeepSeek Harness data, so it cannot be a Bot's photo. Pass an image from another folder.`);
76
+ if (info.size > MAX_BYTES)
77
+ throw new Error(tooLarge(info.size));
78
+ let bytes;
79
+ try {
80
+ bytes = await readFile(real);
81
+ }
82
+ catch (error) {
83
+ throw new Error(`Cannot read the image at ${full}: ${errorCode(error) === "EACCES" ? "permission denied" : errorMessage(error)}`);
84
+ }
85
+ if (bytes.length > MAX_BYTES)
86
+ throw new Error(tooLarge(bytes.length));
87
+ return toDataUrl(bytes);
88
+ }
89
+ function readDataUrl(value) {
90
+ const comma = value.indexOf(",");
91
+ if (comma === -1)
92
+ throw new Error(`The avatar data URL has no comma before its image data. ${HINT}`);
93
+ const header = value.slice("data:".length, comma);
94
+ const [mime = "", ...params] = header.split(";").map((part) => part.trim().toLowerCase());
95
+ if (!MIMES.has(mime))
96
+ throw new Error(`The avatar data URL must hold a PNG, JPEG, WebP, or GIF image. ${HINT}`);
97
+ if (!params.includes("base64"))
98
+ throw new Error("The avatar data URL must be base64 encoded, as in data:image/png;base64,...");
99
+ const payload = value.slice(comma + 1).replace(/\s/g, "");
100
+ if (payload === "" || payload.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(payload))
101
+ throw new Error("The avatar data URL does not hold valid base64 image data.");
102
+ const bytes = Buffer.from(payload, "base64");
103
+ return { url: toDataUrl(bytes), bytes, mime: mimeOf(bytes) };
104
+ }
105
+ /** The image behind an avatar data URL: the bytes, the type they really are, and the
106
+ * canonical data URL. The same checks as for a Bot's own avatar apply. */
107
+ export function avatarBytes(value) {
108
+ return readDataUrl(String(value ?? ""));
109
+ }
110
+ // module directly from tools.js;
111
+ export function install(rt) {
112
+ Object.assign(rt, { readAvatar, avatarBytes });
113
+ }
114
+ /** Returns the data URL for avatar.image, or throws with a line the user can read.
115
+ * `baseDir` is where a relative local path starts, and `isData` says whether a real path
116
+ * lies in data a Bot may not read from. Links are not fetched: the host would reach
117
+ * addresses, such as local services, that dsh's own web fetch refuses. */
118
+ export async function readAvatar(input, { baseDir, isData } = {}) {
119
+ const value = typeof input === "string" ? input.trim() : "";
120
+ if (value === "")
121
+ throw new Error(`Give an image path or a data URL. ${HINT}`);
122
+ if (/^data:/i.test(value))
123
+ return readDataUrl(value).url;
124
+ if (/^https?:\/\//i.test(value))
125
+ throw new Error("An http(s) link is not downloaded here. Save the image to a file and pass its path, or pass a data URL.");
126
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(value))
127
+ throw new Error(`The avatar must be a local image path or a data URL. ${HINT}`);
128
+ // A network path (\\host\share) would make Windows connect to that host and sign in.
129
+ if (/^[\\/]{2}/.test(value))
130
+ throw new Error("A network path is not read here. Copy the image to this computer and pass its path, or pass a data URL.");
131
+ return readLocal(value, baseDir, isData);
132
+ }