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,344 @@
1
+ // Reading a Bot's own chat: Session history pages, the SQLite FTS5 index, and the
2
+ // search, latest, and around reads behind read_own_chat.
3
+ //
4
+ // Chat index. read_own_chat searches a SQLite FTS5 table of every part of a Bot's own
5
+ // chat, ranked by BM25. The trigram tokenizer matches any run of three or more
6
+ // characters, so Chinese needs no word splitting; shorter words fall back to LIKE.
7
+ // The index is derived from the Session logs: a part catches up when it is read, an
8
+ // ended part is sealed once it is complete, and deleting the file only costs a
9
+ // rebuild. Without SQLite, read_own_chat scans the logs as before.
10
+ import { mkdir, unlink } from "node:fs/promises";
11
+ import { join } from "node:path";
12
+ import { ANCHOR_LINE_MAX, HISTORY_PAGE, HISTORY_PAGES_MAX, HISTORY_TIMEOUT_MS, OWN_CHAT_BRIEF, OWN_CHAT_LINE_MAX, OWN_CHAT_READ, OWN_CHAT_SEARCH, } from "../core/constants.js";
13
+ import { excerpt } from "../core/text.js";
14
+ const INDEX_VERSION = 3;
15
+ const AROUND_LINES = 6;
16
+ const SEARCH_TERMS_MAX = 6;
17
+ const ANY_TERM_HITS = 50;
18
+ const INDEX_TIMEOUT_MS = 120_000;
19
+ // A part still gets a few events (a title, a late reply) just after it ends.
20
+ const SEAL_AFTER_MS = 60_000;
21
+ const likeOf = (term) => `%${term.replace(/[\\%_]/g, (char) => `\\${char}`)}%`;
22
+ // Each term is a quoted FTS5 phrase when trigrams can match it, else a LIKE filter.
23
+ export function termQuery(terms, sessions, limit, hidden) {
24
+ const long = terms.filter((term) => [...term].length >= 3);
25
+ const short = terms.filter((term) => [...term].length < 3);
26
+ const where = ["session IN (SELECT value FROM json_each(?))"];
27
+ const args = [JSON.stringify(sessions)];
28
+ if (hidden !== undefined) {
29
+ where.push("NOT (session = ? AND seq >= ?)");
30
+ args.push(hidden.session, hidden.from);
31
+ }
32
+ if (long.length > 0) {
33
+ where.unshift("lines MATCH ?");
34
+ args.unshift(long.map((term) => `"${term.replace(/"/g, '""')}"`).join(" "));
35
+ }
36
+ for (const term of short) {
37
+ where.push("text LIKE ? ESCAPE '\\'");
38
+ args.push(likeOf(term));
39
+ }
40
+ return {
41
+ sql: `SELECT text, session, seq, time, who FROM lines WHERE ${where.join(" AND ")} ORDER BY ${long.length > 0 ? "rank" : "time DESC"} LIMIT ${limit}`,
42
+ args,
43
+ };
44
+ }
45
+ export function install(rt) {
46
+ const { ctx, home } = rt;
47
+ const controller = () => ctx.sessionController;
48
+ const partLines = (sessionId, signal) => rt.partLines(sessionId, signal);
49
+ const indexPath = join(home, "chat-index.sqlite");
50
+ let chatIndex;
51
+ let indexOpening;
52
+ // A Session's events from newest to oldest, also for an archived part.
53
+ async function* eventsBackwards(sessionId, signal, maxPages = HISTORY_PAGES_MAX) {
54
+ const agent = (await rt.resolveAgent(sessionId));
55
+ const throughSeq = Number(agent.session?.seq) - 1;
56
+ let beforeSeq;
57
+ for (let pages = 0; throughSeq >= 0 && pages < maxPages; pages += 1) {
58
+ const page = await controller().page({
59
+ address: { kind: "session", sessionId },
60
+ throughSeq,
61
+ ...(beforeSeq === undefined ? {} : { beforeSeq }),
62
+ maxMessages: HISTORY_PAGE,
63
+ }, signal);
64
+ const events = page.records.map((record) => record.event);
65
+ for (let index = events.length - 1; index >= 0; index -= 1)
66
+ yield events[index];
67
+ if (!page.hasMore || events.length === 0)
68
+ return;
69
+ beforeSeq = events[0].seq;
70
+ }
71
+ }
72
+ function createIndex(db) {
73
+ try {
74
+ db.exec("PRAGMA journal_mode = WAL");
75
+ const version = Number(db.prepare("PRAGMA user_version").get()?.user_version);
76
+ if (version !== INDEX_VERSION) {
77
+ db.exec(`
78
+ DROP TABLE IF EXISTS lines;
79
+ DROP TABLE IF EXISTS marks;
80
+ CREATE VIRTUAL TABLE lines USING fts5(text, session UNINDEXED, seq UNINDEXED, time UNINDEXED, who UNINDEXED, tokenize = 'trigram');
81
+ CREATE TABLE marks (session TEXT PRIMARY KEY, through INTEGER NOT NULL, keep INTEGER NOT NULL, sealed INTEGER NOT NULL, turn INTEGER NOT NULL);
82
+ PRAGMA user_version = ${INDEX_VERSION};`);
83
+ }
84
+ return {
85
+ db,
86
+ mark: db.prepare("SELECT through, keep, sealed, turn FROM marks WHERE session = ?"),
87
+ setMark: db.prepare("INSERT OR REPLACE INTO marks (session, through, keep, sealed, turn) VALUES (?, ?, ?, ?, ?)"),
88
+ insert: db.prepare("INSERT INTO lines (text, session, seq, time, who) VALUES (?, ?, ?, ?, ?)"),
89
+ before: db.prepare("SELECT text, session, seq, time, who FROM lines WHERE session = ? AND seq < ? ORDER BY seq DESC LIMIT ?"),
90
+ from: db.prepare("SELECT text, session, seq, time, who FROM lines WHERE session = ? AND seq >= ? ORDER BY seq LIMIT ?"),
91
+ };
92
+ }
93
+ catch (error) {
94
+ db.close();
95
+ throw error;
96
+ }
97
+ }
98
+ function openIndex() {
99
+ if (chatIndex !== undefined)
100
+ return Promise.resolve(chatIndex);
101
+ if (indexOpening !== undefined)
102
+ return indexOpening;
103
+ const opening = (async () => {
104
+ let DatabaseSync;
105
+ try {
106
+ // Loaded at run time on purpose: `node:sqlite` is an optional native module,
107
+ // and a host without it must still load and scan the logs (see the file head).
108
+ ({ DatabaseSync } = (await import("node:sqlite")));
109
+ await mkdir(home, { recursive: true });
110
+ }
111
+ catch (error) {
112
+ rt.warn("ocean-crew: no chat index, read_own_chat scans the logs: %s", error);
113
+ return null;
114
+ }
115
+ // A damaged file is rebuilt from the logs once.
116
+ for (const fresh of [false, true]) {
117
+ try {
118
+ if (fresh)
119
+ await unlink(indexPath).catch(() => undefined);
120
+ return createIndex(new DatabaseSync(indexPath));
121
+ }
122
+ catch (error) {
123
+ if (fresh)
124
+ rt.warn("ocean-crew: no chat index, read_own_chat scans the logs: %s", error);
125
+ }
126
+ }
127
+ return null;
128
+ })();
129
+ let tracked;
130
+ tracked = opening.then((index) => {
131
+ // The plugin may have been disposed while the file opened.
132
+ if (indexOpening !== tracked) {
133
+ index?.db.close();
134
+ return null;
135
+ }
136
+ chatIndex = index ?? undefined;
137
+ return index;
138
+ });
139
+ indexOpening = tracked;
140
+ return tracked;
141
+ }
142
+ ctx.effect(() => () => {
143
+ chatIndex?.db.close();
144
+ chatIndex = undefined;
145
+ indexOpening = undefined;
146
+ }, "ocean-crew: chat index");
147
+ // Adds what a part said since its mark. Reads of one part wait for each other, so no
148
+ // line is added twice.
149
+ const catchingUp = new Map();
150
+ function catchUp(index, part) {
151
+ const running = catchingUp.get(part.sessionId);
152
+ if (running !== undefined)
153
+ return running;
154
+ const job = (async () => {
155
+ const mark = index.mark.get(part.sessionId);
156
+ if (mark?.sealed)
157
+ return;
158
+ const through = mark?.through ?? -1;
159
+ const events = [];
160
+ for await (const event of eventsBackwards(part.sessionId, AbortSignal.timeout(INDEX_TIMEOUT_MS), Infinity)) {
161
+ if (event.seq <= through)
162
+ break;
163
+ events.push(event);
164
+ }
165
+ if (events.length > 0 && events[events.length - 1].seq !== through + 1)
166
+ throw new Error(`the history of ${part.sessionId} came back with a gap`);
167
+ events.reverse();
168
+ if (index !== chatIndex)
169
+ return;
170
+ let keep = mark ? mark.keep === 1 : false;
171
+ let turn = mark?.turn ?? -1;
172
+ const seal = part.endedAt !== undefined && Date.now() - part.endedAt > SEAL_AFTER_MS;
173
+ index.db.exec("BEGIN");
174
+ try {
175
+ let last = through;
176
+ for (const event of events) {
177
+ last = event.seq;
178
+ if (event.type === "turn/start")
179
+ turn = event.seq;
180
+ const entry = rt.historyEntry(event, false);
181
+ if (entry === undefined)
182
+ continue;
183
+ if (entry.answer === true) {
184
+ if (keep)
185
+ index.insert.run(entry.text, part.sessionId, event.seq, event.time ?? 0, "you");
186
+ continue;
187
+ }
188
+ keep = entry.keepAnswers === true;
189
+ if (entry.text !== "")
190
+ index.insert.run(entry.text, part.sessionId, event.seq, event.time ?? 0, entry.who ?? "");
191
+ }
192
+ index.setMark.run(part.sessionId, last, keep ? 1 : 0, seal ? 1 : 0, turn);
193
+ index.db.exec("COMMIT");
194
+ }
195
+ catch (error) {
196
+ index.db.exec("ROLLBACK");
197
+ throw error;
198
+ }
199
+ })().finally(() => catchingUp.delete(part.sessionId));
200
+ catchingUp.set(part.sessionId, job);
201
+ return job;
202
+ }
203
+ // A message's anchor: its part (once there are several) and its seq in that part.
204
+ const anchorOf = (parts, row) => {
205
+ const part = parts.find((item) => item.sessionId === row.session);
206
+ return `${parts.length > 1 ? `part ${part?.number ?? "?"} ` : ""}#${row.seq}`;
207
+ };
208
+ const rowLine = (parts, row, terms = [], max = OWN_CHAT_LINE_MAX) => {
209
+ const lower = row.text.toLowerCase();
210
+ const wanted = terms.find((term) => lower.includes(term)) ?? "";
211
+ const text = row.who === "user" ? `User: ${row.text}` : row.who === "you" ? `You: ${row.text}` : row.text;
212
+ return `[${anchorOf(parts, row)} · ${rt.clock(row.time)}] ${excerpt(text, wanted, max)}`;
213
+ };
214
+ // Every part, caught up. Asked from the current part, the turn that asks is left out
215
+ // (`hidden`): it is in context, and its own question would match.
216
+ async function indexedParts(botId, askingId) {
217
+ const index = await openIndex();
218
+ if (index === null)
219
+ return { index, parts: [] };
220
+ const parts = rt.partsOf(botId);
221
+ // A part that cannot be read now is searched as far as it was indexed.
222
+ for (const part of parts)
223
+ await catchUp(index, part).catch((error) => rt.warn("ocean-crew: indexing %s failed: %s", part.sessionId, error));
224
+ const turn = askingId !== undefined && askingId === parts.at(-1)?.sessionId
225
+ ? index.mark.get(askingId)?.turn
226
+ : undefined;
227
+ return {
228
+ index,
229
+ parts,
230
+ hidden: turn === undefined || turn < 0 ? undefined : { session: askingId, from: turn },
231
+ };
232
+ }
233
+ // Messages with all the words, best first; with none of them having all, those with
234
+ // the most of them. Shown oldest first.
235
+ async function searchOwnChat(botId, query, { askingId, limit = OWN_CHAT_SEARCH } = {}) {
236
+ const terms = [...new Set(query.toLowerCase().split(/\s+/).filter(Boolean))].slice(0, SEARCH_TERMS_MAX);
237
+ const { index, parts, hidden } = await indexedParts(botId, askingId);
238
+ if (index === null)
239
+ return { lines: await scanOwnChat(botId, query, limit), every: true };
240
+ const sessions = parts.map((part) => part.sessionId);
241
+ const run = (wanted, max) => {
242
+ const { sql, args } = termQuery(wanted, sessions, max, hidden);
243
+ return index.db.prepare(sql).all(...args);
244
+ };
245
+ const byTime = (a, b) => a.time - b.time || sessions.indexOf(a.session) - sessions.indexOf(b.session) || a.seq - b.seq;
246
+ let rows = run(terms, limit);
247
+ let every = true;
248
+ if (rows.length === 0 && terms.length > 1) {
249
+ every = false;
250
+ const found = new Map();
251
+ for (const term of terms) {
252
+ for (const row of run([term], ANY_TERM_HITS)) {
253
+ const key = `${row.session}#${row.seq}`;
254
+ const hit = found.get(key) ?? { row, count: 0 };
255
+ hit.count += 1;
256
+ found.set(key, hit);
257
+ }
258
+ }
259
+ rows = [...found.values()]
260
+ .sort((a, b) => b.count - a.count || b.row.time - a.row.time)
261
+ .slice(0, limit)
262
+ .map((hit) => hit.row);
263
+ }
264
+ return { lines: rows.sort(byTime).map((row) => rowLine(parts, row, terms)), every };
265
+ }
266
+ // The latest messages across all parts, oldest first.
267
+ async function latestOwnChat(botId, { askingId, limit = OWN_CHAT_READ } = {}) {
268
+ const { index, parts, hidden } = await indexedParts(botId, askingId);
269
+ if (index === null)
270
+ return scanOwnChat(botId, "", limit);
271
+ const rows = [];
272
+ for (const part of [...parts].reverse()) {
273
+ const below = hidden?.session === part.sessionId ? hidden.from : Number.MAX_SAFE_INTEGER;
274
+ rows.push(...index.before.all(part.sessionId, below, limit - rows.length));
275
+ if (rows.length >= limit)
276
+ break;
277
+ }
278
+ return rows.reverse().map((row) => rowLine(parts, row));
279
+ }
280
+ // The messages around an anchor, in its part.
281
+ async function aroundOwnChat(botId, anchor) {
282
+ const { index, parts } = await indexedParts(botId);
283
+ if (index === null)
284
+ return { error: "Anchors need the chat index, which is unavailable here. Search with words instead." };
285
+ const found = /(?:part\s*)?(\d+)?\s*#\s*(\d+)/i.exec(anchor);
286
+ if (found === null)
287
+ return {
288
+ error: `"${anchor}" is not an anchor. Anchors look like "part 2 #1234", or "#1234" in the current part.`,
289
+ };
290
+ const number = found[1] === undefined ? parts.length : Number(found[1]);
291
+ const part = parts[number - 1];
292
+ if (part === undefined)
293
+ return { error: `Your own chat has no part ${number}.` };
294
+ const seq = Number(found[2]);
295
+ const rows = [
296
+ ...index.before.all(part.sessionId, seq, AROUND_LINES).reverse(),
297
+ ...index.from.all(part.sessionId, seq, AROUND_LINES + 1),
298
+ ];
299
+ return {
300
+ part,
301
+ exact: rows.some((row) => row.seq === seq),
302
+ lines: rows.map((row) => rowLine(parts, row, [], row.seq === seq ? ANCHOR_LINE_MAX : OWN_CHAT_LINE_MAX)),
303
+ };
304
+ }
305
+ // Without the index: what the user, other Bots, and the Bot itself said, across all
306
+ // parts, oldest first, by reading the logs.
307
+ async function scanOwnChat(botId, query, limit) {
308
+ const wanted = query.toLowerCase();
309
+ const parts = rt.partsOf(botId);
310
+ const signal = AbortSignal.timeout(HISTORY_TIMEOUT_MS);
311
+ const lines = [];
312
+ for (const part of [...parts].reverse()) {
313
+ for await (const line of partLines(part.sessionId, signal)) {
314
+ if (wanted !== "" && !line.text.toLowerCase().includes(wanted))
315
+ continue;
316
+ lines.push(`[${parts.length > 1 ? `part ${part.number} · ` : ""}${rt.clock(line.time)}] ${excerpt(line.text, wanted)}`);
317
+ if (lines.length >= limit)
318
+ break;
319
+ }
320
+ if (lines.length >= limit)
321
+ break;
322
+ }
323
+ return lines.reverse();
324
+ }
325
+ // The parts of a Bot's own chat, for the model: when each ran.
326
+ const partIndex = (parts) => parts
327
+ .map((part) => `part ${part.number} (${rt.clock(part.startedAt)} – ${part.endedAt === undefined ? "now" : rt.clock(part.endedAt)})`)
328
+ .join(", ");
329
+ async function ownChatBrief(member) {
330
+ let lines = [];
331
+ try {
332
+ lines = await latestOwnChat(member.id, { limit: OWN_CHAT_BRIEF });
333
+ }
334
+ catch (error) {
335
+ rt.warn("ocean-crew: brief for %s failed: %s", member.name, error);
336
+ }
337
+ return [
338
+ "Your own chat with the user so far (latest messages, oldest first; private, so share only what the group needs):",
339
+ lines.length > 0 ? lines.join("\n") : "Nothing yet.",
340
+ "This brief does not change. Call read_own_chat when you need something newer or older from your own chat.",
341
+ ].join("\n");
342
+ }
343
+ Object.assign(rt, { eventsBackwards, searchOwnChat, latestOwnChat, aroundOwnChat, partIndex, ownChatBrief });
344
+ }
@@ -0,0 +1,231 @@
1
+ // Delivery: how one Bot, the user, or Ocean Crew itself puts a message into a Bot
2
+ // Session. A relay carries a header line, a producer source that turns and resume read,
3
+ // and its exchange record. A message past MESSAGE_INLINE_MAX goes as a DSH file instead
4
+ // of whole into every Session it reaches.
5
+ import { randomUUID } from "node:crypto";
6
+ import { createUserMessage } from "@deepseek-ai/dsh-llm";
7
+ import { HISTORY_TIMEOUT_MS, LONG_HEAD, MESSAGE_INLINE_MAX, READ_LINE_MAX, SOURCE_KIND, TEXT_KEPT, TEXT_SHOW_MAX, } from "../core/constants.js";
8
+ import { headOf, textOf } from "../core/text.js";
9
+ // The roles recorded as team exchanges; the others inform a turn without waiting for it.
10
+ const EXCHANGE_ROLES = new Set(["request", "reply", "stopped"]);
11
+ const createRelayMessage = createUserMessage;
12
+ const sessionsService = (ctx) => ctx.sessions;
13
+ const attachmentsService = (ctx) => ctx.get?.("attachments");
14
+ // What one delivered message says above its text. The wording is what the model reads
15
+ // and what the client shows; it is part of the public behavior, so it is kept verbatim.
16
+ export function headerFor(role, fromName, roomName, audience, note = "new message", deferred = false, { resumed = false, retried = false, resent = false, asker } = {}) {
17
+ // What came back for work another Bot gave this one says where its answer goes.
18
+ const goesOn = asker === undefined
19
+ ? ""
20
+ : ` · for the work ${asker} asked you for; your answer goes to ${asker} once everything you handed on is back`;
21
+ if (deferred && role === "reply")
22
+ return `[Reply from ${fromName} · you read it during your last task; go on with the work it is for now${goesOn}]`;
23
+ if (deferred && role === "report")
24
+ return `[Group chat "${roomName}" · replies to your post · you read them during your last task; go on with the work they are for now${goesOn}]`;
25
+ if (deferred && role !== "room")
26
+ return `[Message from ${fromName} · you read it during your last task; answer it now]`;
27
+ if (retried && role !== "room")
28
+ return "[Your last reply wrote a tool call as text, so nothing ran]";
29
+ if (resumed && role === "resume")
30
+ return "[DeepSeek Harness restarted while you were working]";
31
+ if (resumed && role !== "room")
32
+ return `[Message from ${fromName} · DeepSeek Harness restarted while you were working on it]`;
33
+ if (resent && role === "reply")
34
+ return `[Reply from ${fromName} · DeepSeek Harness restarted before it could reach you]`;
35
+ if (resent && role !== "room")
36
+ return `[Message from ${fromName} · DeepSeek Harness restarted before it could reach you]`;
37
+ if (role === "reply")
38
+ return `[Reply from ${fromName}${goesOn}]`;
39
+ if (role === "stopped")
40
+ return `[Stopped by ${fromName}]`;
41
+ if (role === "room")
42
+ return `[Group chat "${roomName}" · ${note}]`;
43
+ if (role === "report")
44
+ return `[Group chat "${roomName}" · replies to your post${goesOn}]`;
45
+ if (role === "kickoff")
46
+ return "[Bot team setup]";
47
+ if (role === "continue")
48
+ return "[Retry after a failed turn]";
49
+ // The editor can be the user or a Bot (its own tool call, or a Main Bot's): the
50
+ // header stays neutral about who changed the profile.
51
+ if (role === "update")
52
+ return "[Your profile was updated — follow the latest version]";
53
+ if (role === "secret")
54
+ return "[Secret card]";
55
+ if (audience === "user")
56
+ return `[Message from ${fromName} · the user sees your reply here]`;
57
+ return `[Message from ${fromName}]`;
58
+ }
59
+ export function install(rt) {
60
+ const { ctx, state } = rt;
61
+ // The Agent a Session is on now, opened if needed. A Session DSH cannot open rejects
62
+ // with the code DSH gave, so the caller can tell a gone Session from a failure.
63
+ const resolveAgent = async (sessionId) => {
64
+ const controller = ctx.sessionController;
65
+ const resolved = await controller.resolveAgent(sessionId);
66
+ if ("error" in resolved)
67
+ throw Object.assign(new Error(resolved.error?.message ?? "Session unavailable"), {
68
+ code: resolved.error?.code,
69
+ });
70
+ rt.hideBotUnusedTools?.(resolved.agent);
71
+ return resolved.agent;
72
+ };
73
+ // `sessionId` defaults to the recipient's own chat; group turns name its group Session.
74
+ // `deferred`: a request the Bot already read mid-turn, handed back to answer in a turn
75
+ // of its own; its exchange was recorded when it first arrived. `resumed` and
76
+ // `retried` carry on a turn a restart cut off or that wrote its tool call as text;
77
+ // their exchange is recorded already too. `resent` is a message a shutdown cancelled;
78
+ // its first send may not have been recorded. `askedBy` is the member whose @mention
79
+ // called a group turn. `fromRoomId`: the group whose Session the sender wrote from;
80
+ // the reply goes back to that Session. `images` and `files` go along as image and
81
+ // file blocks, which DSH projects for the recipient's model and tools. `length`: the
82
+ // whole message's length when it went as a file (attachLong). `work`: the work a
83
+ // result that came back goes on with.
84
+ // one its recipient had not read, under that one's exchange.
85
+ async function deliver(args) {
86
+ const { fromId, toId, sessionId, text, role, replyTo, roomId, audience, note, exchangeId = randomUUID(), steer = false, deferred = false, resumed = false, retried = false, resent = false, merged = false, askedBy, fromRoomId, images = [], files = [], length, work, } = args;
87
+ const from = rt.botOf(fromId);
88
+ const to = rt.botOf(toId);
89
+ if (to === undefined)
90
+ throw new Error(`Unknown Bot ${toId}`);
91
+ const fromName = from?.name ?? "You";
92
+ const room = rt.roomOf(roomId);
93
+ const asker = work === undefined ? undefined : rt.botOf(state.works?.[work]?.from)?.name;
94
+ // Session events reject `undefined` members, so optional fields are omitted instead.
95
+ const source = {
96
+ kind: SOURCE_KIND,
97
+ form: "relay",
98
+ role,
99
+ exchangeId,
100
+ senderName: fromName,
101
+ ...(work ? { work } : {}),
102
+ ...(fromId ? { senderSessionId: fromId } : {}),
103
+ ...(replyTo ? { replyTo } : {}),
104
+ ...(roomId ? { roomId } : {}),
105
+ ...(audience === "user" ? { audience } : {}),
106
+ ...(deferred ? { deferred: true } : {}),
107
+ ...(resumed ? { resumed: true } : {}),
108
+ ...(retried ? { retried: true } : {}),
109
+ ...(askedBy ? { askedBy } : {}),
110
+ ...(fromRoomId ? { senderRoomId: fromRoomId } : {}),
111
+ };
112
+ const targetSessionId = sessionId ?? rt.chatOf(toId);
113
+ await rt.ensureCapabilities?.(targetSessionId);
114
+ const agent = await resolveAgent(targetSessionId);
115
+ // A steered message cuts into a running turn at its next step; a follow-up queues
116
+ // for the next turn.
117
+ const message = createRelayMessage({
118
+ content: [
119
+ {
120
+ type: "text",
121
+ text: `${headerFor(role, fromName, room?.name, audience, note, deferred, { resumed, retried, resent, asker })}\n${text}`,
122
+ },
123
+ ...images.map((attachment) => ({ type: "image", attachment })),
124
+ ...files.map((attachment) => ({ type: "file", attachment })),
125
+ ],
126
+ source,
127
+ });
128
+ if (steer)
129
+ agent.steer(message);
130
+ else
131
+ agent.followup(message);
132
+ await sessionsService(ctx).flush(agent.session);
133
+ if (EXCHANGE_ROLES.has(role) && !deferred && !resumed && !retried) {
134
+ // failed delivery as delivered. The whole text is in its file, or in the
135
+ // Session it goes to; exchangeText reads it back there.
136
+ const kept = headOf(text, TEXT_KEPT);
137
+ const whole = length !== undefined
138
+ ? { length, files }
139
+ : kept.length < text.length
140
+ ? { length: text.length, sessionId: targetSessionId }
141
+ : {};
142
+ const entry = {
143
+ id: exchangeId,
144
+ from: fromId ?? "",
145
+ to: toId,
146
+ text: kept,
147
+ ...whole,
148
+ role,
149
+ time: Date.now(),
150
+ ...(replyTo ? { replyTo } : {}),
151
+ ...(audience === "user" ? { audience } : {}),
152
+ };
153
+ const earlier = state.exchanges.findIndex((other) => other.id === exchangeId);
154
+ if (earlier === -1)
155
+ state.exchanges.push(entry);
156
+ else if (merged)
157
+ state.exchanges[earlier] = entry;
158
+ void rt.save().catch(() => { });
159
+ }
160
+ return exchangeId;
161
+ }
162
+ // A message between Bots, whole, from the Session it was delivered to: the message
163
+ // there is its header line, then the text. `whole` is false when only the start could
164
+ // be read (the Session is gone, or the message is past what the dialog shows).
165
+ async function exchangeText(entry) {
166
+ if (entry.length === undefined)
167
+ return { text: entry.text, whole: true };
168
+ const files = entry.files ?? [];
169
+ if (files.length > 0)
170
+ return (await fileText(files[0])) ?? { text: entry.text, whole: false };
171
+ try {
172
+ // The durable Session log reader is a later module's; without it the call throws
173
+ // and the start of the message, said to be only that, is the answer.
174
+ for await (const event of rt.eventsBackwards(entry.sessionId ?? rt.chatOf(entry.to), AbortSignal.timeout(HISTORY_TIMEOUT_MS))) {
175
+ if (event.type !== "user/message" || event.data?.source?.exchangeId !== entry.id)
176
+ continue;
177
+ const delivered = textOf(event.data.content);
178
+ const text = delivered.slice(delivered.indexOf("\n") + 1);
179
+ return { text: headOf(text, TEXT_SHOW_MAX), whole: text.length <= TEXT_SHOW_MAX };
180
+ }
181
+ }
182
+ catch (error) {
183
+ rt.warn("ocean-crew: message %s not read back: %s", entry.id, error);
184
+ }
185
+ return { text: entry.text, whole: false };
186
+ }
187
+ // A message a Bot sends past MESSAGE_INLINE_MAX goes as a DSH file: it is stored once,
188
+ // not in every Session it reaches, and the model reads it with its file tools when it
189
+ // needs it. Undefined when this DSH cannot store files.
190
+ async function attachLong(text, name) {
191
+ if (text.length <= MESSAGE_INLINE_MAX)
192
+ return { text, files: [] };
193
+ try {
194
+ const attachments = attachmentsService(ctx);
195
+ const file = await attachments?.saveFile?.({ data: new TextEncoder().encode(text), name });
196
+ if (file === undefined)
197
+ return undefined;
198
+ // The file stays verbatim, so a line past what the read tool shows needs
199
+ // another way.
200
+ const wide = text.split("\n").some((line) => line.length > READ_LINE_MAX);
201
+ const note = `[A long message: ${text.length} characters. Its start is below; all of it is in the attached file. Read the file before you act on it.${wide ? ` Some lines are longer than the ${READ_LINE_MAX} characters the read tool shows of a line; read those with a shell command.` : ""}]`;
202
+ return { text: `${note}\n${headOf(text, LONG_HEAD)}…`, files: [file], length: text.length };
203
+ }
204
+ catch (error) {
205
+ rt.warn("ocean-crew: long message not stored as a file: %s", error);
206
+ return undefined;
207
+ }
208
+ }
209
+ // The text of a stored file, up to what the dialogs show.
210
+ async function fileText(file) {
211
+ const attachments = attachmentsService(ctx);
212
+ if (attachments?.readFileStream === undefined)
213
+ return undefined;
214
+ const decoder = new TextDecoder();
215
+ let text = "";
216
+ try {
217
+ for await (const chunk of attachments.readFileStream(file, AbortSignal.timeout(HISTORY_TIMEOUT_MS))) {
218
+ text += decoder.decode(chunk, { stream: true });
219
+ if (text.length > TEXT_SHOW_MAX)
220
+ return { text: headOf(text, TEXT_SHOW_MAX), whole: false };
221
+ }
222
+ }
223
+ catch (error) {
224
+ rt.warn("ocean-crew: file %s not read: %s", file?.name, error);
225
+ return undefined;
226
+ }
227
+ text += decoder.decode();
228
+ return { text, whole: true };
229
+ }
230
+ Object.assign(rt, { resolveAgent, deliver, exchangeText, attachLong, fileText });
231
+ }