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,1021 @@
1
+ // Learning from work. After a long turn a Bot's chat is marked for review. When the
2
+ // team has been idle for a while, and at most every few hours, one background pass
3
+ // reads what was said since the last pass and asks whether it taught a skill worth
4
+ // keeping or a lasting project rule. Most work teaches nothing, so most passes propose
5
+ // nothing. A proposal waits as a card in the Bot's chat until approved, and declined
6
+ // changes are remembered so the same idea does not come back.
7
+ //
8
+ // its model-facing texts. A failed parse, a failed chat read or a failed approval keeps
9
+ // the work queued instead of advancing the cursor; an answered card stays in the chat
10
+ // part it was answered in. The review runs on memory.ts's one-shot model call
11
+ // (`rt.botShot`); without that slice the pass warns and leaves everything queued.
12
+ import { createHash, randomUUID } from "node:crypto";
13
+ import { readFileSync } from "node:fs";
14
+ import { access, mkdir, readFile, unlink, writeFile } from "node:fs/promises";
15
+ import { dirname, join } from "node:path";
16
+ import { listenGlobal } from "../core/events.js";
17
+ import { replaceFile } from "../core/files.js";
18
+ import { validateCapabilityName } from "./capabilities.js";
19
+ import { looksLikeKey, looksSecret, writtenIn } from "./memory.js";
20
+ export const LEARNING = {
21
+ minTurnMs: 3 * 60_000,
22
+ idleMs: 10 * 60_000,
23
+ intervalMs: 12 * 60 * 60_000,
24
+ tickMs: 60_000,
25
+ readMs: 15_000,
26
+ textMax: 24_000,
27
+ userTextMax: 8_000,
28
+ userLineMax: 2_000,
29
+ botLineMax: 1_200,
30
+ learnedMax: 5,
31
+ descriptionMax: 300,
32
+ contentMax: 8_000,
33
+ declinedMax: 20,
34
+ agentsFileMax: 16_000,
35
+ agentsEditMax: 400,
36
+ };
37
+ const clip = (text, max) => (text.length > max ? `${text.slice(0, max)} […]` : text);
38
+ export const hashOf = (text) => createHash("sha256")
39
+ .update(String(text ?? ""))
40
+ .digest("hex")
41
+ .slice(0, 16);
42
+ // The answer of the review's model: one JSON object, perhaps in a code fence.
43
+ export function parseJson(text) {
44
+ const raw = String(text ?? "").trim();
45
+ // A skill's content may hold code fences of its own, so read the whole answer first
46
+ // and fall back to a fenced block only when that fails.
47
+ const fenced = /^```(?:json)?\s*([\s\S]*?)```\s*$/.exec(raw)?.[1];
48
+ for (const candidate of [raw, fenced]) {
49
+ if (candidate === undefined)
50
+ continue;
51
+ const start = candidate.indexOf("{");
52
+ const end = candidate.lastIndexOf("}");
53
+ if (start === -1 || end < start)
54
+ continue;
55
+ try {
56
+ const value = JSON.parse(candidate.slice(start, end + 1));
57
+ if (value && typeof value === "object" && !Array.isArray(value))
58
+ return value;
59
+ }
60
+ catch {
61
+ // Try the next candidate.
62
+ }
63
+ }
64
+ return undefined;
65
+ }
66
+ // ---------------------------------------------------------------------------
67
+ // The skill review.
68
+ export function skillSystem() {
69
+ return [
70
+ "You review a stretch of finished work by an AI Bot and decide whether it taught a skill worth keeping.",
71
+ 'A skill is a short document that a future Bot loads when a task of the same class comes up. Strong models already do most tasks well without one, so a weak or narrow skill only adds noise. Most work teaches nothing new: answer "none" unless every test below passes.',
72
+ "",
73
+ "Tests:",
74
+ "1. Reusable: tasks of this class will come up again in later, different work. A rule that only fits this task, file, bug, or pull request fails.",
75
+ "2. Missing knowledge: it gives constraints or context that a capable model does not have or gets wrong: requirements and corrections the user stated, project or tool conventions found by trial and error, pitfalls that cost time, how this user wants the result checked or delivered. General know-how fails, and so does anything that reading the code or the docs shows at once.",
76
+ "3. Earned: the work shows it. The user asked for it or corrected the Bot, or a non-obvious way was found after one that failed. Never invent rules the work did not show.",
77
+ "4. Not covered: no listed skill already says it. When a learned skill of this Bot covers the class, update that skill instead of making a new one. Also update a learned skill that the work showed to be wrong, outdated, or missing a step.",
78
+ "",
79
+ "Never put in a skill: one-off facts, task progress, file lists, dates, version, pull request or issue numbers, pasted errors or logs, facts about the user that are not about how to work (memory keeps those), or keys, passwords, and tokens.",
80
+ 'Write rules that stand alone, not the story of this work: a reason says why the rule matters, never when or how it was broken ("the user corrected this before", "version 1.2 missed it"). Examples must be general: do not name the files, functions, values, or fixes that this work happened to touch, not even as an example, and write a placeholder such as `<file>:<line>` instead; name something only when the rule is about it, such as a check the user wants run.',
81
+ "",
82
+ "Form:",
83
+ '- name: the class of task, lowercase letters, digits, and single hyphens, 32 characters at most, e.g. "release-checks", never "fix-login-bug".',
84
+ "- description: one sentence, 200 characters at most, that says when to use the skill. Be concrete about the trigger.",
85
+ "- content: Markdown, 600 words at most. The rules as short imperative lines, each with a brief reason when it is not obvious; numbered steps only where order matters; how to check the work is done. Keep what is still true when you update, and remove what the work showed to be wrong.",
86
+ "",
87
+ "Answer with one JSON object only, one of:",
88
+ '{"action":"none","reason":"<one sentence>"}',
89
+ '{"action":"create","name":"…","description":"…","content":"…","why":"<one sentence: what in this work shows the skill is needed>"}',
90
+ '{"action":"update","name":"<a learned skill listed>","description":"…","content":"<the whole new content>","why":"<one sentence>"}',
91
+ ].join("\n");
92
+ }
93
+ // `learned`: this Bot's learned skills, in full. `library`: every other skill on the
94
+ // team, by name and description. `declined`: proposals the user turned down.
95
+ export function skillRequest({ bot, lines, learned = [], library = [], declined = [], language, limit = LEARNING.learnedMax, }) {
96
+ const parts = [`The Bot: ${bot.name}${bot.role ? `, ${bot.role}` : ""}.`];
97
+ parts.push(learned.length === 0
98
+ ? "This Bot has no learned skills yet."
99
+ : `This Bot's learned skills (${learned.length} of ${limit}${learned.length >= limit ? "; at the limit, only update one" : ""}):\n${learned
100
+ .map((skill) => `<skill name="${skill.name}">\ndescription: ${skill.description}\n${skill.content ?? ""}\n</skill>`)
101
+ .join("\n")}`);
102
+ if (library.length > 0)
103
+ parts.push(`Other skills on the team:\n${library.map((skill) => `- ${skill.name}: ${skill.description}`).join("\n")}`);
104
+ if (declined.length > 0)
105
+ parts.push(`Proposals the user declined. Do not propose them again unless this work shows something new:\n${declined
106
+ .map((entry) => `- ${entry.name}: ${entry.description}`)
107
+ .join("\n")}`);
108
+ if (language)
109
+ parts.push(`Write the description and the content in ${language}.`);
110
+ parts.push(`The work, oldest first ("User:" lines are the user's own words):\n<work>\n${lines.join("\n")}\n</work>`);
111
+ return parts.join("\n\n");
112
+ }
113
+ export function agentsSystem() {
114
+ return [
115
+ "You review a stretch of finished work that AI Bots did in one project folder, and decide whether the project's AGENTS.md should change.",
116
+ 'Every agent that works in this folder reads AGENTS.md at the start of every task. So each line costs attention on every task: a file that is short and current helps; a long or stale one hurts. Most work should change nothing: answer "none" unless a change passes every test below.',
117
+ "",
118
+ "What belongs in AGENTS.md:",
119
+ "- How the user wants agents to work here: requirements, rules, and corrections the user stated, especially ones the user had to repeat.",
120
+ "- How the user wants to be talked to: language, length, format, what to report.",
121
+ "- Things to always do and never do in this project, with a short reason when it is not obvious.",
122
+ "- Project facts an agent needs on almost every task and cannot see at once from the code: the command that checks the work, a folder that must not be touched, a step that must come before a commit or a push.",
123
+ "",
124
+ "Tests for each change:",
125
+ "1. Almost every task: the rule matters to most future work in this folder, not to one kind of task. A procedure for one kind of task belongs in a skill, not here.",
126
+ "2. Earned: the work shows it. The user said it, or repeated it, or corrected an agent, or the same problem came back. Never invent rules the work did not show.",
127
+ "3. Not obvious: a capable agent would not do it anyway, and reading the code or the docs does not show it at once.",
128
+ "4. Lasting: still true next month. Never add task progress, plans, dates, versions, pull request or issue numbers, file lists, pasted errors or logs, or keys, passwords, and tokens.",
129
+ "5. Not already said: if the file already says it, change nothing; if the file says it unclearly or partly, replace that line instead of adding one.",
130
+ "",
131
+ "Also remove or fix lines the work shows to be wrong or outdated, such as a rule the user reversed, and merge lines that say the same thing. Do not remove a line only because this work did not use it. Keep the user's own words and the file's language and style; do not rewrite lines that are fine.",
132
+ "",
133
+ "Answer with one JSON object only, one of:",
134
+ '{"action":"none","reason":"<one sentence>"}',
135
+ '{"action":"edit","edits":[<1 to 3 edits>]}',
136
+ "Each edit is one of:",
137
+ '{"op":"add","after":"<an exact whole line of the file to add below, or empty for the end>","text":"<the new line or lines>","why":"<one sentence: what in this work shows it>"}',
138
+ '{"op":"replace","old":"<exact text of the file, one or more whole lines>","text":"<the new text>","why":"…"}',
139
+ '{"op":"remove","old":"<exact text of the file, one or more whole lines>","why":"…"}',
140
+ 'Write new lines in the form the file already uses, such as "- " bullets under the right heading.',
141
+ ].join("\n");
142
+ }
143
+ // The folder, its AGENTS.md, declined edits, and work.
144
+ export function agentsRequest({ folder, current, lines, declined = [], language, }) {
145
+ const parts = [`Project folder: ${folder}`];
146
+ parts.push(current === undefined ? "There is no AGENTS.md yet." : `Current AGENTS.md:\n\`\`\`md\n${current}\n\`\`\``);
147
+ const turnedDown = declined
148
+ .filter((entry) => entry.kind === "agents" && entry.folder === folder)
149
+ .flatMap((entry) => entry.edits ?? [])
150
+ .map((edit) => `- ${edit.op}: ${clip(edit.text || edit.old || "", 200)}`);
151
+ if (turnedDown.length > 0)
152
+ parts.push(`Changes the user turned down before; do not propose them again:\n${turnedDown.join("\n")}`);
153
+ if (language)
154
+ parts.push(`The user writes in ${language}.`);
155
+ parts.push(`The work, oldest first:\n${lines.join("\n")}`);
156
+ return parts.join("\n\n");
157
+ }
158
+ // What keeps a proposal from the user, or undefined when it may be shown.
159
+ // Skills often talk about code such as `password == input`; read keys everywhere, but
160
+ // password wording only outside code.
161
+ const holdsSecret = (text) => looksLikeKey(text) || looksSecret(text.replace(/```[\s\S]*?```/g, " ").replace(/`[^`\n]*`/g, " "));
162
+ const textOf = (value) => (typeof value === "string" ? value : "");
163
+ const lineBlock = (value) => value.split(/\r?\n/);
164
+ const occurrences = (lines, block) => {
165
+ const found = [];
166
+ for (let at = 0; at <= lines.length - block.length; at += 1) {
167
+ if (block.every((line, index) => lines[at + index] === line))
168
+ found.push(at);
169
+ }
170
+ return found;
171
+ };
172
+ export function withoutReviewedAgentsRecords(records, reviewedOrders) {
173
+ const reviewed = new Set(reviewedOrders);
174
+ return records.filter((record) => !reviewed.has(record.order));
175
+ }
176
+ export function applyEdits(current, edits) {
177
+ let text = current ?? "";
178
+ let newline = text.includes("\r\n") ? "\r\n" : "\n";
179
+ let trailing = text.endsWith("\n");
180
+ let lines = text === "" ? [] : text.split(/\r?\n/);
181
+ if (trailing)
182
+ lines.pop();
183
+ for (const edit of edits) {
184
+ if (!["add", "replace", "remove"].includes(String(edit.op)))
185
+ throw new Error("unknown edit operation");
186
+ if (edit.op === "add") {
187
+ const added = lineBlock(textOf(edit.text));
188
+ const after = textOf(edit.after);
189
+ if (current === undefined && after !== "")
190
+ throw new Error("a new AGENTS.md can only add at the end");
191
+ if (!text) {
192
+ lines = added;
193
+ trailing = true;
194
+ }
195
+ else if (!after) {
196
+ lines.push(...added);
197
+ trailing = true;
198
+ }
199
+ else {
200
+ const positions = occurrences(lines, [after]);
201
+ if (positions.length !== 1)
202
+ throw new Error("the add anchor must occur once as a whole line");
203
+ const at = positions[0];
204
+ lines.splice(at + 1, 0, ...added);
205
+ }
206
+ }
207
+ else {
208
+ const old = lineBlock(textOf(edit.old));
209
+ if (current === undefined)
210
+ throw new Error("a new AGENTS.md can only add lines");
211
+ const positions = occurrences(lines, old);
212
+ if (positions.length !== 1)
213
+ throw new Error("the old text must occur once as whole lines");
214
+ const at = positions[0];
215
+ const replacement = edit.op === "replace" ? lineBlock(textOf(edit.text)) : [];
216
+ lines.splice(at, old.length, ...replacement);
217
+ }
218
+ text = lines.join(newline) + (trailing && lines.length > 0 ? newline : "");
219
+ newline = text.includes("\r\n") ? "\r\n" : "\n";
220
+ trailing = text.endsWith("\n");
221
+ lines = text === "" ? [] : text.split(/\r?\n/);
222
+ if (trailing)
223
+ lines.pop();
224
+ }
225
+ return text;
226
+ }
227
+ // The review's answer as a proposal. A single edit written as the answer itself, with
228
+ // its operation as the action, is that edit; a replacement with no text removes; lines
229
+ // one edit names that stand apart in the file, each once, are one edit each.
230
+ export function agentsAnswer(reply, current) {
231
+ let answer = parseJson(reply);
232
+ if (answer !== undefined && answer.action !== undefined && ["add", "replace", "remove"].includes(answer.action)) {
233
+ const { action, ...edit } = answer;
234
+ answer = { action: "edit", edits: [{ op: action, ...edit }] };
235
+ }
236
+ if (answer === undefined || !Array.isArray(answer.edits))
237
+ return answer;
238
+ const lines = lineBlock(current ?? "");
239
+ const apart = (edit) => {
240
+ if (edit?.op !== "replace" && edit?.op !== "remove")
241
+ return [edit];
242
+ const old = lineBlock(textOf(edit.old));
243
+ const text = lineBlock(textOf(edit.text));
244
+ if (old.length < 2 ||
245
+ occurrences(lines, old).length > 0 ||
246
+ old.some((line) => occurrences(lines, [line]).length !== 1))
247
+ return [edit];
248
+ if (edit.op === "replace" && text.length !== old.length)
249
+ return [edit];
250
+ return old.map((line, index) => ({
251
+ ...edit,
252
+ op: edit.op,
253
+ old: line,
254
+ ...(edit.op === "replace" ? { text: text[index] } : {}),
255
+ }));
256
+ };
257
+ const removes = (edit) => ({
258
+ op: "remove",
259
+ ...(edit.old !== undefined ? { old: edit.old } : {}),
260
+ ...(edit.after !== undefined ? { after: edit.after } : {}),
261
+ ...(edit.why !== undefined ? { why: edit.why } : {}),
262
+ });
263
+ const edits = answer.edits.map((edit) => edit?.op === "replace" && textOf(edit.text).trim() === "" ? removes(edit) : edit);
264
+ return { ...answer, edits: edits.flatMap(apart) };
265
+ }
266
+ export function agentsProblem(proposal, current) {
267
+ if (proposal === undefined)
268
+ return "the answer is not a JSON object";
269
+ if (proposal.action === "none")
270
+ return undefined;
271
+ if (proposal.action !== "edit")
272
+ return `unknown action ${String(proposal.action)}`;
273
+ if (!Array.isArray(proposal.edits) || proposal.edits.length < 1 || proposal.edits.length > 3)
274
+ return "an edit needs 1 to 3 changes";
275
+ const initial = current ?? "";
276
+ let result = initial;
277
+ for (const edit of proposal.edits) {
278
+ if (!edit || !["add", "replace", "remove"].includes(String(edit.op)))
279
+ return "unknown edit operation";
280
+ const text = textOf(edit.text);
281
+ const old = textOf(edit.old);
282
+ const after = textOf(edit.after);
283
+ const why = textOf(edit.why);
284
+ if (why.trim() === "")
285
+ return "each edit needs a reason";
286
+ if (edit.op !== "remove" && text === "")
287
+ return "an add or replacement needs text";
288
+ if (text.length > LEARNING.agentsEditMax)
289
+ return `edit text must have at most ${LEARNING.agentsEditMax} characters`;
290
+ if (holdsSecret(text) || holdsSecret(why))
291
+ return "it looks like it holds a secret";
292
+ if (/#\d{2,}|\b\d{4}-\d{2}-\d{2}\b|\bv\d+\.\d+/u.test(text))
293
+ return "the edit contains a task number, date, or version";
294
+ const lines = lineBlock(result);
295
+ if (edit.op === "add") {
296
+ if (typeof edit.after !== "string")
297
+ return "an add needs an anchor";
298
+ if (current === undefined && after !== "")
299
+ return "a new AGENTS.md can only add at the end";
300
+ if (after !== "" && (after.includes("\n") || occurrences(lines, [after]).length !== 1))
301
+ return "the add anchor must be one exact whole line";
302
+ }
303
+ else {
304
+ if (current === undefined)
305
+ return "a new AGENTS.md can only add lines";
306
+ if (old === "" ||
307
+ old.startsWith("\n") ||
308
+ old.endsWith("\n") ||
309
+ occurrences(lines, lineBlock(old)).length !== 1)
310
+ return "the old text must occur exactly once as whole lines";
311
+ }
312
+ result = applyEdits(result, [edit]);
313
+ }
314
+ if (result.length > Math.max(initial.length + 800, 4_000))
315
+ return "the resulting AGENTS.md is too large";
316
+ if (result === initial)
317
+ return "the edits change nothing";
318
+ return undefined;
319
+ }
320
+ export function skillProblem(proposal, { skills, learned, limit = LEARNING.learnedMax, }) {
321
+ if (proposal === undefined)
322
+ return "the answer is not a JSON object";
323
+ const { action } = proposal;
324
+ if (action === "none")
325
+ return undefined;
326
+ if (action !== "create" && action !== "update")
327
+ return `unknown action ${String(action)}`;
328
+ let name;
329
+ try {
330
+ name = validateCapabilityName(proposal.name);
331
+ }
332
+ catch (error) {
333
+ return error.message;
334
+ }
335
+ const description = typeof proposal.description === "string" ? proposal.description.trim() : "";
336
+ const content = typeof proposal.content === "string" ? proposal.content.trim() : "";
337
+ const why = typeof proposal.why === "string" ? proposal.why.trim() : "";
338
+ if (description === "" || description.length > LEARNING.descriptionMax)
339
+ return `the description must have 1 to ${LEARNING.descriptionMax} characters`;
340
+ if (content === "" || content.length > LEARNING.contentMax)
341
+ return `the content must have 1 to ${LEARNING.contentMax} characters`;
342
+ if (holdsSecret(description) || holdsSecret(content) || holdsSecret(why))
343
+ return "it looks like it holds a secret";
344
+ const existing = Object.hasOwn(skills, name) ? skills[name] : undefined;
345
+ if (action === "create") {
346
+ if (existing !== undefined)
347
+ return `${name} already exists`;
348
+ if (learned.length >= limit)
349
+ return `the Bot already keeps ${limit} learned skills`;
350
+ }
351
+ else {
352
+ if (existing === undefined || existing.kind !== "skill" || !learned.includes(name))
353
+ return `${name} is not a learned skill of this Bot`;
354
+ if (String(existing.content ?? "").trim() === content &&
355
+ String(existing.description ?? "").trim() === description)
356
+ return "the update changes nothing";
357
+ }
358
+ return undefined;
359
+ }
360
+ export function install(rt) {
361
+ const { ctx, config, home, state } = rt;
362
+ const settings = { ...LEARNING, ...(config.learningTimes ?? {}) };
363
+ const on = config.learning !== false && config.memory !== false;
364
+ const emptyView = () => ({});
365
+ if (!on) {
366
+ Object.assign(rt, {
367
+ learningView: emptyView,
368
+ learningSettledView: emptyView,
369
+ learningEndpoint: async () => {
370
+ throw new Error("Learning is turned off on this host");
371
+ },
372
+ learningRun: async () => 0,
373
+ });
374
+ return;
375
+ }
376
+ const learningConfig = config;
377
+ const root = join(home, "learning");
378
+ const path = join(root, "state.json");
379
+ // `pending`: Bot id → chats with a long turn since the last pass. `agentsPending`:
380
+ // folder → queued work waiting for AGENTS.md review. `cursors`: newest line read.
381
+ // `proposals`: waiting for the user, by id. `settled`: answered ones, by id, with the
382
+ // outcome; their cards stay in the chat part they were answered in. `learned`: skill
383
+ // names learned by Bot.
384
+ let saved;
385
+ const data = () => {
386
+ if (saved !== undefined)
387
+ return saved;
388
+ let parsed = {};
389
+ try {
390
+ parsed = JSON.parse(readFileSync(path, "utf8"));
391
+ }
392
+ catch (error) {
393
+ if (error?.code !== "ENOENT")
394
+ rt.warn("ocean-crew: unreadable learning state %s: %s", path, error?.message ?? error);
395
+ }
396
+ const store = parsed;
397
+ store.pending ??= {};
398
+ store.cursors ??= {};
399
+ store.proposals ??= {};
400
+ store.settled ??= {};
401
+ store.declined ??= [];
402
+ store.learned ??= {};
403
+ store.agentsPending ??= {};
404
+ saved = store;
405
+ return store;
406
+ };
407
+ let stopped = false;
408
+ let writing = Promise.resolve();
409
+ const persist = () => {
410
+ if (stopped)
411
+ return writing;
412
+ const text = JSON.stringify(data(), null, 1);
413
+ writing = writing
414
+ .then(async () => {
415
+ await mkdir(dirname(path), { recursive: true });
416
+ const temp = `${path}.${process.pid}.tmp`;
417
+ await writeFile(temp, text, "utf8");
418
+ await replaceFile(temp, path);
419
+ })
420
+ .catch((error) => rt.warn("ocean-crew: saving the learning state failed: %s", error?.message ?? error));
421
+ return writing;
422
+ };
423
+ // A turn in a Bot's own chat that ran long marks the chat for the next pass.
424
+ const started = new Map();
425
+ const noted = new Map();
426
+ let lastActivity = Date.now();
427
+ listenGlobal(ctx, "agent/status", ({ agent, status }) => {
428
+ if (stopped)
429
+ return;
430
+ const id = agent?.id;
431
+ if (id === undefined || !rt.isBotSession(id))
432
+ return;
433
+ lastActivity = Date.now();
434
+ if (status !== "idle") {
435
+ if (!started.has(id))
436
+ started.set(id, Date.now());
437
+ return;
438
+ }
439
+ const since = started.get(id);
440
+ started.delete(id);
441
+ const botId = rt.chatOwners.get(id);
442
+ if (since === undefined || botId === undefined || Date.now() - since < settings.minTurnMs)
443
+ return;
444
+ const pending = data().pending;
445
+ pending[botId] = [...new Set([...(pending[botId] ?? []), id])];
446
+ noted.set(id, (noted.get(id) ?? 0) + 1);
447
+ void persist();
448
+ });
449
+ const partLines = (sessionId, signal) => {
450
+ const reader = rt.partLines;
451
+ if (typeof reader !== "function")
452
+ throw new Error("The conversation record is not available on this host");
453
+ return reader(sessionId, signal, { pages: Number.POSITIVE_INFINITY });
454
+ };
455
+ // After the text budget, keep scanning to the cursor for user corrections.
456
+ async function linesSince(sessionId) {
457
+ const cursor = data().cursors[sessionId];
458
+ const kept = [];
459
+ const userLines = [];
460
+ const keptRecords = [];
461
+ const userRecords = [];
462
+ let used = 0;
463
+ let userUsed = 0;
464
+ let overBudget = false;
465
+ let through;
466
+ for await (const line of partLines(sessionId, AbortSignal.timeout(settings.readMs))) {
467
+ through ??= line.seq;
468
+ if (cursor !== undefined && line.seq !== undefined && line.seq <= cursor)
469
+ break;
470
+ const text = clip(line.text, line.who === "user" ? settings.userLineMax : settings.botLineMax);
471
+ if (!overBudget && used + text.length <= settings.textMax) {
472
+ kept.push(text);
473
+ keptRecords.push({ text, time: line.time });
474
+ used += text.length;
475
+ continue;
476
+ }
477
+ overBudget = true;
478
+ if (text.startsWith("User: ") && userUsed + text.length <= settings.userTextMax) {
479
+ userLines.push(text);
480
+ userRecords.push({ text, time: line.time });
481
+ userUsed += text.length;
482
+ }
483
+ }
484
+ return { lines: [...kept, ...userLines].reverse(), work: [...keptRecords, ...userRecords].reverse(), through };
485
+ }
486
+ const skillsOf = () => {
487
+ const found = {};
488
+ for (const [name, item] of Object.entries(state.capabilities?.items ?? {}))
489
+ if (item?.kind === "skill")
490
+ found[name] = item;
491
+ return found;
492
+ };
493
+ const learnedOf = (bot) => (data().learned[bot.id] ?? []).filter((name) => skillsOf()[name] !== undefined && (bot.capabilities ?? []).includes(name));
494
+ const waitingFor = (botId) => Object.values(data().proposals).filter((entry) => entry.botId === botId);
495
+ const redact = (text) => {
496
+ const service = rt.redactSecrets;
497
+ return typeof service === "function" ? service(text) : text;
498
+ };
499
+ const language = (lines) => {
500
+ const detected = writtenIn(lines.filter((line) => line.startsWith("User: ")).map((line) => line.slice(6)), rt.uiLanguage?.() ?? "");
501
+ return detected === "" ? undefined : detected;
502
+ };
503
+ async function botShot(bot, system, text) {
504
+ const shot = rt.botShot;
505
+ if (typeof shot !== "function")
506
+ throw new Error("The model review is not available on this host");
507
+ return shot(bot, system, text, { sessionId: rt.chatOf(bot.id), kind: "memory" });
508
+ }
509
+ async function reviewSkill(bot, lines) {
510
+ const skills = skillsOf();
511
+ const learned = learnedOf(bot);
512
+ const library = Object.values(skills)
513
+ .filter((item) => !learned.includes(item.name))
514
+ .map((item) => ({ name: item.name, description: String(item.description ?? "") }));
515
+ const text = skillRequest({
516
+ bot,
517
+ lines,
518
+ learned: learned.map((name) => skills[name]),
519
+ library,
520
+ declined: data().declined.filter((entry) => entry.kind !== "agents"),
521
+ language: language(lines),
522
+ limit: settings.learnedMax,
523
+ });
524
+ const reply = await botShot(bot, skillSystem(), text);
525
+ const proposal = parseJson(reply);
526
+ if (["description", "content", "why"].some((key) => {
527
+ const value = proposal?.[key];
528
+ return typeof value === "string" && redact(value) !== value;
529
+ })) {
530
+ rt.warn("ocean-crew: the skill review of %s proposed content containing a saved secret", bot.name);
531
+ return undefined;
532
+ }
533
+ const problem = skillProblem(proposal, { skills, learned, limit: settings.learnedMax });
534
+ if (problem !== undefined) {
535
+ rt.warn("ocean-crew: the skill review of %s proposed nothing usable: %s", bot.name, problem);
536
+ return undefined;
537
+ }
538
+ if (proposal === undefined || proposal.action === "none")
539
+ return undefined;
540
+ const name = proposal.name.trim();
541
+ const previous = proposal.action === "update" ? skills[name] : undefined;
542
+ return {
543
+ id: randomUUID(),
544
+ kind: "skill",
545
+ action: proposal.action,
546
+ botId: bot.id,
547
+ at: Date.now(),
548
+ name,
549
+ description: proposal.description.trim(),
550
+ content: proposal.content.trim(),
551
+ why: typeof proposal.why === "string" ? clip(proposal.why.trim(), 300) : "",
552
+ ...(previous !== undefined
553
+ ? {
554
+ previous: {
555
+ description: String(previous.description ?? ""),
556
+ content: String(previous.content ?? ""),
557
+ },
558
+ base: hashOf(`${previous.description}\n${previous.content}`),
559
+ }
560
+ : {}),
561
+ };
562
+ }
563
+ async function reviewAgents(bot, folder, current, lines) {
564
+ const request = agentsRequest({ folder, current, lines, declined: data().declined, language: language(lines) });
565
+ const reply = await botShot(bot, agentsSystem(), request);
566
+ const answer = agentsAnswer(reply, current);
567
+ if (answer === undefined || answer === null || typeof answer !== "object")
568
+ throw new Error("the answer is not a JSON object");
569
+ if (Array.isArray(answer.edits) &&
570
+ answer.edits.some((edit) => [edit?.text, edit?.why].some((value) => typeof value === "string" && redact(value) !== value))) {
571
+ rt.warn("ocean-crew: the AGENTS.md review of %s proposed content containing a saved secret", bot.name);
572
+ return undefined;
573
+ }
574
+ const problem = agentsProblem(answer, current);
575
+ if (problem !== undefined) {
576
+ if (answer?.action !== "none")
577
+ rt.warn("ocean-crew: the AGENTS.md review of %s proposed nothing usable: %s", bot.name, problem);
578
+ return undefined;
579
+ }
580
+ if (answer.action === "none" || !Array.isArray(answer.edits))
581
+ return undefined;
582
+ const content = applyEdits(current, answer.edits);
583
+ return {
584
+ id: randomUUID(),
585
+ kind: "agents",
586
+ botId: bot.id,
587
+ at: Date.now(),
588
+ folder,
589
+ path: join(folder, "AGENTS.md"),
590
+ base: hashOf(current ?? ""),
591
+ exists: current !== undefined,
592
+ edits: answer.edits.map((edit) => ({
593
+ op: edit.op,
594
+ ...(typeof edit.after === "string" ? { after: edit.after } : {}),
595
+ ...(typeof edit.old === "string" ? { old: edit.old } : {}),
596
+ ...(typeof edit.text === "string" ? { text: edit.text } : {}),
597
+ ...(typeof edit.why === "string" ? { why: edit.why } : {}),
598
+ })),
599
+ content,
600
+ };
601
+ }
602
+ const exists = async (target) => {
603
+ try {
604
+ await access(target);
605
+ return true;
606
+ }
607
+ catch {
608
+ return false;
609
+ }
610
+ };
611
+ async function agentsFile(folder) {
612
+ const target = join(folder, "AGENTS.md");
613
+ try {
614
+ const content = await readFile(target, "utf8");
615
+ return content.length <= LEARNING.agentsFileMax ? content : undefined;
616
+ }
617
+ catch (error) {
618
+ if (error?.code === "ENOENT")
619
+ return undefined;
620
+ throw error;
621
+ }
622
+ }
623
+ function queueAgentsWork(folder, bot, records) {
624
+ if (records.length === 0)
625
+ return;
626
+ if (data().agentsPending[folder] === undefined)
627
+ data().agentsPending[folder] = { botId: bot.id, records: [] };
628
+ const queue = data().agentsPending[folder];
629
+ if (rt.botOf(queue.botId) === undefined)
630
+ queue.botId = bot.id;
631
+ queue.records ??= [];
632
+ let order = queue.records.reduce((max, item) => Math.max(max, item.order ?? 0), -1) + 1;
633
+ for (const item of records)
634
+ queue.records.push({ botId: bot.id, text: item.text, time: item.time, order: order++ });
635
+ const max = settings.textMax + settings.userTextMax;
636
+ let size = queue.records.reduce((sum, item) => sum + item.text.length, 0);
637
+ const removed = new Set();
638
+ const oldest = [...queue.records].sort((a, b) => (a.time ?? 0) - (b.time ?? 0) || a.order - b.order);
639
+ for (const item of oldest) {
640
+ if (size <= max)
641
+ break;
642
+ if (item.text.startsWith("User: "))
643
+ continue;
644
+ removed.add(item);
645
+ size -= item.text.length;
646
+ }
647
+ for (const item of oldest) {
648
+ if (size <= max)
649
+ break;
650
+ if (!item.text.startsWith("User: "))
651
+ continue;
652
+ removed.add(item);
653
+ size -= item.text.length;
654
+ }
655
+ if (removed.size > 0)
656
+ queue.records = queue.records.filter((item) => !removed.has(item));
657
+ if (queue.records.length === 0)
658
+ delete data().agentsPending[folder];
659
+ }
660
+ // One pass over every Bot with a long turn since the last one. A Bot with a proposal
661
+ // still waiting is skipped: its chat is read again in the next pass.
662
+ let running;
663
+ async function run() {
664
+ if (stopped)
665
+ return 0;
666
+ if (running !== undefined)
667
+ return running;
668
+ running = (async () => {
669
+ const store = data();
670
+ store.lastRun = Date.now();
671
+ let made = 0;
672
+ for (const [botId, pendingSessions] of Object.entries(store.pending)) {
673
+ if (stopped)
674
+ break;
675
+ const bot = rt.botOf(botId);
676
+ if (bot === undefined) {
677
+ delete store.pending[botId];
678
+ continue;
679
+ }
680
+ if (waitingFor(botId).length > 0)
681
+ continue;
682
+ const sessions = [...pendingSessions];
683
+ const notedAtStart = new Map(sessions.map((sessionId) => [sessionId, noted.get(sessionId)]));
684
+ try {
685
+ const read = [];
686
+ for (const sessionId of sessions)
687
+ read.push({ sessionId, ...(await linesSince(sessionId)) });
688
+ if (stopped)
689
+ return made;
690
+ const lines = read.flatMap((entry) => entry.lines);
691
+ const proposal = lines.length === 0 ? undefined : await reviewSkill(bot, lines);
692
+ if (stopped)
693
+ return made;
694
+ if (proposal !== undefined) {
695
+ store.proposals[proposal.id] = proposal;
696
+ made += 1;
697
+ }
698
+ if (learningConfig.agentsMd !== false) {
699
+ for (const entry of read) {
700
+ try {
701
+ if (typeof rt.sessionFolder !== "function" || typeof rt.real !== "function")
702
+ continue;
703
+ const agent = (await rt.resolveAgent(entry.sessionId));
704
+ const reported = rt.sessionFolder(agent.session);
705
+ if (!reported)
706
+ continue;
707
+ const folder = await rt.real(reported);
708
+ try {
709
+ await rt.workFolder?.(folder);
710
+ }
711
+ catch {
712
+ continue;
713
+ }
714
+ if (!(await exists(join(folder, ".git"))) && !(await exists(join(folder, "AGENTS.md"))))
715
+ continue;
716
+ queueAgentsWork(folder, bot, entry.work ?? []);
717
+ }
718
+ catch (error) {
719
+ rt.warn("ocean-crew: the AGENTS.md folder lookup for %s failed: %s", entry.sessionId, error?.message ?? error);
720
+ }
721
+ }
722
+ }
723
+ for (const entry of read)
724
+ if (entry.through !== undefined)
725
+ store.cursors[entry.sessionId] = entry.through;
726
+ const remaining = (store.pending[botId] ?? []).filter((sessionId) => !notedAtStart.has(sessionId) || noted.get(sessionId) !== notedAtStart.get(sessionId));
727
+ if (remaining.length === 0)
728
+ delete store.pending[botId];
729
+ else
730
+ store.pending[botId] = remaining;
731
+ }
732
+ catch (error) {
733
+ rt.warn("ocean-crew: the learning review of %s failed: %s", bot.name, error?.message ?? error);
734
+ }
735
+ }
736
+ if (learningConfig.agentsMd !== false) {
737
+ for (const [folder, group] of Object.entries(store.agentsPending)) {
738
+ if (stopped)
739
+ break;
740
+ let bot = rt.botOf(group.botId);
741
+ if (bot === undefined) {
742
+ const contributor = group.records.find((item) => rt.botOf(item.botId) !== undefined);
743
+ if (contributor === undefined) {
744
+ delete store.agentsPending[folder];
745
+ continue;
746
+ }
747
+ group.botId = contributor.botId;
748
+ bot = rt.botOf(group.botId);
749
+ }
750
+ if (bot === undefined)
751
+ continue;
752
+ if (Object.values(store.proposals).some((entry) => entry.kind === "agents" && entry.folder === folder))
753
+ continue;
754
+ try {
755
+ const reviewRecords = [...group.records];
756
+ const reviewedOrders = reviewRecords.map((item) => item.order);
757
+ const current = await agentsFile(folder);
758
+ if (stopped)
759
+ return made;
760
+ if (current === undefined && (await exists(join(folder, "AGENTS.md"))))
761
+ continue;
762
+ const lines = [];
763
+ let used = 0;
764
+ let userUsed = 0;
765
+ let overBudget = false;
766
+ for (const item of reviewRecords.sort((a, b) => (a.time ?? 0) - (b.time ?? 0) || a.order - b.order)) {
767
+ if (!overBudget && used + item.text.length <= settings.textMax) {
768
+ lines.push(item.text);
769
+ used += item.text.length;
770
+ continue;
771
+ }
772
+ overBudget = true;
773
+ if (item.text.startsWith("User: ") && userUsed + item.text.length <= settings.userTextMax) {
774
+ lines.push(item.text);
775
+ userUsed += item.text.length;
776
+ }
777
+ }
778
+ if (lines.length === 0)
779
+ continue;
780
+ const proposal = await reviewAgents(bot, folder, current, lines);
781
+ if (stopped)
782
+ return made;
783
+ if (proposal !== undefined) {
784
+ store.proposals[proposal.id] = proposal;
785
+ made += 1;
786
+ }
787
+ const queue = store.agentsPending[folder];
788
+ if (queue !== undefined) {
789
+ queue.records = withoutReviewedAgentsRecords(queue.records ?? [], reviewedOrders);
790
+ if (queue.records.length === 0)
791
+ delete store.agentsPending[folder];
792
+ }
793
+ }
794
+ catch (error) {
795
+ rt.warn("ocean-crew: the AGENTS.md review of %s failed: %s", folder, error?.message ?? error);
796
+ }
797
+ }
798
+ }
799
+ await persist();
800
+ return made;
801
+ })().finally(() => {
802
+ running = undefined;
803
+ });
804
+ return running;
805
+ }
806
+ const due = () => {
807
+ if (stopped)
808
+ return false;
809
+ const store = data();
810
+ const hasPending = Object.values(store.pending).some((sessions) => sessions.length > 0) ||
811
+ (learningConfig.agentsMd !== false &&
812
+ Object.values(store.agentsPending).some((queue) => (queue.records?.length ?? 0) > 0));
813
+ if (!hasPending || rt.readOnly() !== undefined)
814
+ return false;
815
+ if (Object.keys(rt.workView?.() ?? {}).length > 0 || Date.now() - lastActivity < settings.idleMs)
816
+ return false;
817
+ return store.lastRun === undefined || Date.now() - store.lastRun >= settings.intervalMs;
818
+ };
819
+ ctx.effect(() => {
820
+ const timer = setInterval(() => {
821
+ if (due())
822
+ void run().catch((error) => rt.warn("ocean-crew: learning pass failed: %s", error?.message ?? error));
823
+ }, settings.tickMs);
824
+ timer.unref?.();
825
+ return () => {
826
+ stopped = true;
827
+ clearInterval(timer);
828
+ // A new host may take over after disposal; finish only writes already queued.
829
+ return writing;
830
+ };
831
+ }, "ocean-crew: learning");
832
+ // The user's answer to a card.
833
+ async function answer({ id, approve }) {
834
+ const store = data();
835
+ const proposal = Object.hasOwn(store.proposals, id) ? store.proposals[id] : undefined;
836
+ if (proposal === undefined)
837
+ throw Object.assign(new Error("This proposal is no longer open"), { code: "bot/stale" });
838
+ // The answered card stays where it was, read-only, with how it ended. A waiting card
839
+ // shows in the Bot's current chat part, so that is where it stays.
840
+ const settle = (outcome) => {
841
+ delete store.proposals[id];
842
+ const kept = { ...proposal };
843
+ delete kept.saved;
844
+ store.settled[id] = {
845
+ ...kept,
846
+ sessionId: rt.chatOf(proposal.botId),
847
+ outcome,
848
+ settledAt: Date.now(),
849
+ };
850
+ };
851
+ if (approve !== true) {
852
+ settle("declined");
853
+ const decline = proposal.kind === "agents"
854
+ ? {
855
+ kind: "agents",
856
+ folder: proposal.folder,
857
+ edits: proposal.edits.map(({ op, text, old }) => ({ op, text, old })),
858
+ at: Date.now(),
859
+ }
860
+ : { kind: "skill", name: proposal.name, description: proposal.description, at: Date.now() };
861
+ store.declined = [...store.declined, decline].slice(-settings.declinedMax);
862
+ await persist();
863
+ return { applied: false };
864
+ }
865
+ // A failed approval keeps the card, so the user can still decline it.
866
+ if (proposal.kind === "agents")
867
+ await applyAgents(proposal);
868
+ else
869
+ await applySkill(proposal);
870
+ settle("approved");
871
+ await persist();
872
+ return { applied: true };
873
+ }
874
+ const agentsApplying = new Map();
875
+ async function applyAgents(proposal) {
876
+ const target = proposal.path;
877
+ const previous = agentsApplying.get(target) ?? Promise.resolve();
878
+ let release = () => { };
879
+ const lock = new Promise((resolve) => {
880
+ release = resolve;
881
+ });
882
+ const queued = previous.catch(() => undefined).then(() => lock);
883
+ agentsApplying.set(target, queued);
884
+ await previous.catch(() => undefined);
885
+ try {
886
+ if (typeof rt.real !== "function" || typeof rt.workFolder !== "function")
887
+ throw new Error("The project folders are not available on this host");
888
+ const folder = await rt.real(proposal.folder);
889
+ await rt.workFolder(folder);
890
+ if (join(folder, "AGENTS.md") !== target)
891
+ throw new Error("AGENTS.md changed since this was proposed");
892
+ const readCurrent = async () => {
893
+ try {
894
+ return await readFile(target, "utf8");
895
+ }
896
+ catch (error) {
897
+ if (error?.code !== "ENOENT")
898
+ throw error;
899
+ return undefined;
900
+ }
901
+ };
902
+ const checkCurrent = (current) => {
903
+ const existsNow = current !== undefined;
904
+ if (existsNow !== proposal.exists || hashOf(current ?? "") !== proposal.base)
905
+ throw new Error("AGENTS.md changed since this was proposed");
906
+ };
907
+ checkCurrent(await readCurrent());
908
+ const temp = `${target}.${process.pid}.${randomUUID()}.tmp`;
909
+ try {
910
+ await writeFile(temp, proposal.content, "utf8");
911
+ checkCurrent(await readCurrent());
912
+ await replaceFile(temp, target);
913
+ }
914
+ finally {
915
+ await unlink(temp).catch(() => undefined);
916
+ }
917
+ }
918
+ finally {
919
+ release();
920
+ if (agentsApplying.get(target) === queued)
921
+ agentsApplying.delete(target);
922
+ }
923
+ }
924
+ // The capability list is capabilities.ts's; without that slice an approved proposal
925
+ // cannot be applied and the card stays open, so the user can still decline it.
926
+ const capability = (name) => {
927
+ const found = rt[name];
928
+ return typeof found === "function" ? found : undefined;
929
+ };
930
+ async function applySkill(proposal) {
931
+ const bot = rt.botOf(proposal.botId);
932
+ if (bot === undefined)
933
+ throw new Error("The Bot that learned this skill is gone");
934
+ const current = skillsOf()[proposal.name];
935
+ const store = data();
936
+ if (proposal.action === "create") {
937
+ if (proposal.saved) {
938
+ if (current === undefined ||
939
+ current.description !== proposal.description ||
940
+ current.content !== proposal.content)
941
+ throw new Error(`${proposal.name} changed since this was proposed`);
942
+ store.learned[bot.id] = [...new Set([...(store.learned[bot.id] ?? []), current.name])];
943
+ await persist();
944
+ }
945
+ else {
946
+ if (current !== undefined)
947
+ throw new Error(`A skill named ${proposal.name} exists now`);
948
+ const save = capability("capabilitySave");
949
+ if (save === undefined)
950
+ throw new Error("The capability library is not available on this host");
951
+ const item = await save({ name: proposal.name, kind: "skill", description: proposal.description, content: proposal.content }, { botId: bot.id, ui: false });
952
+ proposal.saved = true;
953
+ store.learned[bot.id] = [...new Set([...(store.learned[bot.id] ?? []), item.name])];
954
+ await persist();
955
+ }
956
+ }
957
+ else if (proposal.action === "update") {
958
+ if (current === undefined || hashOf(`${current.description}\n${current.content}`) !== proposal.base)
959
+ throw new Error(`${proposal.name} changed since this was proposed`);
960
+ const save = capability("capabilitySave");
961
+ if (save === undefined)
962
+ throw new Error("The capability library is not available on this host");
963
+ const item = await save({ name: proposal.name, kind: "skill", description: proposal.description, content: proposal.content }, { botId: bot.id, ui: false });
964
+ store.learned[bot.id] = [...new Set([...(store.learned[bot.id] ?? []), item.name])];
965
+ await persist();
966
+ }
967
+ if (!(bot.capabilities ?? []).includes(proposal.name)) {
968
+ const endpoint = capability("capabilityEndpoint");
969
+ if (endpoint === undefined)
970
+ throw new Error("The capability library is not available on this host");
971
+ await endpoint("bot-capability", { botId: bot.id, name: proposal.name, attached: true });
972
+ }
973
+ }
974
+ async function learningEndpoint(endpoint, body) {
975
+ if (endpoint === "learning-answer")
976
+ return answer({ id: String(body.id ?? ""), approve: body.approve === true });
977
+ throw new Error(`Unknown endpoint ${endpoint}`);
978
+ }
979
+ async function removeLearned(name) {
980
+ const learned = data().learned;
981
+ const deleted = String(name ?? "");
982
+ for (const [botId, names] of Object.entries(learned)) {
983
+ learned[botId] = (Array.isArray(names) ? names : []).filter((entry) => entry !== deleted);
984
+ }
985
+ await persist();
986
+ }
987
+ // Per Bot, its proposals waiting for the user.
988
+ const learningView = () => {
989
+ const byBot = {};
990
+ for (const entry of Object.values(data().proposals)) {
991
+ byBot[entry.botId] = [...(byBot[entry.botId] ?? []), entry];
992
+ }
993
+ return byBot;
994
+ };
995
+ // Per Bot, the answered proposals of its current chat part.
996
+ const learningSettledView = () => {
997
+ const byBot = {};
998
+ for (const entry of Object.values(data().settled)) {
999
+ if (rt.botOf(entry.botId) !== undefined && entry.sessionId === rt.chatOf(entry.botId))
1000
+ byBot[entry.botId] = [...(byBot[entry.botId] ?? []), entry];
1001
+ }
1002
+ return byBot;
1003
+ };
1004
+ // A skill removed through the capability library is no longer learned by any Bot.
1005
+ const capabilityDelete = rt.capabilityDelete;
1006
+ rt.capabilityDelete = async (...args) => {
1007
+ const result = typeof capabilityDelete === "function" ? await capabilityDelete(...args) : undefined;
1008
+ await removeLearned(args[0]);
1009
+ return result;
1010
+ };
1011
+ const capabilityEndpoint = rt.capabilityEndpoint;
1012
+ rt.capabilityEndpoint = async (endpoint, body, ...args) => {
1013
+ const result = typeof capabilityEndpoint === "function"
1014
+ ? await capabilityEndpoint(endpoint, body, ...args)
1015
+ : undefined;
1016
+ if (endpoint === "capability-delete")
1017
+ await removeLearned(body?.name);
1018
+ return result;
1019
+ };
1020
+ Object.assign(rt, { learningView, learningSettledView, learningEndpoint, learningRun: run });
1021
+ }