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.
- package/LICENSE +21 -0
- package/README.md +156 -2
- package/THIRD_PARTY_NOTICES.md +159 -0
- package/client.js +31389 -0
- package/cordis.patch.yml +6 -0
- package/dist/host/chat/btw.js +436 -0
- package/dist/host/chat/channel.js +1241 -0
- package/dist/host/chat/chat-index.js +344 -0
- package/dist/host/chat/delivery.js +231 -0
- package/dist/host/chat/feed.js +165 -0
- package/dist/host/chat/handoffs.js +625 -0
- package/dist/host/chat/parts.js +2033 -0
- package/dist/host/chat/requests.js +155 -0
- package/dist/host/chat/resume.js +364 -0
- package/dist/host/chat/side.js +457 -0
- package/dist/host/chat/steer.js +346 -0
- package/dist/host/chat/turns.js +1457 -0
- package/dist/host/connectors/catalog.js +331 -0
- package/dist/host/connectors/gateway.js +339 -0
- package/dist/host/connectors/index.js +1235 -0
- package/dist/host/connectors/mail/index.js +912 -0
- package/dist/host/connectors/mail/presets.js +53 -0
- package/dist/host/connectors/mail/transport.js +519 -0
- package/dist/host/connectors/oauth.js +337 -0
- package/dist/host/core/constants.js +123 -0
- package/dist/host/core/diagnostics.js +310 -0
- package/dist/host/core/dsh-update.js +212 -0
- package/dist/host/core/events.js +16 -0
- package/dist/host/core/files.js +37 -0
- package/dist/host/core/folders.js +283 -0
- package/dist/host/core/home-lock.js +137 -0
- package/dist/host/core/links.js +52 -0
- package/dist/host/core/runtime.js +5 -0
- package/dist/host/core/store.js +662 -0
- package/dist/host/core/text.js +59 -0
- package/dist/host/core/thinking.js +60 -0
- package/dist/host/core/time-context.js +69 -0
- package/dist/host/core/update.js +466 -0
- package/dist/host/groups/calls.js +684 -0
- package/dist/host/groups/index.js +79 -0
- package/dist/host/groups/log.js +334 -0
- package/dist/host/groups/prompt.js +124 -0
- package/dist/host/groups/relay.js +489 -0
- package/dist/host/groups/round.js +992 -0
- package/dist/host/groups/tasks.js +217 -0
- package/dist/host/groups/tools.js +478 -0
- package/dist/host/groups/types.js +8 -0
- package/dist/host/im/format.js +228 -0
- package/dist/host/im/index.js +2461 -0
- package/dist/host/im/platforms/feishu.js +1134 -0
- package/dist/host/im/platforms/index.js +6 -0
- package/dist/host/im/platforms/media-type.js +70 -0
- package/dist/host/im/platforms/weixin.js +1117 -0
- package/dist/host/im/reaction.js +51 -0
- package/dist/host/im/supervisor.js +56 -0
- package/dist/host/team/agents.js +161 -0
- package/dist/host/team/models.js +427 -0
- package/dist/host/team/permissions.js +263 -0
- package/dist/host/team/privacy.js +108 -0
- package/dist/host/team/prompt.js +86 -0
- package/dist/host/team/providers.js +461 -0
- package/dist/host/team/roster.js +1011 -0
- package/dist/host/team/tools.js +1638 -0
- package/dist/host/work/approvals.js +290 -0
- package/dist/host/work/avatar-library.js +155 -0
- package/dist/host/work/avatar.js +132 -0
- package/dist/host/work/capabilities.js +862 -0
- package/dist/host/work/companion.js +306 -0
- package/dist/host/work/goal-skills.js +91 -0
- package/dist/host/work/goals.js +1337 -0
- package/dist/host/work/images.js +283 -0
- package/dist/host/work/learning.js +1021 -0
- package/dist/host/work/memory-pending.js +108 -0
- package/dist/host/work/memory.js +3237 -0
- package/dist/host/work/notify.js +146 -0
- package/dist/host/work/reset.js +43 -0
- package/dist/host/work/schedules.js +309 -0
- package/dist/host/work/secrets.js +702 -0
- package/dist/host/work/steward.js +166 -0
- package/dist/host/work/tts.js +192 -0
- package/dist/host/work/usage.js +84 -0
- package/dist/index.js +137 -0
- package/dist/shared/api.js +32 -0
- package/dist/shared/companion.js +110 -0
- package/dist/shared/config.js +3 -0
- package/dist/shared/coordination.js +6 -0
- package/dist/shared/group-replies.js +95 -0
- package/dist/shared/im-platforms.js +35 -0
- package/dist/shared/im.js +3 -0
- package/dist/shared/mentions.js +85 -0
- package/dist/shared/state.js +25 -0
- package/package.json +81 -5
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
// The ask path is gone, but saved
|
|
2
|
+
// cards stay readable until the user deletes them, so this file keeps their files, the
|
|
3
|
+
// projection that places a card on the turn it belongs to, and the shared snapshot /
|
|
4
|
+
// model-call helpers that side chats use.
|
|
5
|
+
//
|
|
6
|
+
// Cards live in `<home>/btw/<botId>/<partId>.json`, not in state.json, so a save never
|
|
7
|
+
// rewrites them; only the host that owns the team writes them.
|
|
8
|
+
import { randomUUID } from "node:crypto";
|
|
9
|
+
import { mkdir, readFile, rm, unlink, writeFile } from "node:fs/promises";
|
|
10
|
+
import { dirname, join } from "node:path";
|
|
11
|
+
import { BlockAssembler } from "@deepseek-ai/dsh-llm";
|
|
12
|
+
import { replaceFile } from "../core/files.js";
|
|
13
|
+
export const RESTART_ERROR = "Ocean Crew restarted before this answer finished.";
|
|
14
|
+
export const TOOL_ERROR = "The model called a tool again after being told not to, so this aside was not answered.";
|
|
15
|
+
export const EMPTY_ERROR = "The model wrote nothing.";
|
|
16
|
+
// Events that still belong to a turn. A model selection or a title does not.
|
|
17
|
+
const MESSAGE_EVENTS = new Set([
|
|
18
|
+
"user/message",
|
|
19
|
+
"assistant/message",
|
|
20
|
+
"tool/call",
|
|
21
|
+
"tool/result",
|
|
22
|
+
"turn/start",
|
|
23
|
+
"turn/end",
|
|
24
|
+
]);
|
|
25
|
+
// Host-only projection. `turnBoundary` clears its open seq on turn/end, so a log-only
|
|
26
|
+
// tail would lose the turn it still belongs to.
|
|
27
|
+
const ANCHOR_KEY = "dsBotAnchor";
|
|
28
|
+
const seqOf = (value) => typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : null;
|
|
29
|
+
const turnOf = (value) => (typeof value === "number" && Number.isInteger(value) ? value : null);
|
|
30
|
+
// Checkpoint check for the projection registry. The live read does not use it.
|
|
31
|
+
const anchorStateSchema = {
|
|
32
|
+
parse(value) {
|
|
33
|
+
if (value === null || typeof value !== "object" || Array.isArray(value))
|
|
34
|
+
throw new Error("ocean-crew anchor projection state is not an object");
|
|
35
|
+
const record = value;
|
|
36
|
+
const starts = [];
|
|
37
|
+
if (Array.isArray(record.starts)) {
|
|
38
|
+
for (const raw of record.starts) {
|
|
39
|
+
const start = raw;
|
|
40
|
+
const seq = seqOf(start?.seq);
|
|
41
|
+
const turn = turnOf(start?.turn);
|
|
42
|
+
if (seq === null || turn === null)
|
|
43
|
+
continue;
|
|
44
|
+
starts.push({ seq, turn });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
turnStartSeq: seqOf(record.turnStartSeq),
|
|
49
|
+
turn: turnOf(record.turn),
|
|
50
|
+
messageSeq: seqOf(record.messageSeq),
|
|
51
|
+
starts,
|
|
52
|
+
};
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
// `turnStartSeq` is the latest turn/start and stays set after turn/end.
|
|
56
|
+
// `starts` lists every numbered turn/start so a later page can seed its rows.
|
|
57
|
+
const anchorProjection = {
|
|
58
|
+
key: ANCHOR_KEY,
|
|
59
|
+
stateVersion: 1,
|
|
60
|
+
stateSchema: anchorStateSchema,
|
|
61
|
+
init() {
|
|
62
|
+
return { turnStartSeq: null, turn: null, messageSeq: null, starts: [] };
|
|
63
|
+
},
|
|
64
|
+
apply(state, event) {
|
|
65
|
+
const seq = seqOf(event?.seq);
|
|
66
|
+
if (seq === null)
|
|
67
|
+
return state;
|
|
68
|
+
if (event.type === "turn/start") {
|
|
69
|
+
const turn = turnOf(event.data?.turn);
|
|
70
|
+
if (turn === null)
|
|
71
|
+
return state.turnStartSeq === seq && state.messageSeq === seq
|
|
72
|
+
? state
|
|
73
|
+
: { ...state, turnStartSeq: seq, messageSeq: seq };
|
|
74
|
+
return { turnStartSeq: seq, turn, messageSeq: seq, starts: [...state.starts, { seq, turn }] };
|
|
75
|
+
}
|
|
76
|
+
if (!MESSAGE_EVENTS.has(event.type) || state.messageSeq === seq)
|
|
77
|
+
return state;
|
|
78
|
+
return { ...state, messageSeq: seq };
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
// The projections service, when this DSH build has it.
|
|
82
|
+
const projectionsOf = (ctx) => (typeof ctx?.get === "function" ? ctx.get("sessionProjections") : undefined);
|
|
83
|
+
// `session.eventAt` is closed to new callers. The registry folds the log;
|
|
84
|
+
// this reads the fold. Absent means the capability is not loaded.
|
|
85
|
+
function registerAnchor(ctx) {
|
|
86
|
+
const register = (projections) => {
|
|
87
|
+
if (typeof projections?.register === "function")
|
|
88
|
+
projections.register(anchorProjection);
|
|
89
|
+
};
|
|
90
|
+
if (typeof ctx?.inject === "function") {
|
|
91
|
+
ctx.inject(["sessionProjections"], (projectionCtx) => {
|
|
92
|
+
register(projectionCtx.sessionProjections);
|
|
93
|
+
});
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
register(projectionsOf(ctx));
|
|
97
|
+
}
|
|
98
|
+
const anchorState = (session, projections) => {
|
|
99
|
+
if (typeof projections?.stateOf !== "function" || session == null)
|
|
100
|
+
return undefined;
|
|
101
|
+
return projections.stateOf(session, ANCHOR_KEY);
|
|
102
|
+
};
|
|
103
|
+
const callIds = (message) => {
|
|
104
|
+
if (message?.role !== "assistant" || !Array.isArray(message.content))
|
|
105
|
+
return [];
|
|
106
|
+
const ids = [];
|
|
107
|
+
for (const block of message.content)
|
|
108
|
+
if (block?.type === "tool-call")
|
|
109
|
+
ids.push(block.id);
|
|
110
|
+
return ids;
|
|
111
|
+
};
|
|
112
|
+
// DSH commits the assistant message before tool results exist. A tail that still
|
|
113
|
+
// owes a result is dropped whole: an adapter rejects a tool call with no result,
|
|
114
|
+
// and an aside does not wait for one or invent one.
|
|
115
|
+
export function trimToolBoundary(messages) {
|
|
116
|
+
if (!Array.isArray(messages) || messages.length === 0)
|
|
117
|
+
return [];
|
|
118
|
+
let index = messages.length;
|
|
119
|
+
while (index > 0 && messages[index - 1]?.role === "tool")
|
|
120
|
+
index -= 1;
|
|
121
|
+
const calls = callIds(messages[index - 1]);
|
|
122
|
+
if (calls.length === 0)
|
|
123
|
+
return messages;
|
|
124
|
+
const answered = new Set();
|
|
125
|
+
for (const message of messages.slice(index)) {
|
|
126
|
+
if (typeof message?.toolCallId === "string")
|
|
127
|
+
answered.add(message.toolCallId);
|
|
128
|
+
}
|
|
129
|
+
const balanced = calls.every((id) => typeof id === "string" && answered.has(id));
|
|
130
|
+
return balanced ? messages : messages.slice(0, index - 1);
|
|
131
|
+
}
|
|
132
|
+
// `seq` is the next event's number. The last committed one is the snapshot's atSeq.
|
|
133
|
+
const committedSeq = (session) => {
|
|
134
|
+
const seq = session?.seq;
|
|
135
|
+
return typeof seq === "number" && Number.isInteger(seq) && seq > 0 ? seq - 1 : null;
|
|
136
|
+
};
|
|
137
|
+
// `atSeq` is the last committed event, which may be log-only. `anchorSeq` is the
|
|
138
|
+
// turn/start of the turn that event belongs to (or the newest message when no turn
|
|
139
|
+
// has started) so the card is not left turnless. `turn` is that turn's number from the
|
|
140
|
+
// same read, or null when no numbered turn owns the snapshot.
|
|
141
|
+
function placementOf(session, projections) {
|
|
142
|
+
const state = anchorState(session, projections);
|
|
143
|
+
if (state == null)
|
|
144
|
+
return { anchorSeq: null, turn: null };
|
|
145
|
+
return { anchorSeq: seqOf(state.turnStartSeq) ?? seqOf(state.messageSeq), turn: turnOf(state.turn) };
|
|
146
|
+
}
|
|
147
|
+
// The turn number in effect at `seq`: the latest numbered turn/start at or before it.
|
|
148
|
+
// No projection, no session, or no such start leaves it unset.
|
|
149
|
+
export function openTurnAt(projections, session, seq) {
|
|
150
|
+
if (seqOf(seq) === null)
|
|
151
|
+
return undefined;
|
|
152
|
+
const starts = anchorState(session, projections)?.starts;
|
|
153
|
+
if (!Array.isArray(starts))
|
|
154
|
+
return undefined;
|
|
155
|
+
let best = -1;
|
|
156
|
+
let turn;
|
|
157
|
+
for (const start of starts) {
|
|
158
|
+
const at = seqOf(start?.seq);
|
|
159
|
+
if (at === null || at > seq || at < best)
|
|
160
|
+
continue;
|
|
161
|
+
const next = turnOf(start.turn);
|
|
162
|
+
if (next === null)
|
|
163
|
+
continue;
|
|
164
|
+
best = at;
|
|
165
|
+
turn = next;
|
|
166
|
+
}
|
|
167
|
+
return turn;
|
|
168
|
+
}
|
|
169
|
+
// Header, derived history, tool history, and the session id, taken together.
|
|
170
|
+
export function snapshotOf(session, projections) {
|
|
171
|
+
const header = session.requestHeader();
|
|
172
|
+
const messages = trimToolBoundary(session.deriveMessages());
|
|
173
|
+
const toolHistory = session.toolHistory();
|
|
174
|
+
const { anchorSeq, turn } = placementOf(session, projections);
|
|
175
|
+
return { header, messages, toolHistory, sessionId: session.id, atSeq: committedSeq(session), anchorSeq, turn };
|
|
176
|
+
}
|
|
177
|
+
// The same route as the conversation, including temperature, stop, and the output
|
|
178
|
+
// budget: thinking shares it, so an aside must not set a small one of its own. The
|
|
179
|
+
// messages array is tagged for usage when the usage module is installed. `extra`
|
|
180
|
+
// messages sit between the snapshot and the prompt;
|
|
181
|
+
// pairs go there.
|
|
182
|
+
export async function callAside(ctx, rt, snapshot, { extra = [], question, promptFor, kind, retry = false, signal, }) {
|
|
183
|
+
const raw = [
|
|
184
|
+
...snapshot.messages,
|
|
185
|
+
...extra,
|
|
186
|
+
{ role: "user", content: [{ type: "text", text: promptFor(question, retry) }] },
|
|
187
|
+
];
|
|
188
|
+
const messages = typeof rt.tagUsage === "function" ? rt.tagUsage(raw, { sessionId: snapshot.sessionId, kind }) : raw;
|
|
189
|
+
const assembler = new BlockAssembler();
|
|
190
|
+
const tools = snapshot.header?.tools;
|
|
191
|
+
for await (const chunk of ctx.llm.stream({
|
|
192
|
+
...snapshot.header?.config,
|
|
193
|
+
messages,
|
|
194
|
+
toolHistory: snapshot.toolHistory,
|
|
195
|
+
...(tools ? { tools: [...tools] } : {}),
|
|
196
|
+
sessionId: snapshot.sessionId,
|
|
197
|
+
signal,
|
|
198
|
+
}))
|
|
199
|
+
assembler.push(chunk);
|
|
200
|
+
const finish = assembler.finish;
|
|
201
|
+
if (finish.kind === "error" || finish.kind === "aborted") {
|
|
202
|
+
return { failed: finish.failure?.message ?? finish.kind };
|
|
203
|
+
}
|
|
204
|
+
const text = assembler
|
|
205
|
+
.blocks()
|
|
206
|
+
.filter((block) => block.type === "text")
|
|
207
|
+
.map((block) => block.text)
|
|
208
|
+
.join("\n")
|
|
209
|
+
.trim();
|
|
210
|
+
// Checked before a tool-call retry: a truncated tool call is not a request.
|
|
211
|
+
if (finish.kind === "max-tokens")
|
|
212
|
+
return { text, cutOff: true };
|
|
213
|
+
const toolCalls = finish.kind === "tool-calls" || assembler.blocks().some((block) => block?.type === "tool-call");
|
|
214
|
+
return { text, toolCalls };
|
|
215
|
+
}
|
|
216
|
+
// `groupError` is the refusal a group chat raises.
|
|
217
|
+
export function locatePrivate(rt, raw, groupError) {
|
|
218
|
+
const past = rt.pastOwners.get(String(raw));
|
|
219
|
+
const sessionId = rt.botOf(String(raw)) !== undefined
|
|
220
|
+
? rt.chatOf(String(raw))
|
|
221
|
+
: past && past.roomId === undefined && past.botId !== undefined
|
|
222
|
+
? rt.chatOf(past.botId)
|
|
223
|
+
: String(raw);
|
|
224
|
+
const grouped = rt.roomOf(String(raw)) !== undefined ||
|
|
225
|
+
rt.roomOf(sessionId) !== undefined ||
|
|
226
|
+
rt.groupOwners.has(String(raw)) ||
|
|
227
|
+
rt.groupOwners.has(sessionId) ||
|
|
228
|
+
past?.roomId !== undefined;
|
|
229
|
+
if (grouped)
|
|
230
|
+
throw new Error(groupError);
|
|
231
|
+
const botId = rt.chatOwners.get(sessionId) ?? rt.botOf(String(raw))?.id;
|
|
232
|
+
if (botId === undefined || rt.botOf(botId) === undefined)
|
|
233
|
+
throw new Error("Unknown Bot");
|
|
234
|
+
return { sessionId, botId };
|
|
235
|
+
}
|
|
236
|
+
export function install(rt) {
|
|
237
|
+
const { ctx, state } = rt;
|
|
238
|
+
registerAnchor(ctx);
|
|
239
|
+
const dropped = new Set();
|
|
240
|
+
// Cards by Bot and part. A part's list is read from
|
|
241
|
+
// <home>/btw/<botId>/<partId>.json on first need and kept here; writes are
|
|
242
|
+
// whole-file replaces, queued so two deletes never interleave. A runtime without
|
|
243
|
+
// a home (a bare channel install) keeps cards in memory only.
|
|
244
|
+
const cardsDir = typeof rt.home === "string" ? join(rt.home, "btw") : undefined;
|
|
245
|
+
const partFile = (botId, partId) => join(cardsDir, botId, `${partId}.json`);
|
|
246
|
+
const partKey = (botId, partId) => `${botId}/${partId}`;
|
|
247
|
+
const writes = () => rt.readOnly?.() === undefined;
|
|
248
|
+
const cards = new Map();
|
|
249
|
+
const reading = new Map();
|
|
250
|
+
let writing = Promise.resolve();
|
|
251
|
+
let deleting = Promise.resolve();
|
|
252
|
+
const listOf = (botId, partId) => cards.get(botId)?.get(partId) ?? [];
|
|
253
|
+
const writePart = (botId, partId) => {
|
|
254
|
+
const run = writing.then(async () => {
|
|
255
|
+
if (cardsDir === undefined || !writes() || dropped.has(botId) || rt.botOf(botId) === undefined)
|
|
256
|
+
return;
|
|
257
|
+
const path = partFile(botId, partId);
|
|
258
|
+
await mkdir(dirname(path), { recursive: true });
|
|
259
|
+
const temp = `${path}.${process.pid}.tmp`;
|
|
260
|
+
try {
|
|
261
|
+
await writeFile(temp, JSON.stringify(cards.get(botId)?.get(partId) ?? [], null, 2), "utf8");
|
|
262
|
+
await replaceFile(temp, path);
|
|
263
|
+
}
|
|
264
|
+
catch (error) {
|
|
265
|
+
await unlink(temp).catch(() => { });
|
|
266
|
+
throw error;
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
writing = run.catch(() => { });
|
|
270
|
+
return run;
|
|
271
|
+
};
|
|
272
|
+
// A part's list, reading its file once. The owning host records the restart a
|
|
273
|
+
// previous process left behind: an 'answering' card could only have failed with
|
|
274
|
+
// it, and the file is written again. A reading host keeps the cards as they were
|
|
275
|
+
// saved.
|
|
276
|
+
async function readPart(botId, partId) {
|
|
277
|
+
const byBot = cards.get(botId) ?? new Map();
|
|
278
|
+
cards.set(botId, byBot);
|
|
279
|
+
const held = byBot.get(partId);
|
|
280
|
+
if (held !== undefined)
|
|
281
|
+
return held;
|
|
282
|
+
const key = partKey(botId, partId);
|
|
283
|
+
const pending = reading.get(key);
|
|
284
|
+
if (pending !== undefined)
|
|
285
|
+
return pending;
|
|
286
|
+
const job = (async () => {
|
|
287
|
+
let list = [];
|
|
288
|
+
if (cardsDir === undefined) {
|
|
289
|
+
byBot.set(partId, list);
|
|
290
|
+
return list;
|
|
291
|
+
}
|
|
292
|
+
try {
|
|
293
|
+
const parsed = JSON.parse(await readFile(partFile(botId, partId), "utf8"));
|
|
294
|
+
if (Array.isArray(parsed)) {
|
|
295
|
+
list = parsed
|
|
296
|
+
.filter((card) => card !== null && typeof card === "object" && !Array.isArray(card))
|
|
297
|
+
.map((card) => ({ ...card, botId, partId }));
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
catch (error) {
|
|
301
|
+
const code = error?.code;
|
|
302
|
+
if (code !== "ENOENT")
|
|
303
|
+
rt.warn("ocean-crew: unreadable btw cards %s: %s", partFile(botId, partId), code ?? error);
|
|
304
|
+
}
|
|
305
|
+
if (writes()) {
|
|
306
|
+
const now = Date.now();
|
|
307
|
+
let touched = false;
|
|
308
|
+
list = list.map((card) => {
|
|
309
|
+
if (card.status !== "answering")
|
|
310
|
+
return card;
|
|
311
|
+
touched = true;
|
|
312
|
+
return {
|
|
313
|
+
...card,
|
|
314
|
+
status: "failed",
|
|
315
|
+
error: card.error ?? RESTART_ERROR,
|
|
316
|
+
finishedAt: card.finishedAt ?? now,
|
|
317
|
+
};
|
|
318
|
+
});
|
|
319
|
+
byBot.set(partId, list);
|
|
320
|
+
if (touched)
|
|
321
|
+
await writePart(botId, partId).catch((error) => rt.warn("ocean-crew: btw cards of %s not saved: %s", botId, error));
|
|
322
|
+
}
|
|
323
|
+
else {
|
|
324
|
+
byBot.set(partId, list);
|
|
325
|
+
}
|
|
326
|
+
return byBot.get(partId) ?? list;
|
|
327
|
+
})();
|
|
328
|
+
reading.set(key, job);
|
|
329
|
+
try {
|
|
330
|
+
return await job;
|
|
331
|
+
}
|
|
332
|
+
finally {
|
|
333
|
+
reading.delete(key);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
// Only each Bot's current part is read up front.
|
|
337
|
+
const originalLoad = typeof rt.load === "function" ? rt.load.bind(rt) : undefined;
|
|
338
|
+
if (originalLoad !== undefined) {
|
|
339
|
+
let ready;
|
|
340
|
+
rt.load = () => {
|
|
341
|
+
ready ??= (async () => {
|
|
342
|
+
await originalLoad();
|
|
343
|
+
for (const botId of Object.keys(state.bots))
|
|
344
|
+
await readPart(botId, rt.chatOf(botId));
|
|
345
|
+
})();
|
|
346
|
+
return ready;
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
const originalRefresh = typeof rt.refresh === "function" ? rt.refresh.bind(rt) : undefined;
|
|
350
|
+
if (originalRefresh !== undefined) {
|
|
351
|
+
rt.refresh = async () => {
|
|
352
|
+
const wasWriting = writes();
|
|
353
|
+
await originalRefresh();
|
|
354
|
+
// Taking over a team the other host closed makes this one the writer:
|
|
355
|
+
// re-read the loaded parts so a leftover 'answering' is recorded now.
|
|
356
|
+
if (wasWriting || !writes())
|
|
357
|
+
return;
|
|
358
|
+
for (const [botId, byBot] of cards) {
|
|
359
|
+
const parts = [...byBot.keys()];
|
|
360
|
+
byBot.clear();
|
|
361
|
+
for (const partId of parts)
|
|
362
|
+
await readPart(botId, partId);
|
|
363
|
+
}
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
// The team view carries each Bot's current part only;
|
|
367
|
+
// their history pages.
|
|
368
|
+
const btwView = () => {
|
|
369
|
+
const view = {};
|
|
370
|
+
for (const botId of Object.keys(state.bots)) {
|
|
371
|
+
const list = listOf(botId, rt.chatOf(botId));
|
|
372
|
+
if (list.length > 0)
|
|
373
|
+
view[botId] = list;
|
|
374
|
+
}
|
|
375
|
+
return view;
|
|
376
|
+
};
|
|
377
|
+
// Deleting a card removes only that saved aside. The whole read-modify-write is
|
|
378
|
+
// queued so two cards deleted together cannot restore each other. A state save
|
|
379
|
+
// afterwards bumps the revision, so every window polls the new lists.
|
|
380
|
+
function btwDelete({ sessionId, cardId }) {
|
|
381
|
+
const run = deleting.then(async () => {
|
|
382
|
+
const { botId } = locatePrivate(rt, sessionId, "Side chat is only available in a Bot's private chat.");
|
|
383
|
+
const list = await readPart(botId, sessionId);
|
|
384
|
+
const next = list.filter((card) => card?.id !== cardId);
|
|
385
|
+
if (next.length === list.length)
|
|
386
|
+
return { deleted: false };
|
|
387
|
+
const byBot = cards.get(botId);
|
|
388
|
+
byBot.set(sessionId, next);
|
|
389
|
+
try {
|
|
390
|
+
await writePart(botId, sessionId);
|
|
391
|
+
}
|
|
392
|
+
catch (error) {
|
|
393
|
+
byBot.set(sessionId, list);
|
|
394
|
+
throw error;
|
|
395
|
+
}
|
|
396
|
+
await rt.save();
|
|
397
|
+
return { deleted: true };
|
|
398
|
+
});
|
|
399
|
+
deleting = run.catch(() => { });
|
|
400
|
+
return run;
|
|
401
|
+
}
|
|
402
|
+
// Plugin unload; queued deletes and their file writes have to finish.
|
|
403
|
+
async function btwDispose() {
|
|
404
|
+
await deleting;
|
|
405
|
+
await writing;
|
|
406
|
+
}
|
|
407
|
+
// Clears the Bot's cards before the roster save.
|
|
408
|
+
async function dropBot(botId) {
|
|
409
|
+
dropped.add(botId);
|
|
410
|
+
cards.delete(botId);
|
|
411
|
+
for (const key of reading.keys())
|
|
412
|
+
if (key.startsWith(`${botId}/`))
|
|
413
|
+
reading.delete(key);
|
|
414
|
+
if (cardsDir !== undefined && writes() && typeof rm === "function") {
|
|
415
|
+
await rm(join(cardsDir, botId), { recursive: true, force: true }).catch((error) => rt.warn("ocean-crew: btw cards of %s not removed: %s", botId, error));
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
// Channel can be installed without the roster (the thinking route test). The full
|
|
419
|
+
// runtime always has deleteBot, and that is the path that must drop cards.
|
|
420
|
+
if (typeof rt.deleteBot === "function") {
|
|
421
|
+
const originalDelete = rt.deleteBot.bind(rt);
|
|
422
|
+
rt.deleteBot = async (id) => {
|
|
423
|
+
if (rt.botOf(id) !== undefined && !rt.isMain(id))
|
|
424
|
+
await dropBot(id);
|
|
425
|
+
return originalDelete(id);
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
Object.assign(rt, {
|
|
429
|
+
btwDelete,
|
|
430
|
+
btwDispose,
|
|
431
|
+
btwView,
|
|
432
|
+
btwPartCards: readPart,
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
// A card id for a new aside; the ask path is gone, but tests and older cards use it.
|
|
436
|
+
export const newCardId = () => randomUUID();
|