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
|
@@ -0,0 +1,625 @@
|
|
|
1
|
+
// Hand-offs: the work Bots give each other, and what each Bot is doing right now.
|
|
2
|
+
//
|
|
3
|
+
// A Bot's request to another Bot is a piece of work. What the recipient hands on while
|
|
4
|
+
// it works on it hangs below it, and the recipient's answer goes up only once all of
|
|
5
|
+
// that came back, together with what came back. Results find their way up by
|
|
6
|
+
// themselves, so no Bot needs to message one that waits for it, and that is the one
|
|
7
|
+
// hand-off refused. The open works are kept in state.json so a restart waits on.
|
|
8
|
+
//
|
|
9
|
+
// The goal cards that read this tree (`goalTaskReported`/`goalTaskReset`, goals.ts) and
|
|
10
|
+
// mounting a subagent
|
|
11
|
+
import { createHash } from "node:crypto";
|
|
12
|
+
import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { TEXT_KEPT } from "../core/constants.js";
|
|
15
|
+
import { headOf } from "../core/text.js";
|
|
16
|
+
const minutes = (since) => {
|
|
17
|
+
const count = Math.floor((Date.now() - since) / 60_000);
|
|
18
|
+
return count < 1 ? "for less than a minute" : `for ${count} min`;
|
|
19
|
+
};
|
|
20
|
+
export function install(rt) {
|
|
21
|
+
const state = rt.state;
|
|
22
|
+
const table = state;
|
|
23
|
+
const works = () => {
|
|
24
|
+
table.works ??= {};
|
|
25
|
+
return table.works;
|
|
26
|
+
};
|
|
27
|
+
// Results that still owe the user a delivery, kept after their works are gone.
|
|
28
|
+
const duties = () => {
|
|
29
|
+
table.publishDuties ??= {};
|
|
30
|
+
return table.publishDuties;
|
|
31
|
+
};
|
|
32
|
+
const DUTIES_KEPT = 100;
|
|
33
|
+
const DUTY_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
34
|
+
// What came back, whole; state.json keeps the start of each.
|
|
35
|
+
const whole = new Map();
|
|
36
|
+
const save = () => {
|
|
37
|
+
void rt.save().catch(() => { });
|
|
38
|
+
};
|
|
39
|
+
const nameOf = (id) => rt.botOf(id)?.name;
|
|
40
|
+
// `turns` publishes the live turns on the runtime; read as the shape this module uses.
|
|
41
|
+
// Read at call time: this module installs before `turns` fills the map in.
|
|
42
|
+
const live = () => rt.live;
|
|
43
|
+
// The whole result of a work, in a file of its own: state.json is written whole on
|
|
44
|
+
// every save, so it keeps only the start of a long result, and a restart would cut
|
|
45
|
+
// that result short before it reached the Bot that asked for it.
|
|
46
|
+
const resultsDir = join(rt.home, "handoff-results");
|
|
47
|
+
const resultPath = (name) => join(resultsDir, name);
|
|
48
|
+
// A work id is a UUID, but a saved team could carry any name: it stays inside the
|
|
49
|
+
// folder, and one that is not plain falls back to a hash of the id.
|
|
50
|
+
const resultName = (id) => {
|
|
51
|
+
const text = String(id);
|
|
52
|
+
return `${/^[A-Za-z0-9_-]{1,80}$/.test(text) ? text : createHash("sha256").update(text).digest("hex")}.txt`;
|
|
53
|
+
};
|
|
54
|
+
const keptName = (file) => typeof file === "string" && /^[A-Za-z0-9_-]+\.txt$/.test(file) ? file : undefined;
|
|
55
|
+
const writes = () => rt.readOnly() === undefined;
|
|
56
|
+
// The result of a work, whole, in a file of its own. The file is written before the
|
|
57
|
+
// team file names it, so a result that is claimed to exist is there.
|
|
58
|
+
function keepResult(id, text) {
|
|
59
|
+
if (text.length <= TEXT_KEPT || !writes())
|
|
60
|
+
return undefined;
|
|
61
|
+
const name = resultName(id);
|
|
62
|
+
const path = resultPath(name);
|
|
63
|
+
const temp = `${path}.${process.pid}.tmp`;
|
|
64
|
+
try {
|
|
65
|
+
mkdirSync(resultsDir, { recursive: true });
|
|
66
|
+
writeFileSync(temp, text, "utf8");
|
|
67
|
+
renameSync(temp, path);
|
|
68
|
+
return name;
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
rt.warn("ocean-crew: result of work %s not kept whole: %s", id, error);
|
|
72
|
+
try {
|
|
73
|
+
rmSync(temp, { force: true });
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
// The temp file is not there or cannot be removed; the result stays in state.json.
|
|
77
|
+
}
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
// The copy of a result that is not needed any more: the answer it went into has it.
|
|
82
|
+
const dropResult = (file) => {
|
|
83
|
+
const name = keptName(file);
|
|
84
|
+
if (name === undefined || !writes())
|
|
85
|
+
return;
|
|
86
|
+
try {
|
|
87
|
+
// save() can resolve without writing during shutdown. Check the durable
|
|
88
|
+
// references too, so an old state can still recover its result after restart.
|
|
89
|
+
const saved = JSON.parse(readFileSync(join(rt.home, "state.json"), "utf8"));
|
|
90
|
+
if (Object.values(saved.works ?? {}).some((work) => (work.results ?? []).some((result) => result.file === name)))
|
|
91
|
+
return;
|
|
92
|
+
rmSync(resultPath(name), { force: true });
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
// A missing or unreadable team file keeps the copy; the next host drops it.
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
// What came back for a work: the whole result if it is still to be found, else the
|
|
99
|
+
// start of it. A result that a restart cut, or whose file is gone, is reported as
|
|
100
|
+
// such and not passed on as if it were whole.
|
|
101
|
+
function resultText(result) {
|
|
102
|
+
const kept = whole.get(result.id);
|
|
103
|
+
if (kept !== undefined)
|
|
104
|
+
return { text: kept, whole: true };
|
|
105
|
+
if (result.length === undefined)
|
|
106
|
+
return { text: result.text ?? "", whole: true };
|
|
107
|
+
const name = keptName(result.file);
|
|
108
|
+
if (name !== undefined) {
|
|
109
|
+
try {
|
|
110
|
+
const text = readFileSync(resultPath(name), "utf8");
|
|
111
|
+
if (text.length === result.length)
|
|
112
|
+
return { text, whole: true };
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
// The file is gone or unreadable: the start of the result is all there is.
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return { text: result.text ?? "", whole: false };
|
|
119
|
+
}
|
|
120
|
+
// The work a turn does: the request it answers, or the work a result that came back
|
|
121
|
+
// (or a turn picked up again) goes on with.
|
|
122
|
+
const workOf = (info) => info === undefined
|
|
123
|
+
? undefined
|
|
124
|
+
: info.role === "request" && info.audience !== "user"
|
|
125
|
+
? info.exchangeId
|
|
126
|
+
: info.work;
|
|
127
|
+
// `parent`: the work the sender was doing when it sent this one. `child`: the
|
|
128
|
+
// session id of the subagent a run of this work belongs to — the run, not the
|
|
129
|
+
// child, is the sub-work, so a child that is continued hands up a result per run.
|
|
130
|
+
function openWork(id, { from, to, parent, fromRoom, room, name, child }) {
|
|
131
|
+
// The work id is the replay key: a request replayed with the same id is the same
|
|
132
|
+
// hand-off, not a second one. `dropWork` removes the id, so it can be opened again.
|
|
133
|
+
if (works()[id] !== undefined)
|
|
134
|
+
return;
|
|
135
|
+
const above = parent === undefined ? undefined : works()[parent];
|
|
136
|
+
works()[id] = {
|
|
137
|
+
...(from ? { from } : {}),
|
|
138
|
+
...(to ? { to } : {}),
|
|
139
|
+
...(room ? { room } : {}),
|
|
140
|
+
...(above ? { parent } : {}),
|
|
141
|
+
...(fromRoom ? { fromRoom } : {}),
|
|
142
|
+
...(name ? { name } : {}),
|
|
143
|
+
...(child ? { child } : {}),
|
|
144
|
+
open: [],
|
|
145
|
+
results: [],
|
|
146
|
+
at: Date.now(),
|
|
147
|
+
};
|
|
148
|
+
if (above !== undefined)
|
|
149
|
+
above.open.push(id);
|
|
150
|
+
save();
|
|
151
|
+
}
|
|
152
|
+
// The publish duty of a result: pending from the moment it exists. The room the work
|
|
153
|
+
// was handed on in is where the user reads it; without a room the asker's answer
|
|
154
|
+
// carries it, so only the top of a chain keeps a duty.
|
|
155
|
+
const openDuty = (id, work, name, text) => {
|
|
156
|
+
const room = work.room ?? work.fromRoom;
|
|
157
|
+
if (room === undefined && work.parent !== undefined)
|
|
158
|
+
return;
|
|
159
|
+
duties()[id] = {
|
|
160
|
+
id,
|
|
161
|
+
state: "pending",
|
|
162
|
+
at: Date.now(),
|
|
163
|
+
...(work.from !== undefined ? { by: work.from } : {}),
|
|
164
|
+
...(room !== undefined ? { room } : {}),
|
|
165
|
+
...(work.parent !== undefined ? { parent: work.parent } : {}),
|
|
166
|
+
name,
|
|
167
|
+
text: headOf(text, TEXT_KEPT),
|
|
168
|
+
};
|
|
169
|
+
pruneDuties();
|
|
170
|
+
};
|
|
171
|
+
// Delivered duty and every result folded into it. One line that reaches the user
|
|
172
|
+
// settles the whole chain it summarized.
|
|
173
|
+
function deliver(id) {
|
|
174
|
+
const duty = duties()[id];
|
|
175
|
+
if (duty === undefined)
|
|
176
|
+
return false;
|
|
177
|
+
duty.state = "delivered";
|
|
178
|
+
duty.at = Date.now();
|
|
179
|
+
delete duty.reason;
|
|
180
|
+
for (const child of Object.values(duties()))
|
|
181
|
+
if (child.parent === id && child.state !== "delivered") {
|
|
182
|
+
child.state = "delivered";
|
|
183
|
+
child.at = duty.at;
|
|
184
|
+
delete child.reason;
|
|
185
|
+
}
|
|
186
|
+
return true;
|
|
187
|
+
}
|
|
188
|
+
// A duty that is dropped instead of delivered says so; it is never silently removed.
|
|
189
|
+
function abandon(id, reason) {
|
|
190
|
+
const duty = duties()[id];
|
|
191
|
+
if (duty === undefined || duty.state === "delivered")
|
|
192
|
+
return;
|
|
193
|
+
duty.state = "abandoned";
|
|
194
|
+
duty.at = Date.now();
|
|
195
|
+
duty.reason = reason;
|
|
196
|
+
for (const child of Object.values(duties()))
|
|
197
|
+
if (child.parent === id && child.state !== "delivered") {
|
|
198
|
+
child.state = "abandoned";
|
|
199
|
+
child.at = duty.at;
|
|
200
|
+
child.reason = reason;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
// Old duties go first. One still owed is kept until its retention runs out, and is
|
|
204
|
+
// then marked abandoned with the reason instead of being forgotten. Past the count
|
|
205
|
+
// limit the oldest owed duty becomes the same explicit failure, so a busy team's
|
|
206
|
+
// state file stays bounded without a duty vanishing unannounced.
|
|
207
|
+
function pruneDuties() {
|
|
208
|
+
const kept = Object.values(duties()).sort((left, right) => left.at - right.at);
|
|
209
|
+
const now = Date.now();
|
|
210
|
+
for (const duty of kept) {
|
|
211
|
+
if (duty.state === "delivered" || duty.state === "abandoned")
|
|
212
|
+
continue;
|
|
213
|
+
if (now - duty.at > DUTY_TTL_MS)
|
|
214
|
+
abandon(duty.id, "the user was not confirmed to have received this result within 7 days");
|
|
215
|
+
}
|
|
216
|
+
const all = Object.values(duties()).sort((left, right) => left.at - right.at);
|
|
217
|
+
for (const duty of all.slice(0, Math.max(0, all.length - DUTIES_KEPT))) {
|
|
218
|
+
if (duty.state === "pending" || duty.state === "taken")
|
|
219
|
+
abandon(duty.id, "the publish ledger reached its retention limit before delivery was confirmed");
|
|
220
|
+
}
|
|
221
|
+
for (const duty of Object.values(duties()).sort((left, right) => left.at - right.at)) {
|
|
222
|
+
if (Object.keys(duties()).length <= DUTIES_KEPT)
|
|
223
|
+
break;
|
|
224
|
+
if (duty.state === "delivered" || duty.state === "abandoned")
|
|
225
|
+
delete duties()[duty.id];
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
// The answer to work `id` is on its way, with `result` from `name`. Returns the work
|
|
229
|
+
// it goes on with. That work keeps waiting until a turn reads the answer (`taken`).
|
|
230
|
+
function closeWork(id, name, text) {
|
|
231
|
+
const work = works()[id];
|
|
232
|
+
if (work === undefined)
|
|
233
|
+
return undefined;
|
|
234
|
+
// The result exists from here on; its delivery to the user is a duty that survives
|
|
235
|
+
// taken, the parent's answer and the removal of this work record.
|
|
236
|
+
openDuty(id, work, name, text);
|
|
237
|
+
delete works()[id];
|
|
238
|
+
// What it still waits for comes back to it alone.
|
|
239
|
+
for (const child of Object.values(works()))
|
|
240
|
+
if (child.parent === id)
|
|
241
|
+
delete child.parent;
|
|
242
|
+
const above = work.parent === undefined ? undefined : works()[work.parent];
|
|
243
|
+
if (above !== undefined) {
|
|
244
|
+
// The whole result goes into a file of its own before the team file names it.
|
|
245
|
+
const file = keepResult(id, text);
|
|
246
|
+
above.results.push({
|
|
247
|
+
id,
|
|
248
|
+
name,
|
|
249
|
+
text: headOf(text, TEXT_KEPT),
|
|
250
|
+
...(work.child ? { child: work.child } : {}),
|
|
251
|
+
...(text.length > TEXT_KEPT ? { length: text.length } : {}),
|
|
252
|
+
...(file ? { file } : {}),
|
|
253
|
+
at: Date.now(),
|
|
254
|
+
});
|
|
255
|
+
whole.set(id, text);
|
|
256
|
+
}
|
|
257
|
+
for (const result of work.results ?? [])
|
|
258
|
+
whole.delete(result.id);
|
|
259
|
+
// Old state can still name these files until the replacement state lands.
|
|
260
|
+
// A failed save must leave them available to the next host.
|
|
261
|
+
void rt
|
|
262
|
+
.save()
|
|
263
|
+
.then(() => {
|
|
264
|
+
for (const result of work.results ?? [])
|
|
265
|
+
dropResult(result.file);
|
|
266
|
+
})
|
|
267
|
+
.catch(() => { });
|
|
268
|
+
return above === undefined ? undefined : work.parent;
|
|
269
|
+
}
|
|
270
|
+
// The subagent run a settlement notice names. The notice carries only the child's id,
|
|
271
|
+
// and a child's runs end one after another, so it is the earliest settled run of that
|
|
272
|
+
// child not read yet, else a run of it still out.
|
|
273
|
+
const subWorkRun = (id) => {
|
|
274
|
+
let settled;
|
|
275
|
+
let running;
|
|
276
|
+
for (const [workId, work] of Object.entries(works())) {
|
|
277
|
+
for (const openId of work.open) {
|
|
278
|
+
const result = work.results?.find((item) => item.id === openId);
|
|
279
|
+
if ((result?.child ?? works()[openId]?.child ?? openId) !== id)
|
|
280
|
+
continue;
|
|
281
|
+
if (result === undefined)
|
|
282
|
+
running ??= { id: openId, parent: workId };
|
|
283
|
+
else if (settled === undefined || (result.at ?? 0) < settled.at)
|
|
284
|
+
settled = { id: openId, parent: workId, at: result.at ?? 0 };
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return settled ?? running;
|
|
288
|
+
};
|
|
289
|
+
// A settled run is handed back like a teammate's reply: its closing text joins the
|
|
290
|
+
// results of the work it hung under. `id` is the run's id, `child` the child's, which
|
|
291
|
+
// named the sub-work before runs did.
|
|
292
|
+
function settleSubWork(id, text, child) {
|
|
293
|
+
const key = works()[id] !== undefined ? id : child;
|
|
294
|
+
const work = works()[key];
|
|
295
|
+
if (work === undefined)
|
|
296
|
+
return undefined;
|
|
297
|
+
const { parent } = work;
|
|
298
|
+
closeWork(key, work.name ?? "subagent", text);
|
|
299
|
+
return parent;
|
|
300
|
+
}
|
|
301
|
+
// A fast child can settle before its tool result identifies the call that made it.
|
|
302
|
+
const nameSubWork = (id, name) => {
|
|
303
|
+
const found = Object.entries(works()).find(([key, work]) => (work.child ?? key) === id)?.[1] ??
|
|
304
|
+
Object.values(works())
|
|
305
|
+
.flatMap((work) => work.results ?? [])
|
|
306
|
+
.find((result) => (result.child ?? result.id) === id);
|
|
307
|
+
if (found === undefined || found.name === name)
|
|
308
|
+
return;
|
|
309
|
+
found.name = name;
|
|
310
|
+
save();
|
|
311
|
+
};
|
|
312
|
+
const dropWork = (id) => {
|
|
313
|
+
const work = works()[id];
|
|
314
|
+
if (work === undefined)
|
|
315
|
+
return;
|
|
316
|
+
const { parent } = work;
|
|
317
|
+
// Dropping the work removes the answer that would have carried these results: the
|
|
318
|
+
// duties become explicit failures instead of vanishing with it.
|
|
319
|
+
for (const result of work.results ?? [])
|
|
320
|
+
abandon(result.id, `the work was dropped before ${result.name}'s result reached the user`);
|
|
321
|
+
abandon(id, "the work was dropped before its result reached the user");
|
|
322
|
+
delete works()[id];
|
|
323
|
+
const above = parent === undefined ? undefined : works()[parent];
|
|
324
|
+
if (above !== undefined)
|
|
325
|
+
above.open = above.open.filter((other) => other !== id);
|
|
326
|
+
for (const result of work.results ?? [])
|
|
327
|
+
whole.delete(result.id);
|
|
328
|
+
void rt
|
|
329
|
+
.save()
|
|
330
|
+
.then(() => {
|
|
331
|
+
for (const result of work.results ?? [])
|
|
332
|
+
dropResult(result.file);
|
|
333
|
+
})
|
|
334
|
+
.catch(() => { });
|
|
335
|
+
};
|
|
336
|
+
function taken(workId, id) {
|
|
337
|
+
const work = works()[workId];
|
|
338
|
+
if (work === undefined || !work.open.includes(id))
|
|
339
|
+
return;
|
|
340
|
+
work.open = work.open.filter((other) => other !== id);
|
|
341
|
+
// The asking Bot read the result. That is not the user reading it: the publish
|
|
342
|
+
// duty stays open until the answer that carries it is delivered.
|
|
343
|
+
const duty = duties()[id];
|
|
344
|
+
if (duty !== undefined && duty.state === "pending") {
|
|
345
|
+
duty.state = "taken";
|
|
346
|
+
duty.at = Date.now();
|
|
347
|
+
}
|
|
348
|
+
save();
|
|
349
|
+
}
|
|
350
|
+
// A delivery the restart delayed is marked, so the answer that comes back for
|
|
351
|
+
// the work says the request only arrived then.
|
|
352
|
+
function lateWork(id) {
|
|
353
|
+
const work = works()[id];
|
|
354
|
+
if (work === undefined || work.late === true)
|
|
355
|
+
return;
|
|
356
|
+
work.late = true;
|
|
357
|
+
save();
|
|
358
|
+
}
|
|
359
|
+
// The reply carrying a result never reached the asker: the failure reaches it
|
|
360
|
+
// as an input instead (turns), which ends the wait when it is read like any
|
|
361
|
+
// reply. The result kept with the work it was for is marked, so the answer
|
|
362
|
+
// that goes up names it.
|
|
363
|
+
function answerMissed(id) {
|
|
364
|
+
for (const work of Object.values(works())) {
|
|
365
|
+
if (!work.open.includes(id))
|
|
366
|
+
continue;
|
|
367
|
+
const result = (work.results ?? []).find((item) => item.id === id);
|
|
368
|
+
if (result !== undefined)
|
|
369
|
+
result.failed = true;
|
|
370
|
+
save();
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
const owns = (botId, workId) => workId !== undefined && works()[workId]?.to === botId;
|
|
375
|
+
const waits = (workId) => (workId === undefined ? 0 : (works()[workId]?.open.length ?? 0)) > 0;
|
|
376
|
+
function hold(workId, text) {
|
|
377
|
+
const work = works()[workId];
|
|
378
|
+
if (work === undefined || text === "")
|
|
379
|
+
return;
|
|
380
|
+
work.held = headOf(text, TEXT_KEPT);
|
|
381
|
+
save();
|
|
382
|
+
}
|
|
383
|
+
// Who asked for the work, as the turn that answers a request sees its trigger.
|
|
384
|
+
const askedBy = (workId) => {
|
|
385
|
+
if (workId === undefined)
|
|
386
|
+
return undefined;
|
|
387
|
+
const work = works()[workId];
|
|
388
|
+
if (work === undefined)
|
|
389
|
+
return undefined;
|
|
390
|
+
return {
|
|
391
|
+
kind: "bot",
|
|
392
|
+
role: "request",
|
|
393
|
+
exchangeId: workId,
|
|
394
|
+
senderSessionId: work.from,
|
|
395
|
+
...(work.fromRoom ? { senderRoomId: work.fromRoom } : {}),
|
|
396
|
+
};
|
|
397
|
+
};
|
|
398
|
+
// The answer that goes up: what the Bot said last for this work, then what came back.
|
|
399
|
+
function answerText(workId, reply, botName) {
|
|
400
|
+
const work = works()[workId];
|
|
401
|
+
const said = reply !== "" ? reply : (work?.held ?? "");
|
|
402
|
+
const results = (work?.results ?? []).map((result) => {
|
|
403
|
+
const { text, whole: complete } = resultText(result);
|
|
404
|
+
const cut = complete ? "" : `\n[cut; ${result.length} characters in all, the rest was not kept]`;
|
|
405
|
+
if (result.failed === true)
|
|
406
|
+
return `${result.name} could not send its reply to you; what it had answered:\n${text}${cut}`;
|
|
407
|
+
return `${result.name}:\n${text}${cut}`;
|
|
408
|
+
});
|
|
409
|
+
const body = results.length === 0
|
|
410
|
+
? said || `${botName} finished without a reply.`
|
|
411
|
+
: [
|
|
412
|
+
said || `${botName} finished and wrote nothing more.`,
|
|
413
|
+
`[Handed back to ${botName} for this work]`,
|
|
414
|
+
...results,
|
|
415
|
+
].join("\n\n");
|
|
416
|
+
// The request reached its target only after a restart; the answer says so.
|
|
417
|
+
return work?.late === true
|
|
418
|
+
? `[This request reached ${botName} only after DeepSeek Harness restarted.]\n\n${body}`
|
|
419
|
+
: body;
|
|
420
|
+
}
|
|
421
|
+
// The Bots waiting for the result of these works: whoever asked for each, and on up.
|
|
422
|
+
function upstreamOf(workIds) {
|
|
423
|
+
const bots = new Set();
|
|
424
|
+
for (const workId of workIds) {
|
|
425
|
+
const seen = new Set();
|
|
426
|
+
for (let id = workId; id !== undefined && !seen.has(id);) {
|
|
427
|
+
const work = works()[id];
|
|
428
|
+
if (work === undefined)
|
|
429
|
+
break;
|
|
430
|
+
seen.add(id);
|
|
431
|
+
if (work.from)
|
|
432
|
+
bots.add(work.from);
|
|
433
|
+
id = work.parent;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
return bots;
|
|
437
|
+
}
|
|
438
|
+
// Two requests from one sender belong to the same work when they were sent for it.
|
|
439
|
+
const sameWork = (id, parent) => {
|
|
440
|
+
const above = parent === undefined ? undefined : works()[parent];
|
|
441
|
+
return (id === undefined ? undefined : works()[id]?.parent) === (above === undefined ? undefined : parent);
|
|
442
|
+
};
|
|
443
|
+
// The works their Bots wait on, outside a turn that does them (`busy`): each with what
|
|
444
|
+
// it handed on that has not come back yet. One that came back waits only to be read.
|
|
445
|
+
const held = (busy) => Object.entries(works())
|
|
446
|
+
.filter(([id, work]) => work.to !== undefined && work.open.length > 0 && !busy.has(id))
|
|
447
|
+
.map(([id, work]) => ({
|
|
448
|
+
id,
|
|
449
|
+
work,
|
|
450
|
+
out: work.open.map((childId) => works()[childId]).filter((child) => child !== undefined),
|
|
451
|
+
}));
|
|
452
|
+
const busyWith = (sessionIds) => {
|
|
453
|
+
const busy = new Set();
|
|
454
|
+
for (const sessionId of sessionIds) {
|
|
455
|
+
const entry = live().get(sessionId);
|
|
456
|
+
if (entry?.turn === undefined)
|
|
457
|
+
continue;
|
|
458
|
+
const workId = workOf(entry.info);
|
|
459
|
+
if (workId !== undefined)
|
|
460
|
+
busy.add(workId);
|
|
461
|
+
}
|
|
462
|
+
return busy;
|
|
463
|
+
};
|
|
464
|
+
// Per Bot, the Bots and groups it waits for, and the Bots it does that work for.
|
|
465
|
+
function waitingView() {
|
|
466
|
+
const view = {};
|
|
467
|
+
const add = (list, id) => {
|
|
468
|
+
if (id !== undefined && !list.includes(id))
|
|
469
|
+
list.push(id);
|
|
470
|
+
};
|
|
471
|
+
const busy = busyWith([...live().keys()]);
|
|
472
|
+
for (const { work, out } of held(busy)) {
|
|
473
|
+
if (out.length === 0)
|
|
474
|
+
continue;
|
|
475
|
+
const target = work.to;
|
|
476
|
+
if (target === undefined)
|
|
477
|
+
continue;
|
|
478
|
+
let entry = view[target];
|
|
479
|
+
if (entry === undefined) {
|
|
480
|
+
entry = { bots: [], rooms: [], for: [] };
|
|
481
|
+
view[target] = entry;
|
|
482
|
+
}
|
|
483
|
+
for (const child of out)
|
|
484
|
+
add(child.room ? entry.rooms : entry.bots, child.room ?? child.to);
|
|
485
|
+
add(entry.for, work.from);
|
|
486
|
+
}
|
|
487
|
+
return view;
|
|
488
|
+
}
|
|
489
|
+
// A deleted Bot answers no more work and waits for none.
|
|
490
|
+
function forgetBot(botId) {
|
|
491
|
+
const waiting = [];
|
|
492
|
+
for (const [id, work] of Object.entries(works())) {
|
|
493
|
+
if (work.to !== botId)
|
|
494
|
+
continue;
|
|
495
|
+
const asker = askedBy(id);
|
|
496
|
+
if (asker !== undefined)
|
|
497
|
+
waiting.push(asker);
|
|
498
|
+
}
|
|
499
|
+
for (const [id, work] of Object.entries(works()))
|
|
500
|
+
if (work.from === botId && work.to !== botId)
|
|
501
|
+
dropWork(id);
|
|
502
|
+
return waiting;
|
|
503
|
+
}
|
|
504
|
+
// What a Bot is doing, in one line for the Bots that work with it.
|
|
505
|
+
function statusOf(botId) {
|
|
506
|
+
const chat = rt.chatOf(botId);
|
|
507
|
+
const sessions = [...new Set([chat, ...rt.groupSessionsOf(botId)])];
|
|
508
|
+
const place = (sessionId) => {
|
|
509
|
+
const room = rt.roomOf(rt.groupOwners.get(sessionId)?.roomId);
|
|
510
|
+
return room ? `in group "${room.name}"` : "in its own chat";
|
|
511
|
+
};
|
|
512
|
+
const forWhom = (sessionId, info) => {
|
|
513
|
+
if (rt.groupOwners.has(sessionId))
|
|
514
|
+
return info?.askedBy && nameOf(info.askedBy) ? `, for ${nameOf(info.askedBy)}` : "";
|
|
515
|
+
const askedWork = workOf(info);
|
|
516
|
+
const asker = info?.role === "request" || info?.role === "stopped"
|
|
517
|
+
? info.senderSessionId
|
|
518
|
+
: askedWork === undefined
|
|
519
|
+
? undefined
|
|
520
|
+
: works()[askedWork]?.from;
|
|
521
|
+
if (asker !== undefined)
|
|
522
|
+
return nameOf(asker) ? `, for ${nameOf(asker)}` : "";
|
|
523
|
+
return info === undefined ? ", for the user" : "";
|
|
524
|
+
};
|
|
525
|
+
const doing = (entry) => {
|
|
526
|
+
const what = rt.doingOf?.(entry) ?? {};
|
|
527
|
+
if (what.tool)
|
|
528
|
+
return `: running ${what.tool}${what.target ? ` on ${what.target}` : ""}`;
|
|
529
|
+
return what.said ? `: "${what.said.slice(0, 100)}"` : "";
|
|
530
|
+
};
|
|
531
|
+
const lines = [];
|
|
532
|
+
const approval = (rt.approvalsView?.() ?? []).find((entry) => entry.botId === botId);
|
|
533
|
+
const switching = [...(rt.switchJobs?.() ?? [])].find((job) => job.botId === botId);
|
|
534
|
+
const running = sessions.filter((sessionId) => live().get(sessionId)?.turn !== undefined);
|
|
535
|
+
if (approval !== undefined)
|
|
536
|
+
lines.push(`waiting for the user's approval to run ${approval.tool || "a tool"} ${place(approval.sessionId)} (${minutes(approval.at)})`);
|
|
537
|
+
else if (switching !== undefined)
|
|
538
|
+
lines.push(`moving on to a fresh part of its ${switching.roomId ? `conversation in group "${rt.roomOf(switching.roomId)?.name ?? "?"}"` : "own chat"}`);
|
|
539
|
+
for (const sessionId of approval === undefined && switching === undefined ? running : []) {
|
|
540
|
+
const entry = live().get(sessionId);
|
|
541
|
+
lines.push(`working ${place(sessionId)} ${minutes(entry?.since ?? Date.now())}${forWhom(sessionId, entry?.info)}${doing(entry)}`);
|
|
542
|
+
}
|
|
543
|
+
for (const { work, out } of held(busyWith(running))) {
|
|
544
|
+
if (work.to !== botId)
|
|
545
|
+
continue;
|
|
546
|
+
const forName = nameOf(work.from) ? `, for ${nameOf(work.from)}` : "";
|
|
547
|
+
const on = [
|
|
548
|
+
...new Set(out.map((child) => child.room
|
|
549
|
+
? `group "${rt.roomOf(child.room)?.name ?? "a group"}"`
|
|
550
|
+
: (child.name ?? nameOf(child.to) ?? "a teammate"))),
|
|
551
|
+
];
|
|
552
|
+
lines.push(on.length > 0
|
|
553
|
+
? `waiting for ${on.join(" and ")} to hand back work${forName}`
|
|
554
|
+
: `about to go on with work${forName}, whose results came back`);
|
|
555
|
+
}
|
|
556
|
+
if (lines.length === 0)
|
|
557
|
+
lines.push("idle");
|
|
558
|
+
if (state.questions[chat] !== undefined)
|
|
559
|
+
lines.push("a question card in its own chat waits for the user's answer");
|
|
560
|
+
if (state.views[chat] !== undefined)
|
|
561
|
+
lines.push("a view card in its own chat waits for the user's answer");
|
|
562
|
+
const unread = new Map();
|
|
563
|
+
for (const args of rt.unreadOf?.(chat) ?? []) {
|
|
564
|
+
const name = nameOf(args.fromId);
|
|
565
|
+
if (name !== undefined)
|
|
566
|
+
unread.set(name, (unread.get(name) ?? 0) + 1);
|
|
567
|
+
}
|
|
568
|
+
if (unread.size > 0)
|
|
569
|
+
lines.push(`not read yet: ${[...unread].map(([name, count]) => `${count} from ${name}`).join(", ")}`);
|
|
570
|
+
return lines.join("; ");
|
|
571
|
+
}
|
|
572
|
+
// The publish ledger as other modules read and settle it. Groups call
|
|
573
|
+
// `deliverWorkInRoom` when a Bot's line reaches the group log, which is where the
|
|
574
|
+
// user reads it.
|
|
575
|
+
const publishOf = (id) => (id === undefined ? undefined : duties()[id]);
|
|
576
|
+
const publishDuties = () => Object.values(duties());
|
|
577
|
+
const deliverWork = (id) => {
|
|
578
|
+
const done = deliver(id);
|
|
579
|
+
pruneDuties();
|
|
580
|
+
if (done)
|
|
581
|
+
save();
|
|
582
|
+
return done;
|
|
583
|
+
};
|
|
584
|
+
function deliverWorkInRoom(roomId, botId) {
|
|
585
|
+
let count = 0;
|
|
586
|
+
for (const duty of Object.values(duties())) {
|
|
587
|
+
if (duty.room !== roomId || duty.state === "delivered" || duty.state === "abandoned")
|
|
588
|
+
continue;
|
|
589
|
+
if (botId !== undefined && duty.by !== botId)
|
|
590
|
+
continue;
|
|
591
|
+
if (deliver(duty.id))
|
|
592
|
+
count += 1;
|
|
593
|
+
}
|
|
594
|
+
pruneDuties();
|
|
595
|
+
if (count > 0)
|
|
596
|
+
save();
|
|
597
|
+
return count;
|
|
598
|
+
}
|
|
599
|
+
Object.assign(rt, {
|
|
600
|
+
workOf,
|
|
601
|
+
openWork,
|
|
602
|
+
closeWork,
|
|
603
|
+
dropWork,
|
|
604
|
+
takenWork: taken,
|
|
605
|
+
publishOf,
|
|
606
|
+
publishDuties,
|
|
607
|
+
deliverWork,
|
|
608
|
+
deliverWorkInRoom,
|
|
609
|
+
lateWork,
|
|
610
|
+
answerMissed,
|
|
611
|
+
ownsWork: owns,
|
|
612
|
+
waitsWork: waits,
|
|
613
|
+
holdWork: hold,
|
|
614
|
+
workAsker: askedBy,
|
|
615
|
+
answerText,
|
|
616
|
+
upstreamOf,
|
|
617
|
+
sameWork,
|
|
618
|
+
forgetBotWork: forgetBot,
|
|
619
|
+
statusOf,
|
|
620
|
+
waitingView,
|
|
621
|
+
settleSubWork,
|
|
622
|
+
subWorkRun,
|
|
623
|
+
nameSubWork,
|
|
624
|
+
});
|
|
625
|
+
}
|