electron/cloud-send.mjs
Rama main · réplica de lectura del repositorio original.
// Send to My Cloud: this computer's bots, their skills, logins, saved secrets, 1Password, settings and chats go to the person's own
// Cloud. One direction; added and updated, never deleted; one action for every entry point. Chats go by row
// (server/cloud-send-chats.ts): each conversation's rows after the last one My Cloud took, so its history goes once. A
// Cloud too old for that gets Bring newer chats' copy, only from a send the person asked for.
//
// The move ("move") is the same send run to carry everything this computer has: its apps and keys first
// (cloud-connections.mjs), then every bot and its chats whatever was once kept here, over an owner session that counts
// as someone using My Cloud so it stays awake; a sign-in that refreshes itself goes only in the final pass, and this
// computer lets go of its own copy once My Cloud has it. `check` then reads both sides to say what did not arrive
// (cloud-send-check.mjs).
//
// What each side has comes from both servers' GET /api/cloud-send; what goes is decided part by part (planSend in
// cloud-send-parts.mjs) and recorded item by item, so a send cut short goes on from the item it reached. Every write is
// one of My Cloud's own routes, over one owner session per send: the Admin's one-time code for the Cloud it verified,
// redeemed at that Cloud alone, signed out after, as Copy does. A secret reaches main only over the server's private
// port (server/login-handoff.ts) and goes only to that session.
import { createHash } from "node:crypto";
import { AUTOMATION_KINDS, BOT_PARTS, EMPTY, MOVE_BOT_PARTS, MOVE_KINDS, PICTURE_PART, differenceSignature, failedSummary, itemKey, parseSendSummary, pendingRows, planSend, resultKey } from "./cloud-send-parts.mjs";
import { collectConnections, releaseConnections, sendConnections } from "./cloud-connections.mjs";
import { createMoveKinds, mergeRoutines } from "./cloud-send-kinds.mjs";
import { botsOnCloud } from "./cloud-logins.mjs";
import { moveCheck, patientSession, rowIdChunks, unanswered } from "./cloud-send-check.mjs";
import nativeText from "./native-text.cjs";
const LOCAL_ROUTE = /^\/api\/(?:cloud-send|cloud-send\?move=1|cloud-send\/manifest|cloud-send\/chats|cloud-send\/chats\?attachments=1|cloud-send\/kind\?key=[\w%.-]{1,300}|cloud-send\/memory|cloud-send\/memory\?bot=[\w-]{1,128}|cloud-send\/files|cloud-send\/files\?path=[\w%.()~!*'-]{1,12000}&offset=\d{1,16}&length=\d{1,9}|cloud-send\/chats\/rows\?thread=[\w-]{1,200}&after=\d{1,16}&id=[\w%.-]{0,600}|cloud-send\/chats\/ids\?thread=[\w-]{1,200}&from=\d{1,9}|cloud-send\/chats\/attachment\/[A-Za-z0-9-]{1,80}\.[A-Za-z0-9]{1,10}|cloud-connections|connectors\/connected|config|bots\?messages=0|bots\/[\w-]{1,128}\/(?:skills|skills\/[a-z0-9-]{1,64}|memory|memory\/file\?path=[\w%.-]{1,200})|attachments\/[A-Za-z0-9-]{1,80}\.(?:png|jpg|gif|webp))$/u;
/** One request's chats: a few conversations' rows, well under My Cloud's body limit. */
const CHATS_BATCH_BYTES = 4 * 1024 * 1024;
const CHATS_BATCH_PAGES = 40;
/** My Cloud checks at most this many attachment names a request. */
const ATTACHMENT_NAMES = 2000;
/** My Cloud takes at most this many row ids, of at most this many conversations, a request. */
const ROW_IDS = 10_000;
const ROW_ID_CONVERSATIONS = 200;
const MEMORY_FILES = 100;
const SCOPED_READ = /^\/api\/cloud-send(?:\?move=1|\/manifest|\/chats(?:\?attachments=1)?|\/files)$/u;
const PICTURE_TYPES = { png: "image/png", jpg: "image/jpeg", gif: "image/gif", webp: "image/webp" };
const VAULT_ID = /^[\w-]{1,128}$/u;
/** Whole sends, which carry the chats too; a send of some items (Use this computer's, one login) never does. */
const CHAT_REASONS = new Set(["button", "my-cloud", "menu", "move"]);
/** Bring newer chats' copy of the whole workspace, for a Cloud too old to take chats by row: never a move, which such a
* Cloud refuses. */
const OUTDATED_FOR_MOVE = "My Cloud is updating. Your bots move there by themselves when it's done.";
const record = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
const sentence = (value) => (typeof value === "string" && value.trim() ? value.trim().replace(/\s+/g, " ").slice(0, 300) : null);
export class CloudSendError extends Error {
constructor(code, message, options) { super(message, options); this.name = "CloudSendError"; this.code = code; }
}
export { unanswered };
/** Security: only a `serverError.*` key passes, so a server cannot make the page show any other catalog string. */
export function namedRefusal(answer) {
if (answer.status !== 401 && answer.status !== 403) return null;
const key = answer.body?.errorKey, message = sentence(answer.body?.error);
if (typeof key !== "string" || !key.startsWith("serverError.") || !message) return null;
const params = record(answer.body.errorParams) ? Object.fromEntries(Object.entries(answer.body.errorParams).filter(([, value]) => typeof value === "string" || typeof value === "number")) : {};
return { message, errorKey: key, errorParams: params };
}
/** One plain sentence for a failed write, from what My Cloud answered. */
function refusal(answer, fallback) {
if (answer.status === 401 || answer.status === 403) return namedRefusal(answer)?.message ?? "My Cloud did not let this app make the change. Open My Cloud in this app once, then try again.";
if (answer.status === 409 && /limited to \d+ bots/u.test(answer.body?.error ?? "")) return sentence(answer.body.error);
return sentence(answer.body?.error) ? `My Cloud said: ${sentence(answer.body.error)}` : fallback;
}
function refusedError(code, answer, fallback) {
const named = namedRefusal(answer);
return Object.assign(new CloudSendError(code, refusal(answer, fallback)), named ? { errorKey: named.errorKey, errorParams: named.errorParams } : {});
}
/** My Cloud's own 1Password refusal speaks to someone pasting a token on My Cloud's page; here it was this computer's.
* 1Password refusing the token is final: sending it again can't help until 1Password is connected again here. */
function onePasswordRefusal(answer) {
const said = sentence(answer.body?.error);
if (answer.status === 400 && said && /did not accept the token|not a whole 1Password|service account token/iu.test(said)) {
return Object.assign(new CloudSendError("refused", "My Cloud's 1Password did not accept this computer's token: it may have been revoked, expired, or lost access to its vault. Connect 1Password again in this computer's Settings → Logins, and it goes to My Cloud by itself."), { final: true });
}
return refusedError("refused", answer, "My Cloud could not connect 1Password.");
}
const pick = (source, names) => Object.fromEntries(names.filter((name) => source[name] !== undefined && source[name] !== null).map((name) => [name, source[name]]));
/** A bot's fields for some parts, as My Cloud's PATCH takes them: never Custom approval, which only this computer's own
* window can grant. */
export function botPatch(bot, parts) {
const body = {};
for (const part of parts) {
const names = BOT_PARTS[part];
if (names) Object.assign(body, pick(bot, names));
}
if (body.approvalMode === "custom") delete body.approvalMode;
if (record(body.modelSelection)) body.modelSelection = pick(body.modelSelection, ["instanceId", "model", "effort", "variant"]);
if (parts.includes("model") && record(body.modelSelection)) body.requireAvailableModel = true;
return body;
}
const outcomeOf = (value) => record(value) && Number.isSafeInteger(value.at) && record(value.result) ? value : null;
const COUNTED = ["bots", "updatedBots", "skills", "logins", "settings"];
/** What reached My Cloud from this computer, all sends together. */
function countsOf(value) {
const chats = record(value?.chats) ? value.chats : {};
const whole = (count) => Number.isSafeInteger(count) && count > 0 ? count : 0;
return { ...Object.fromEntries(COUNTED.map((key) => [key, whole(value?.[key])])), onePassword: value?.onePassword === true,
chats: { conversations: whole(chats.conversations), messages: whole(chats.messages) } };
}
const addCounts = (total, sent) => countsOf({ ...Object.fromEntries(COUNTED.map((key) => [key, total[key] + sent[key]])), onePassword: total.onePassword || sent.onePassword,
chats: { conversations: total.chats.conversations + (sent.chats?.conversations ?? 0), messages: total.chats.messages + (sent.chats?.messages ?? 0) } });
/** The state main keeps in its own folder: per Cloud what was sent, what the person kept here, the bots a send made
* there whose fields still wait to be recorded (`created`), the bots a send asked My Cloud to make and has not heard back
* about (`creating`), the sign-ins My Cloud took that this computer still holds
* (`release`), the bot files a move put there (`files`, path to sha256), whether each automation a move carried was
* on here (`on`), the conversations a move's check found whole (`verified`: this computer's newest row and row count
* then, and how many rows My Cloud's manifest said it had), and those whose earlier rows My Cloud made its owner's
* (`stamped`). */
export function parseSendState(value) {
const clouds = {};
if (record(value?.clouds)) {
for (const [origin, entry] of Object.entries(value.clouds)) {
if (!/^https?:\/\/[^/\s]+$/u.test(origin) || !record(entry)) continue;
clouds[origin] = {
sent: record(entry.sent) ? entry.sent : {},
off: record(entry.off) ? Object.fromEntries(Object.entries(entry.off).filter(([, why]) => why === "you" || why === "removed")) : {},
dismissed: typeof entry.dismissed === "string" ? entry.dismissed : "",
last: outcomeOf(entry.last),
whole: outcomeOf(entry.whole),
arrived: countsOf(entry.arrived),
chats: record(entry.chats) ? Object.fromEntries(Object.entries(entry.chats).filter(([threadId, cursor]) => /^[\w-]{1,200}$/u.test(threadId)
&& (cursor === "removed" || (record(cursor) && Number.isSafeInteger(cursor.after) && typeof cursor.id === "string")))) : {},
created: record(entry.created) ? Object.fromEntries(Object.entries(entry.created).filter(([key, on]) => key.startsWith("bot:") && on === true)) : {},
creating: record(entry.creating) ? Object.fromEntries(Object.entries(entry.creating).filter(([key, on]) => key.startsWith("bot:") && on === true)) : {},
release: Array.isArray(entry.release) ? entry.release.filter((held) => record(held) && typeof held.id === "string" && typeof held.digest === "string").slice(0, 200) : [],
files: record(entry.files) ? Object.fromEntries(Object.entries(entry.files).filter(([path, sha]) => typeof sha === "string" && /^[a-f0-9]{64}$/u.test(sha) && path.length <= 4096)) : {},
on: record(entry.on) ? Object.fromEntries(Object.entries(entry.on).filter(([key, on]) => /^(?:routine|call|webhook|app-event):[\w-]{1,128}$/u.test(key) && typeof on === "boolean")) : {},
verified: record(entry.verified) ? Object.fromEntries(Object.entries(entry.verified).filter(([threadId, seen]) => /^[\w-]{1,200}$/u.test(threadId) && record(seen)
&& typeof seen.newest === "string" && Number.isSafeInteger(seen.rows) && Number.isSafeInteger(seen.there))) : {},
stamped: record(entry.stamped) ? Object.fromEntries(Object.entries(entry.stamped).filter(([threadId, on]) => /^[\w-]{1,200}$/u.test(threadId) && on === true)) : {},
};
}
}
return { v: 1, clouds };
}
/** What My Cloud's own page is told: the names of this computer's bots that wait, and counts. */
export function sendNotice(snapshot) {
const rows = pendingRows({ rows: snapshot.rows ?? [] });
const count = (kind) => rows.filter((row) => row.kind === kind).length;
return {
ready: snapshot.cloud === "ready",
bots: rows.filter((row) => row.kind === "bot" && row.state === "only-here").map((row) => row.label),
updatedBots: rows.filter((row) => row.kind === "bot" && row.state === "to-update").map((row) => row.label),
skills: count("skill"), logins: count("login"), onePassword: count("1password") > 0, settings: count("setting"),
signature: snapshot.signature ?? "",
dismissed: Boolean(snapshot.signature) && snapshot.signature === snapshot.dismissed,
running: Boolean(snapshot.running),
...(snapshot.running ? { runningReason: snapshot.running.reason, ...(snapshot.running.progress ? { progress: snapshot.running.progress } : {}),
...(snapshot.running.phase ? { phase: snapshot.running.phase } : {}) } : {}),
last: snapshot.last ? told(snapshot.last, snapshot.dismissed) : null,
whole: snapshot.whole ? told(snapshot.whole, snapshot.dismissed) : null,
arrived: countsOf(snapshot.arrived),
};
}
/** A send's outcome as My Cloud's page is told it: counts and reasons only. */
function told(outcome, dismissed) {
return { at: outcome.at, reason: outcome.reason, failed: outcome.result.failed.length, sent: sentCounts(outcome.result),
// Every failure is one trying again can't fix (1Password refused the token): no Try again.
final: !outcome.error && outcome.result.failed.length > 0 && outcome.result.failed.every((entry) => entry.final === true),
seen: dismissed === resultKey(outcome), ...failedSummary(outcome) };
}
/** What a send got across, in counts only: My Cloud's page names nothing of this computer's but its bots. */
function sentCounts(result) {
return { bots: result.bots.length, updatedBots: result.updatedBots.length, skills: result.skills, logins: result.logins, ...(result.secrets ? { secrets: result.secrets } : {}), onePassword: result.onePassword,
settings: result.settings.length, chats: result.chats ? { conversations: result.chats.conversations, messages: result.chats.messages } : null };
}
/** What a send did, in counts and the names a person recognises. `connections`: apps and keys a move carried. */
function emptyResult() {
return { bots: [], updatedBots: [], skills: 0, logins: 0, onePassword: false, settings: [], connections: 0, chats: null, failed: [], notes: [] };
}
/** What a move carried beyond a send: automations and skill extras, memory merged, files, conversations' settings. */
const movedResult = () => ({ moved: 0, memory: 0, files: { sent: 0, kept: 0 }, conversationSettings: 0 });
/**
* @param {{
* local: (route: string) => Promise<Response>,
* cloud: {
* origin(): string | null,
* asleep(): boolean,
* read(route: string): Promise<Response>,
* open(options?: { move?: boolean }): Promise<{ call(method: string, route: string, body?: unknown): Promise<{ status: number, ok: boolean, body: any }>,
* upload(route: string, bytes: Uint8Array, type: string): Promise<{ status: number, ok: boolean, body: any }>, close(): Promise<void> }>,
* },
* handoff: (what: { kind: string, [key: string]: unknown }) => Promise<any>,
* store: { read(): unknown, write(value: unknown): void },
* onChange?: (snapshot: unknown) => void,
* now?: () => number,
* engineWaitMs?: number,
* space?: { disk(): unknown, grow(sizeGb: number): Promise<unknown> },
* team?: { bot: string, name: string, label: string },
* patience?: { stallMs?: number, waits?: number[], blips?: number[] },
* }} deps `patience`: how long a move waits for a Cloud that does not answer (cloud-send-check.mjs). `open({ move: true })`: the move's session, which counts as someone using My Cloud. `engineWaitMs`: how long a
* move waits for My Cloud to run the engines whose keys it just took. `local(route, { method })`: a change on this
* computer's server (an app event My Cloud now collects). `space`: My Cloud's plan disk and the Admin's grow. `team`: a
* move of this one bot into a team (team-move.mjs): only the bot, its skills, files, chats and routines go, under the
* name the team gave it; never apps, keys, logins, settings or its notes about the person. `local` is then the bot's
* own Cloud, and `label` names the person beside a file or bot of the same name.
*/
export function createCloudSend({ local, cloud, handoff, store, onChange = () => {}, now = Date.now, sleep, patience = {}, engineWaitMs, space, team = null, t = nativeText.englishText.t }) {
let saved = parseSendState(safeRead(store));
let view = { cloud: "none", plan: null, local: null, theirs: null, chats: null };
let running = null, progress = null, checking = null, localScope = null;
const only = team?.bot ?? null;
/** A route as a move of one bot asks it of either side: kept to that bot. */
const scoped = (route) => (only ? `${route}${route.includes("?") ? "&" : "?"}bot=${only}` : route);
function safeRead(source) { try { return source.read(); } catch { return null; } }
function persist() { try { store.write(saved); } catch {} }
const entry = (origin) => (saved.clouds[origin] ??= { sent: {}, off: {}, dismissed: "", last: null, whole: null, arrived: countsOf(null), chats: {}, created: {}, creating: {}, release: [], files: {}, on: {},
verified: {}, stamped: {} });
/** What a move ignores of what the person kept here: everything goes but what was removed on My Cloud. */
const removedOnly = (off) => Object.fromEntries(Object.entries(off).filter(([, why]) => why === "removed"));
const connectionLocal = { json: (route) => localJson(route), handoff: (what) => handoff(what) };
function snapshot() {
const origin = cloud.origin();
const mine = origin ? entry(origin) : null;
const plan = view.plan;
return {
cloud: origin ? view.cloud : "none",
running: running ? { reason: running.reason, ...(progress ? { progress } : {}), ...(running.chats ? { phase: "chats" } : {}) } : null,
rows: plan?.rows ?? [],
cloudOnly: plan?.cloudOnly ?? [],
pending: plan ? pendingRows(plan).length : 0,
chats: view.chats ?? null,
signature: plan && pendingRows(plan).length ? createHash("sha256").update(differenceSignature(plan)).digest("hex").slice(0, 32) : "",
dismissed: mine?.dismissed ?? "",
last: mine?.last ?? null,
whole: mine?.whole ?? null,
arrived: mine?.arrived ?? countsOf(null),
cloudVersion: view.theirs?.appVersion ?? null,
localVersion: view.local?.appVersion ?? null,
checking,
};
}
const publish = () => { const value = snapshot(); try { onChange(value); } catch {} return value; };
const allowed = (route) => LOCAL_ROUTE.test(route.replace(/[?&]bot=[\w-]{1,128}$/u, ""));
/** What a move of some bots reads of this computer's server names them. */
const chosenOnly = (route) => (localScope && SCOPED_READ.test(route) ? `${route}${route.includes("?") ? "&" : "?"}scope=${localScope}` : route);
async function localJson(route, options) {
if (!allowed(route)) throw new CloudSendError("invalid", "Unsupported local request.");
const response = await local(chosenOnly(route), options);
if (!response.ok) throw new CloudSendError("local", "OpenMausBot on this computer did not answer. Try again in a moment.");
return response.json();
}
async function localBytes(route) {
if (!allowed(route)) throw new CloudSendError("invalid", "Unsupported local request.");
const response = await local(route);
if (!response.ok) return null;
return new Uint8Array(await response.arrayBuffer());
}
async function localSend(method, route) {
if (!LOCAL_ROUTE.test(route) || method !== "DELETE") throw new CloudSendError("invalid", "Unsupported local request.");
const response = await local(route, { method });
await response.body?.cancel();
return { ok: response.ok, status: response.status };
}
const kinds = createMoveKinds({ localJson, localBytes, localSend, entry, persist, refusal, space, scoped,
progress: (value) => { progress = value; publish(); } });
async function readCloud() {
let response;
try { response = await cloud.read("/api/cloud-send"); } catch { return { state: cloud.asleep() ? "asleep" : "unreachable" }; }
if (response.status === 404) return { state: "outdated" };
if (!response.ok) return { state: response.status === 401 || response.status === 403 ? "signed-out" : "unreachable" };
const summary = parseSendSummary(await response.json().catch(() => null));
return summary ? { state: "ready", summary } : { state: "unreachable" };
}
/** `everything`: a move's plan, which goes past what the person once kept here. A bot a send made on My Cloud whose
* fields were not recorded yet has them all sent again. */
function plan(origin, mine, theirs, force = [], { everything = false } = {}) {
const kept = entry(origin);
// A Cloud too old to keep a secret by its id would make a new one every send.
const going = theirs?.vault ? mine : { ...mine, items: (mine?.items ?? []).filter((item) => item.kind !== "secret") };
const planned = planSend({ local: going, cloud: theirs, sent: kept.sent, off: everything ? removedOnly(kept.off) : kept.off, force: [...force, ...Object.keys(kept.created)] });
if (everything && !team) mergeRoutines(planned.rows);
let changed = false;
for (const [key, parts] of Object.entries(planned.baseline)) { kept.sent[key] = { ...kept.sent[key], ...parts }; changed = true; }
for (const key of planned.removed) { kept.off[key] = "removed"; delete kept.sent[key]; delete kept.created[key]; changed = true; }
if (everything) {
// A move has nobody to ask: a part both sides hold otherwise is My Cloud's (§2.2), and recorded as settled, so the
// move's check (manifestGaps) agrees with this plan instead of calling it missing on every pass.
const here = new Map((mine?.items ?? []).map((item) => [item.key, item])), there = new Map((theirs?.items ?? []).map((item) => [item.key, item]));
for (const row of planned.rows) {
// A routine's runs are history My Cloud adds by id and never drops: when they are all that differs, it goes.
if (row.kind === "routine" && row.differs?.includes("runs") && !row.differs.includes("def")) {
row.differs = row.differs.filter((part) => part !== "runs");
row.parts = [...(row.parts ?? []), "runs"];
row.state = "to-update";
if (!row.differs.length) delete row.differs;
}
for (const part of row.differs ?? []) {
kept.sent[row.key] = { ...kept.sent[row.key], [part]: [here.get(row.key).parts[part], there.get(row.key)?.parts[part] ?? EMPTY] };
changed = true;
}
}
}
if (changed) persist();
return planned;
}
/** The conversations still to go, for Settings and the page; what was known before when this computer did not answer. */
const chatsLeft = (origin, theirs) => theirs?.chats
? chatsWaiting(origin, theirs).then(({ ready, busy }) => ({ waiting: ready.length + busy })).catch(() => view.chats ?? null)
: Promise.resolve(null);
/** Reads both sides and plans; never writes to My Cloud. */
async function compare() {
const origin = cloud.origin();
if (!origin) { view = { cloud: "none", plan: null, local: null, theirs: null, chats: null }; return publish(); }
if (cloud.asleep()) { view = { ...view, cloud: "asleep" }; return publish(); }
const [mine, theirs] = await Promise.all([localJson("/api/cloud-send").then(parseSendSummary).catch(() => null), readCloud()]);
if (!mine) { view = { ...view, cloud: "local-unavailable" }; return publish(); }
if (theirs.state !== "ready") { view = { cloud: theirs.state, plan: null, local: mine, theirs: null, chats: null }; return publish(); }
view = { cloud: "ready", plan: plan(origin, mine, theirs.summary), local: mine, theirs: theirs.summary, chats: await chatsLeft(origin, theirs.summary) };
return publish();
}
/** One send at a time; one asked while another runs waits for it, then runs as it was asked. A move (`reason: "move"`)
* carries everything; its final pass (`final`) carries the sign-ins that refresh themselves too, and this computer lets
* go of each once My Cloud has it. */
async function send({ keys = null, force = [], reason = "button", final = false, scope = null } = {}) {
while (running) await running.promise.catch(() => {});
const origin = cloud.origin();
if (!origin) throw new CloudSendError("no_cloud", "This computer has no My Cloud yet.");
const job = { reason, final: reason === "move" && final === true, scope, promise: null, chats: false };
running = job;
progress = null;
localScope = scope;
publish();
job.promise = (async () => {
try { return await sendTo(origin, job, { keys, force: Array.isArray(force) ? force.filter((key) => typeof key === "string") : [], reason }); }
catch (error) {
// Kept as this send's outcome, so the strip and Settings say why, instead of an earlier send's result.
const kept = entry(origin);
kept.last = { at: now(), reason, result: emptyResult(), error: sentence(error?.message) ?? "Nothing could be sent to My Cloud. Try again." };
if (!keys && CHAT_REASONS.has(reason)) kept.whole = kept.last;
persist();
if (unanswered(error)) view = { ...view, cloud: "unreachable" };
throw error;
}
finally { running = null; progress = null; localScope = null; publish(); }
})();
return job.promise;
}
async function sendTo(origin, job, { keys, force, reason }) {
const result = { ...emptyResult(), ...(reason === "move" ? movedResult() : {}) };
const mine = parseSendSummary(await localJson(reason === "move" ? scoped("/api/cloud-send?move=1") : "/api/cloud-send"));
if (!mine) throw new CloudSendError("local", "OpenMausBot on this computer did not answer. Try again in a moment.");
const withChats = !keys && CHAT_REASONS.has(reason);
const latest = await sendItems(origin, mine, result, { keys, force, job, withChats });
const outcome = { at: now(), reason, result };
const kept = entry(origin);
kept.last = outcome;
if (withChats) kept.whole = outcome;
kept.arrived = addCounts(kept.arrived, sentCounts(result));
persist();
view = { cloud: "ready", plan: plan(origin, mine, latest), local: mine, theirs: latest, chats: await chatsLeft(origin, latest) };
return outcome;
}
/** A move goes only to a Cloud that says it takes one: an older one would lose the owner's lines and the checks. */
async function assertTakesMove(session) {
const described = await session.call("GET", "/.well-known/openmausbot/environment");
if (described.status >= 500) throw Object.assign(new CloudSendError("unreachable", "My Cloud did not answer. Try again."), { status: described.status });
if (!(Number(described.body?.capabilities?.move) >= 1)) throw new CloudSendError("outdated", OUTDATED_FOR_MOVE);
}
/** Every item that waits, and then the chats, over one owner session, always signed out; what My Cloud holds after. A
* move's apps and keys go first, so the bots that follow find their engines; then the skills only a move carries, the
* bots, their memory and files, the chats with each conversation's settings, and last the automations, switched off. An
* app event goes only in the final pass, when this computer lets go of it. */
async function sendItems(origin, mine, result, { keys, force, job, withChats }) {
const move = job.reason === "move";
const summaryRoute = move ? scoped("/api/cloud-send?move=1") : "/api/cloud-send";
const opened = await cloud.open(move ? { move: true } : undefined).catch((error) => { throw new CloudSendError("session", sentence(error?.message) ?? "This app could not sign in to My Cloud. Try again.", { cause: error }); });
const session = move && !team ? patientSession(opened, { ...(sleep ? { sleep } : {}), ...(patience.blips ? { blips: patience.blips } : {}) }) : opened;
try {
const before = await session.call("GET", summaryRoute);
if (before.status === 404) throw new CloudSendError("outdated", move ? OUTDATED_FOR_MOVE : "My Cloud needs an update before it can receive bots from this computer. It updates by itself; try again later.");
const theirs = before.ok ? parseSendSummary(before.body) : null;
if (!theirs) throw refusedError("unreachable", before, "My Cloud did not answer. Try again.");
let computer = team ? { label: team.label } : null;
if (move) {
await assertTakesMove(session);
// A bot moving into a team carries nothing of the person's: their apps and keys stay.
// A sign-in that refreshes itself stays with the bots that stay: only one place can hold it.
if (!team) computer = await moveConnections(origin, session, result, job.final && !job.scope);
}
settleCreating(origin, theirs);
const planned = plan(origin, mine, theirs, force, { everything: move });
const wanted = keys ? new Set(keys) : null;
const rows = pendingRows(planned).filter((row) => !wanted || wanted.has(row.key) || (row.bot && wanted.has(itemKey.bot(row.bot))))
.filter((row) => row.kind !== "app-event" || job.final);
const total = rows.length;
progress = total ? { done: 0, total } : null;
publish();
const cloudBots = new Set(theirs.items.filter((item) => item.kind === "bot" && item.id).map((item) => item.id));
const localItems = new Map(mine.items.map((item) => [item.key, item]));
const cloudItems = new Map(theirs.items.map((item) => [item.key, item]));
const ctx = { origin, session, result, cloudBots, localItems, cloudItems, planned: new Map(planned.rows.map((row) => [row.key, row])), bots: null, skillLists: new Map(),
sentKeys: new Set(), sentParts: {}, recorded: new Set(), move, ownMove: move && !team, summaryRoute, computer: computer?.label ?? null };
const order = { "library-skill": 0, "imported-skill": 0, bot: 1, skill: 2, setting: 3, login: 4, secret: 4, "1password": 5 };
const automations = new Set(AUTOMATION_KINDS);
const goRow = async (row) => {
try { await sendRow(ctx, row); }
catch (error) { result.failed.push({ key: row.key, kind: row.kind, label: row.label, error: sentence(error?.message) ?? "It could not be sent.", ...(error?.final === true ? { final: true } : {}) }); }
await recordItem(ctx, row.key).catch(() => {});
if (progress && !job.chats) progress = { done: progress.done + 1, total };
publish();
};
for (const row of rows.filter((entry) => !automations.has(entry.kind)).sort((a, b) => order[a.kind] - order[b.kind])) await goRow(row);
const moved = move ? await movedBots(ctx) : [];
if (move) {
// Its notes about the person stay with them when a bot moves into a team.
if (!team) await kinds.moveMemory(ctx, moved);
try { await kinds.moveFiles(ctx, moved, computer?.label); }
catch (error) { result.failed.push({ key: "files", kind: "files", label: "Files", error: sentence(error?.message) ?? "The files could not be sent." }); }
}
const after = parseSendSummary((await session.call("GET", summaryRoute)).body);
if (after) remember(origin, ctx, after);
const latest = after ?? theirs;
if (withChats && latest.chats) {
job.chats = true;
progress = null;
publish();
try {
await sendChatRows(origin, session, latest, result, { everything: move });
if (move) await stampHeld(origin, session, latest);
} catch (error) { result.failed.push({ key: "chats", kind: "chats", label: "Chats", error: sentence(error?.message) ?? "The chats could not be added." }); }
}
if (move) {
try { result.conversationSettings = await kinds.moveSettings(ctx); }
catch (error) { result.failed.push({ key: "conversation-settings", kind: "chats", label: "Chats", error: sentence(error?.message) ?? "The chats' settings could not be added." }); }
const last = rows.filter((entry) => automations.has(entry.kind));
for (const row of last) await goRow(row);
// The pass's closing plan reads My Cloud as it is now: read before the automations went, it would take each one
// just carried for one removed there.
if (last.length) return parseSendSummary((await session.call("GET", summaryRoute)).body) ?? latest;
}
return latest;
} finally {
try { await session.close(); } catch {}
}
}
/** The bots of this computer's that My Cloud has now, with their names. */
async function movedBots(ctx) {
const bots = await localBots(ctx);
return [...ctx.cloudBots].filter((id) => bots.has(id) && ctx.localItems.has(itemKey.bot(id))).map((id) => ({ id, name: bots.get(id).name }));
}
/** This computer's apps and keys, MCP servers, mailboxes and connected apps, over the move's session. A sign-in that
* refreshes itself goes only in the final pass; this computer then lets go of its copy once My Cloud has it, and one it
* could not let go of yet is kept and let go of on the next final pass. */
async function moveConnections(origin, session, result, final) {
const kept = entry(origin);
const failed = (id, label, error, last) => result.failed.push({ key: id, kind: "connection", label, error: sentence(error) ?? t("native.home.itemDidNotMove"), ...(last ? { final: true } : {}) });
if (final && kept.release.length) {
kept.release = await releaseConnections(kept.release, { local: connectionLocal, target: session }).catch(() => kept.release);
persist();
}
let collected;
try { collected = await collectConnections(connectionLocal); }
catch (error) { failed("connections", "Apps and keys", error?.message, false); return null; }
const moved = await sendConnections(collected, session, { local: connectionLocal, signIns: final, t, ...(engineWaitMs !== undefined ? { waitMs: engineWaitMs } : {}),
onProgress: (step) => { progress = { done: step.done, total: step.total }; publish(); } });
result.connections += moved.moved.length + moved.runsHere.length;
for (const failure of moved.failed) failed(failure.id, failure.label, failure.message, failure.final);
for (const held of moved.held ?? []) result.notes.push({ kind: "held", connection: held.label });
for (const stayed of moved.runsHere) if (!stayed.off) result.notes.push({ kind: "stayed", connection: stayed.label, ...(stayed.needs ? { needs: stayed.needs } : {}) });
if (moved.release.length) {
kept.release = [...kept.release.filter((held) => !moved.release.some((next) => next.id === held.id)), ...moved.release];
persist();
}
return collected.computer;
}
/** The conversations whose newest row My Cloud has not taken, not answered in now: of bots My Cloud has and that are
* not kept here (`everything`: whatever was kept here), and of rooms, which My Cloud makes once one of their bots is
* there. A room nobody wrote in yet goes as its active thread with no rows (`empty`), once, so My Cloud makes it too. */
async function chatsWaiting(origin, theirs, { everything = false } = {}) {
const listed = await localJson(scoped("/api/cloud-send/chats"));
const kept = entry(origin);
const cloudBots = new Set(theirs.items.filter((item) => item.kind === "bot" && item.id).map((item) => item.id));
const id = (value) => typeof value === "string" && /^[\w-]{1,128}$/u.test(value);
const thread = (value) => typeof value === "string" && /^[\w-]{1,200}$/u.test(value);
const keptHere = (botId) => everything ? kept.off[itemKey.bot(botId)] === "removed" : Boolean(kept.off[itemKey.bot(botId)]);
// My Cloud makes the room once one of its bots is there, as it does from a room's rows; one with no bot never goes.
const empty = (Array.isArray(listed?.rooms) ? listed.rooms : []).filter((room) => record(room) && id(room.room) && thread(room.threadId)
&& Array.isArray(room.members) && room.members.length > 0)
.map((room) => ({ room: room.room, threadId: room.threadId, last: 0, lastId: "", busy: false, empty: true }));
const conversations = [...(Array.isArray(listed?.conversations) ? listed.conversations : []).filter((head) => record(head)
&& (id(head.bot) !== id(head.room)) && thread(head.threadId) && Number.isSafeInteger(head.last) && typeof head.lastId === "string"), ...empty];
const waiting = conversations.filter((head) => {
const cursor = kept.chats[head.threadId];
if (cursor === "removed" || (head.bot && (!cloudBots.has(head.bot) || keptHere(head.bot)))) return false;
return !cursor || cursor.after !== head.last || cursor.id !== head.lastId;
});
return { dataDir: typeof listed?.dataDir === "string" ? listed.dataDir : "", ready: waiting.filter((head) => !head.busy), busy: waiting.filter((head) => head.busy).length,
owner: team && typeof listed?.owner === "string" && /^p_[\w-]{22}$/u.test(listed.owner) ? listed.owner : null };
}
/** Each waiting conversation's new rows, page by page, a few conversations per request; the cursor moves only past
* what My Cloud took, and once My Cloud did not take a page, the conversation's later pages wait for the next send too.
* The attachments those rows name go first, unless My Cloud has them. `result.chats.left`: the conversations that did
* not all go (a bot answering in one, or My Cloud not taking a page), for the next send. */
async function sendChatRows(origin, session, theirs, result, { everything = false } = {}) {
const { dataDir, ready, busy, owner: from } = await chatsWaiting(origin, theirs, { everything });
const kept = entry(origin);
const total = ready.length;
const held = new Set();
let batch = [], bytes = 0, done = 0;
result.chats = { conversations: 0, messages: 0, left: busy };
progress = total ? { done: 0, total } : null;
publish();
const flush = async () => {
if (!batch.length) return;
const names = [...new Set(batch.flatMap((page) => page.attachments))];
for (let i = 0; i < names.length; i += ATTACHMENT_NAMES) {
const asked = names.slice(i, i + ATTACHMENT_NAMES);
const checked = await session.call("POST", "/api/cloud-send/chats/attachments", { names: asked });
if (!checked.ok) throw refusedError("refused", checked, "My Cloud could not take the chats' attachments.");
for (const name of Array.isArray(checked.body?.missing) ? checked.body.missing.filter((name) => asked.includes(name)) : []) {
const file = await localBytes(`/api/cloud-send/chats/attachment/${name}`);
if (!file) continue;
const stored = await session.upload(`/api/cloud-send/chats/attachment/${name}`, file, "application/octet-stream");
if (stored.status === 413 || stored.status === 507) {
result.failed.push({ key: `attachment:${name}`, kind: "chats", label: "Attachment", error: stored.status === 507
? "My Cloud's room for attachments is full, so an attachment did not go. Its chat went without it."
: "An attachment is bigger than My Cloud takes, so it did not go. Its chat went without it.", ...(stored.status === 413 ? { final: true } : {}) });
continue;
}
if (!stored.ok) throw refusedError("refused", stored, "My Cloud could not take an attachment of the chats.");
}
}
// Into a team: whose lines on My Cloud become the mover's there.
const answer = await session.call("POST", "/api/cloud-send/chats", { v: 1, dataDir, ...(from ? { owner: from } : {}),
conversations: batch.map(({ owner, threadId, task, rows, leaf, sent }) => ({ ...owner, threadId, task, rows, leaf, sent })) });
if (!answer.ok) throw refusedError("refused", answer, "My Cloud could not add the chats.");
const accepted = new Set(Array.isArray(answer.body?.accepted) ? answer.body.accepted : []);
const removed = new Set(Array.isArray(answer.body?.removed) ? answer.body.removed : []);
for (const page of batch) {
if (accepted.has(page.threadId) && typeof page.lastId === "string") kept.chats[page.threadId] = { after: page.last, id: page.lastId };
else {
if (!held.has(page.threadId) && !(removed.has(page.threadId) && page.sent)) result.chats.left++;
held.add(page.threadId);
if (removed.has(page.threadId) && page.sent) kept.chats[page.threadId] = "removed";
}
}
result.chats.conversations += Number.isSafeInteger(answer.body?.conversations) ? answer.body.conversations : 0;
result.chats.messages += Number.isSafeInteger(answer.body?.messages) ? answer.body.messages : 0;
persist();
batch = []; bytes = 0;
};
for (const head of ready) {
let cursor = record(kept.chats[head.threadId]) ? kept.chats[head.threadId] : null;
for (let pages = 0; pages < 10_000 && !held.has(head.threadId); pages++) {
const page = await localJson(`/api/cloud-send/chats/rows?thread=${head.threadId}&after=${cursor?.after ?? 0}&id=${encodeURIComponent(cursor?.id ?? "")}`);
if (!record(page) || !Array.isArray(page.rows)) break;
if (page.restarted) delete kept.chats[head.threadId];
// A room nobody wrote in yet: its thread goes with no rows, which My Cloud takes as the room to make.
const room = head.empty === true && !page.rows.length && !page.lastId && record(page.roomInfo);
if (page.rows.length || room) {
const size = Buffer.byteLength(JSON.stringify(page.rows), "utf8");
if (bytes + size > CHATS_BATCH_BYTES || batch.length >= CHATS_BATCH_PAGES) await flush();
if (held.has(head.threadId)) break;
const owner = head.room ? { room: head.room, ...(record(page.roomInfo) ? { roomInfo: page.roomInfo } : {}) } : { bot: head.bot };
batch.push({ owner, threadId: head.threadId, task: record(page.task) ? page.task : null, rows: page.rows, leaf: typeof page.leaf === "string" ? page.leaf : null,
sent: record(kept.chats[head.threadId]), last: room ? 0 : page.last, lastId: room ? "" : page.lastId,
attachments: Array.isArray(page.attachments) ? page.attachments.filter((name) => typeof name === "string" && /^[A-Za-z0-9-]{1,80}\.[A-Za-z0-9]{1,10}$/u.test(name)) : [] });
bytes += size;
} else if (page.lastId) kept.chats[head.threadId] = { after: page.last, id: page.lastId };
if (!page.more || !page.lastId) break;
cursor = { after: page.last, id: page.lastId };
}
progress = { done: ++done, total };
publish();
}
await flush();
}
/** A move has My Cloud make its owner's lines and answers its owner's among the rows it already held of this computer's
* bots and rooms, which an earlier send or a Bring left naming nobody: this computer's row ids go, never a row. Once per
* conversation (`stamped`): every row that arrives over the owner's session after it carries the owner already, and a
* check that finds one that does not names its conversation again. */
async function stampHeld(origin, session, theirs) {
const listed = await localJson(scoped("/api/cloud-send/chats"));
const kept = entry(origin);
const cloudBots = new Set(theirs.items.filter((item) => item.kind === "bot" && item.id).map((item) => item.id));
const threads = (Array.isArray(listed?.conversations) ? listed.conversations : []).filter((head) => record(head) && (cloudBots.has(head.bot) || typeof head.room === "string")
&& typeof head.threadId === "string" && /^[\w-]{1,200}$/u.test(head.threadId) && kept.chats[head.threadId] !== "removed" && !kept.stamped[head.threadId]).map((head) => head.threadId);
let batch = [], ids = 0, whole = [];
const flush = async () => {
if (batch.length) {
const answer = await session.call("POST", "/api/cloud-send/chats/owner", { v: 1, conversations: batch });
if (!answer.ok) throw refusedError("refused", answer, "My Cloud could not keep the chats' lines as yours.");
}
for (const threadId of whole) kept.stamped[threadId] = true;
if (whole.length) persist();
batch = []; ids = 0; whole = [];
};
for (const threadId of threads) {
for await (const chunk of rowIdChunks(localJson, threadId)) {
if (ids + chunk.ids.length > ROW_IDS || batch.length >= ROW_ID_CONVERSATIONS) await flush();
batch.push({ threadId, ids: chunk.ids });
ids += chunk.ids.length;
if (!chunk.more) whole.push(threadId);
}
}
await flush();
}
/** What was sent is recorded against what My Cloud holds right after, a part it refused included: that part is tried
* again only once it changes here. `keys`: only these. */
function remember(origin, ctx, after, keys = ctx.sentKeys) {
const kept = entry(origin);
const there = new Map(after.items.map((item) => [item.key, item]));
for (const key of keys) {
const mine = ctx.localItems.get(key), theirs = there.get(key);
if (!mine || !theirs) continue;
kept.sent[key] = { ...kept.sent[key] };
for (const part of ctx.sentParts[key] ?? Object.keys(mine.parts)) kept.sent[key][part] = [mine.parts[part], theirs.parts[part] ?? EMPTY];
delete kept.created[key];
ctx.recorded.add(key);
}
}
/** Each item is recorded as soon as it went, from My Cloud's own answer for it and its skills, so a send cut short goes
* on from the next item instead of judging what already went as My Cloud's own change. */
async function recordItem(ctx, key) {
const keys = [...ctx.sentKeys].filter((sent) => !ctx.recorded.has(sent));
if (!keys.length) return;
const answer = await ctx.session.call("GET", scoped(`/api/cloud-send?item=${encodeURIComponent(key)}${ctx.move ? "&move=1" : ""}`));
const after = answer.ok ? parseSendSummary(answer.body) : null;
if (!after) return;
remember(ctx.origin, ctx, after, keys);
persist();
}
/** A bot a send asked My Cloud to make and never heard back about: one My Cloud has is that send's own, so all its
* fields go (`created`); one it lacks was never made, and goes as new. */
function settleCreating(origin, theirs) {
const kept = entry(origin);
const waiting = Object.keys(kept.creating ?? {});
if (!waiting.length) return;
const there = new Set(theirs.items.map((item) => item.key));
for (const key of waiting) {
if (there.has(key)) { kept.sent[key] ??= {}; kept.created[key] = true; }
delete kept.creating[key];
}
persist();
}
const done = (ctx, key, parts = null) => { ctx.sentKeys.add(key); if (parts) ctx.sentParts[key] = parts; };
/** A bot this send made on My Cloud: its fields go whatever My Cloud's defaults are, until they are recorded, and it
* counts as sent before, so one removed there before then stays removed. */
function markCreated(ctx, key) {
const kept = entry(ctx.origin);
kept.sent[key] ??= {};
kept.created[key] = true;
delete kept.creating[key];
persist();
}
async function localBots(ctx) {
if (!ctx.bots) {
const listed = await localJson("/api/bots?messages=0");
ctx.bots = new Map((Array.isArray(listed?.bots) ? listed.bots : []).filter((bot) => record(bot) && typeof bot.id === "string").map((bot) => [bot.id, bot]));
}
return ctx.bots;
}
async function localSkills(ctx, botId) {
if (!ctx.skillLists.has(botId)) {
const listed = await localJson(`/api/bots/${botId}/skills`);
ctx.skillLists.set(botId, new Map((Array.isArray(listed?.skills) ? listed.skills : []).filter((skill) => record(skill) && typeof skill.name === "string").map((skill) => [skill.name, skill])));
}
return ctx.skillLists.get(botId);
}
async function sendRow(ctx, row) {
if (MOVE_KINDS.includes(row.kind)) { await kinds.sendKind(ctx, row); ctx.result.moved++; return done(ctx, row.key); }
if (row.kind === "bot") return row.state === "only-here" ? addBot(ctx, row) : updateBot(ctx, row);
if (row.kind === "skill") return sendSkill(ctx, row);
if (row.kind === "setting") return sendSetting(ctx, row);
if (row.kind === "login") return sendLogin(ctx, row);
if (row.kind === "secret") return sendSecret(ctx, row);
if (row.kind === "1password") return sendOnePassword(ctx, row);
}
async function patchBot(ctx, bot, parts) {
const refused = {};
const whole = await ctx.session.call("PATCH", `/api/bots/${bot.id}`, botPatch(bot, parts));
if (whole.ok) return refused;
for (const part of parts) {
const body = botPatch(bot, [part]);
if (!Object.keys(body).length) continue;
const one = await ctx.session.call("PATCH", `/api/bots/${bot.id}`, body);
if (!one.ok) refused[part] = refusal(one, "My Cloud refused this setting.");
}
return refused;
}
async function sendPicture(ctx, bot) {
const name = typeof bot.avatarUrl === "string" ? /^\/api\/attachments\/([A-Za-z0-9-]{1,80}\.(png|jpg|gif|webp))$/u.exec(bot.avatarUrl) : null;
if (!name) return null;
const bytes = await localBytes(`/api/attachments/${name[1]}`);
if (!bytes) return "The picture could not be read on this computer.";
const uploaded = await ctx.session.upload("/api/attachments", bytes, PICTURE_TYPES[name[2]]);
const stored = typeof uploaded.body?.path === "string" ? uploaded.body.path.replaceAll("\\", "/").split("/").pop() : null;
if (!uploaded.ok || !stored) return refusal(uploaded, "My Cloud could not store the picture.");
const set = await ctx.session.call("PATCH", `/api/bots/${bot.id}`, { avatarUrl: `/api/attachments/${stored}` });
return set.ok ? null : refusal(set, "My Cloud could not use the picture.");
}
/** A move keeps both of two different bots of one name: the one arriving is named after this computer. */
async function arrivingName(ctx, bot) {
if (team) return team.name;
if (!ctx.move || !ctx.computer) return bot.name;
ctx.cloudNames ??= ctx.session.call("GET", "/api/bots?messages=0")
.then((listed) => (Array.isArray(listed.body?.bots) ? listed.body.bots : []).filter(record), () => []);
const same = (name) => typeof name === "string" && name.trim().toLocaleLowerCase() === bot.name.trim().toLocaleLowerCase();
const taken = (await ctx.cloudNames).some((theirs) => theirs.id !== bot.id && same(theirs.name) && !(bot.defaultBot && theirs.defaultBot));
return taken ? `${bot.name} (${ctx.computer})`.slice(0, 80) : bot.name;
}
async function addBot(ctx, row) {
const bot = (await localBots(ctx)).get(row.id);
if (!bot) throw new CloudSendError("gone", "This bot is no longer on this computer.");
const model = record(bot.modelSelection) ? pick(bot.modelSelection, ["instanceId", "model", "effort", "variant"]) : null;
// A bot that moves into a team gets a main chat of the team's own there; its chats with the person arrive as theirs.
const base = { id: bot.id, ...(team ? {} : { threadId: bot.threadId }), name: await arrivingName(ctx, bot), useDefaults: false };
// Kept before asking: a send cut short while My Cloud makes the bot still knows the bot is its own (settleCreating).
entry(ctx.origin).creating[row.key] = true;
persist();
let created = await ctx.session.call("POST", "/api/bots", { ...base, ...(model ? { modelSelection: model, requireAvailableModel: true } : {}) });
let modelKept = Boolean(model) && created.ok;
if (!created.ok && model && created.status === 400) {
created = await ctx.session.call("POST", "/api/bots", base);
modelKept = false;
}
if (!created.ok) throw refusedError("refused", created, "My Cloud could not add this bot.");
ctx.cloudBots.add(bot.id);
markCreated(ctx, row.key);
const parts = Object.keys(BOT_PARTS).filter((part) => part !== "name" && (part !== "model" || modelKept));
const refused = await patchBot(ctx, bot, parts);
if (ctx.move) Object.assign(refused, await kinds.sendBotFields(ctx, bot.id, Object.keys(MOVE_BOT_PARTS)));
const picture = await sendPicture(ctx, bot);
if (picture) refused[PICTURE_PART] = picture;
done(ctx, row.key);
ctx.result.bots.push(bot.name);
if ((!modelKept && model) || refused.model) ctx.result.notes.push({ kind: "model", bot: bot.name });
if (Object.keys(refused).some((part) => part !== "model")) ctx.result.notes.push({ kind: "settings", bot: bot.name });
for (const [key, item] of ctx.localItems) {
if (item.kind !== "skill" || item.bot !== bot.id || ctx.planned.get(key)?.state !== "only-here") continue;
try { await putSkill(ctx, bot.id, item.id, false); done(ctx, key); ctx.result.skills++; }
catch (error) { ctx.result.failed.push({ key, kind: "skill", label: item.label, error: sentence(error?.message) ?? "It could not be sent." }); }
}
if (!ctx.move) await sendMemory(ctx, bot.id).catch(() => ctx.result.notes.push({ kind: "memory", bot: bot.name }));
}
async function updateBot(ctx, row) {
const bot = (await localBots(ctx)).get(row.id);
if (!bot) throw new CloudSendError("gone", "This bot is no longer on this computer.");
const parts = row.parts ?? [];
const fields = parts.filter((part) => part !== PICTURE_PART && !MOVE_BOT_PARTS[part]);
const refused = fields.length ? await patchBot(ctx, bot, fields) : {};
const moveParts = ctx.move ? parts.filter((part) => MOVE_BOT_PARTS[part]) : [];
if (moveParts.length) Object.assign(refused, await kinds.sendBotFields(ctx, bot.id, moveParts));
if (parts.includes(PICTURE_PART)) {
const picture = await sendPicture(ctx, bot);
if (picture) refused[PICTURE_PART] = picture;
}
done(ctx, row.key, parts);
if (Object.keys(refused).length < parts.length) ctx.result.updatedBots.push(bot.name);
if (refused.model) ctx.result.notes.push({ kind: "model", bot: bot.name });
if (Object.keys(refused).some((part) => part !== "model")) ctx.result.notes.push({ kind: "settings", bot: bot.name });
if (entry(ctx.origin).created[row.key] && !ctx.move) await sendMemory(ctx, bot.id).catch(() => ctx.result.notes.push({ kind: "memory", bot: bot.name }));
}
/** The bot's own skill, by its SKILL.md: My Cloud's copy is replaced when the text changed here. */
async function putSkill(ctx, botId, name, replace) {
const skill = (await localSkills(ctx, botId)).get(name);
if (!skill) throw new CloudSendError("gone", "This skill is no longer on this computer.");
const text = await localJson(`/api/bots/${botId}/skills/${name}`);
if (typeof text?.text !== "string") throw new CloudSendError("local", "The skill could not be read on this computer.");
const install = (from, body) => ctx.session.call("POST", `/api/bots/${botId}/skill-template`, {
name, description: typeof from.description === "string" ? from.description.slice(0, 1024) : "", warnings: [],
source: typeof from.source === "string" && from.source.trim() ? from.source.slice(0, 2000) : "this computer",
text: body, enabled: from.enabled === true,
});
let previous = null;
if (replace) {
const listed = await ctx.session.call("GET", `/api/bots/${botId}/skills`);
const own = await ctx.session.call("GET", `/api/bots/${botId}/skills/${name}`);
const entry = Array.isArray(listed.body?.skills) ? listed.body.skills.find((candidate) => candidate?.name === name) : null;
if (!own.ok || typeof own.body?.text !== "string" || !entry) throw refusedError("refused", own, "My Cloud's copy of this skill could not be read, so it was left as it is.");
previous = { entry, text: own.body.text };
const removed = await ctx.session.call("DELETE", `/api/bots/${botId}/skills/${name}`);
if (!removed.ok) throw refusedError("refused", removed, "My Cloud could not replace this skill.");
}
const added = await install(skill, text.text);
if (added.ok) return;
if (previous) await install(previous.entry, previous.text).catch(() => null);
throw refusedError("refused", added, "My Cloud could not add this skill.");
}
async function sendSkill(ctx, row) {
const there = ctx.cloudItems.get(row.key);
const parts = row.parts ?? [];
if (there && parts.length === 1 && parts[0] === "on") {
const skill = (await localSkills(ctx, row.bot)).get(row.id);
const set = await ctx.session.call("PATCH", `/api/bots/${row.bot}/skills/${row.id}`, { enabled: skill?.enabled === true });
if (!set.ok) throw refusedError("refused", set, "My Cloud could not switch this skill.");
} else {
if (!ctx.cloudBots.has(row.bot)) throw new CloudSendError("no_bot", "Its bot is not on My Cloud.");
await putSkill(ctx, row.bot, row.id, Boolean(there));
}
done(ctx, row.key, there && parts.length === 1 && parts[0] === "on" ? parts : null);
ctx.result.skills++;
}
/** A new bot's memory notes go with it once; after that each side's bot keeps its own memory. */
async function sendMemory(ctx, botId) {
const overview = await localJson(`/api/bots/${botId}/memory`);
const files = ["MEMORY.md", ...(Array.isArray(overview?.topics) ? overview.topics.map((topic) => topic?.path).filter((path) => typeof path === "string") : [])];
for (const path of files.slice(0, MEMORY_FILES)) {
const doc = await localJson(`/api/bots/${botId}/memory/file?path=${encodeURIComponent(path)}`);
if (!doc?.exists || typeof doc.text !== "string" || !doc.text.trim()) continue;
const put = await ctx.session.call("PUT", `/api/bots/${botId}/memory/file`, { path, text: doc.text });
if (!put.ok) throw refusedError("refused", put, "My Cloud could not save a memory note.");
}
}
async function sendSetting(ctx, row) {
const config = await localJson("/api/config");
const parts = row.parts ?? ["name", "email", "about", "value"];
let body;
if (row.key === itemKey.setting("profile")) {
const profile = record(config?.profile) ? config.profile : {};
const fields = { name: "name", email: "email", about: "aboutMe" };
body = { profile: Object.fromEntries(parts.filter((part) => fields[part] && typeof profile[fields[part]] === "string").map((part) => [fields[part], profile[fields[part]]])) };
} else if (row.key === itemKey.setting("language")) {
if (typeof config?.language !== "string") return;
body = { language: config.language };
} else return;
const saved = await ctx.session.call("PATCH", "/api/config", body);
if (!saved.ok) throw refusedError("refused", saved, "My Cloud could not save this setting.");
done(ctx, row.key, parts);
ctx.result.settings.push(row.label);
}
async function sendLogin(ctx, row) {
const handed = await handoff({ kind: "login", id: row.id });
const login = handed?.login;
if (!record(login)) throw new CloudSendError("local", "This login could not be read on this computer.");
const kept = botsOnCloud(login.bots, ctx.cloudBots);
const saved = await ctx.session.call("POST", "/api/logins", { site: login.site, username: login.username, password: login.password,
...(login.totp ? { totp: login.totp } : {}), bots: kept, exactHost: login.exactHost === true });
if (!saved.ok) throw refusedError("refused", saved, "My Cloud could not save the login.");
if (Array.isArray(login.bots) && kept.length < login.bots.length) ctx.result.notes.push({ kind: "narrowed", login: row.label });
done(ctx, row.key);
ctx.result.logins++;
}
/** A saved Vault secret, under its id here so an MCP server's `vault:<id>` keeps naming it there: never a linked item. */
async function sendSecret(ctx, row) {
const secret = (await handoff({ kind: "secret", id: row.id }))?.secret;
if (!record(secret)) throw new CloudSendError("local", "This secret could not be read on this computer.");
const kept = botsOnCloud(secret.bots, ctx.cloudBots);
const saved = await ctx.session.call("POST", "/api/vault/secrets", { id: secret.id, name: secret.name, value: secret.value, bots: kept, source: secret.source, ...(secret.refOnly ? { refOnly: true } : {}) });
if (!saved.ok) throw refusedError("refused", saved, "My Cloud could not save the secret.");
if (Array.isArray(secret.bots) && kept.length < secret.bots.length) ctx.result.notes.push({ kind: "narrowed", login: row.label });
done(ctx, row.key);
ctx.result.secrets = (ctx.result.secrets ?? 0) + 1;
}
async function sendOnePassword(ctx, row) {
const handed = await handoff({ kind: "1password" });
const onePassword = handed?.onePassword;
if (!record(onePassword) || typeof onePassword.token !== "string") throw new CloudSendError("local", "1Password is not connected on this computer.");
const parts = row.parts ?? ["token", "vaults"];
if (row.state === "only-here" || parts.includes("token")) {
const connected = await ctx.session.call("POST", "/api/logins/1password", { token: onePassword.token });
if (!connected.ok) throw onePasswordRefusal(connected);
}
for (const [vault, bots] of Object.entries(onePassword.grants ?? {})) {
if (!VAULT_ID.test(vault)) continue;
const granted = await ctx.session.call("PATCH", `/api/logins/1password/vaults/${encodeURIComponent(vault)}`, { bots: botsOnCloud(bots, ctx.cloudBots) });
if (!granted.ok && granted.status !== 404) throw refusedError("refused", granted, "My Cloud connected 1Password but could not keep its vaults to the same bots.");
}
done(ctx, row.key);
ctx.result.onePassword = true;
}
/** What did not arrive of what a move carried (cloud-send-check.mjs `moveCheck`), over one owner session that counts
* as someone using My Cloud; `snapshot().checking` says how far it is. A check that stood still too long fails as
* unanswered, saying whether it got anywhere (`progressed`). `scope`: only the bots a move of some of them carries; its
* credentials are copies this computer goes on using, and are not checked. */
async function check({ scope = null, stopped } = {}) {
const origin = cloud.origin();
if (!origin) throw new CloudSendError("no_cloud", "This computer has no My Cloud yet.");
const session = await cloud.open({ move: true }).catch((error) => { throw new CloudSendError("session", sentence(error?.message) ?? "This app could not sign in to My Cloud. Try again.", { cause: error }); });
localScope = scope;
try {
return await moveCheck({ session, kept: entry(origin), persist, localJson, handoff: team || scope ? null : handoff, scoped, now, sleep, patience, stopped, takesMove: () => assertTakesMove(session),
refuse: (answer, message) => refusedError("unreachable", answer, message), onStep: (value) => { checking = value; publish(); } });
} catch (error) {
if (error?.code === "slow") throw Object.assign(new CloudSendError("unreachable", sentence(error.message) ?? "My Cloud did not answer.", { cause: error.cause }), { progressed: error.progressed === true });
throw error;
} finally {
checking = null;
localScope = null;
publish();
try { await session.close(); } catch {}
}
}
return {
snapshot,
compare,
send,
check,
/** The per-item switch. Turning on something removed on My Cloud forgets that it was sent, so it goes again. */
setItem(key, on) {
const origin = cloud.origin();
if (!origin || typeof key !== "string") return publish();
const kept = entry(origin);
if (on) {
if (kept.off[key] === "removed") delete kept.sent[key];
delete kept.off[key];
} else kept.off[key] = "you";
persist();
if (view.local && view.theirs) view = { ...view, plan: plan(origin, view.local, view.theirs) };
return publish();
},
dismiss(signature) {
const origin = cloud.origin();
if (origin && typeof signature === "string") { entry(origin).dismissed = signature.slice(0, 20_000); persist(); }
return publish();
},
/** Each automation a move carried, by key, and whether it was on here when it went: what the switch turns on there. */
switches() {
const origin = cloud.origin();
return origin ? { ...entry(origin).on } : {};
},
/** For the native menu: what is waiting, from the last comparison, without asking anyone. */
waiting() {
const plan = view.plan;
if (!plan || view.cloud !== "ready") return null;
const rows = pendingRows(plan);
return { bots: rows.filter((row) => row.kind === "bot" && row.state === "only-here").length, total: rows.length };
},
};
}