@goodfish/jarvy 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +53 -0
- package/dist/server/cli.js +1737 -294
- package/dist/web/assets/index-8mJR5pGx.js +604 -0
- package/dist/web/assets/index-eWqQZ8ur.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/skills/dev-team/SKILL.md +1 -1
- package/dist/web/assets/index-BgQn9NWS.js +0 -586
- package/dist/web/assets/index-DeyQgcnD.css +0 -1
package/dist/server/cli.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// src/server/cli.ts
|
|
4
4
|
import { existsSync as existsSync5 } from "node:fs";
|
|
5
5
|
import { createInterface } from "node:readline/promises";
|
|
6
|
-
import { dirname as
|
|
6
|
+
import { dirname as dirname10, join as join16 } from "node:path";
|
|
7
7
|
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
8
8
|
|
|
9
9
|
// src/server/api/server.ts
|
|
@@ -14,8 +14,8 @@ import { networkInterfaces } from "node:os";
|
|
|
14
14
|
import { getRequestListener } from "@hono/node-server";
|
|
15
15
|
|
|
16
16
|
// src/server/api/app.ts
|
|
17
|
-
import { Hono as
|
|
18
|
-
import { HTTPException as
|
|
17
|
+
import { Hono as Hono14 } from "hono";
|
|
18
|
+
import { HTTPException as HTTPException13 } from "hono/http-exception";
|
|
19
19
|
import { serveStatic } from "@hono/node-server/serve-static";
|
|
20
20
|
|
|
21
21
|
// src/server/api/middleware/security.ts
|
|
@@ -214,15 +214,15 @@ var MARK = /\[\[q(\d+)\]\]/g;
|
|
|
214
214
|
function quoteMarks(content2) {
|
|
215
215
|
return [...content2.matchAll(MARK)].map((match) => Number(match[1]));
|
|
216
216
|
}
|
|
217
|
-
function clipQuote(
|
|
218
|
-
const chars = [...
|
|
219
|
-
return chars.length > 200 ? `${chars.slice(0, 120).join("")}\u2026\u2026${chars.slice(-60).join("")}` :
|
|
217
|
+
function clipQuote(text4) {
|
|
218
|
+
const chars = [...text4];
|
|
219
|
+
return chars.length > 200 ? `${chars.slice(0, 120).join("")}\u2026\u2026${chars.slice(-60).join("")}` : text4;
|
|
220
220
|
}
|
|
221
221
|
function previewQuotes(content2, quotes) {
|
|
222
222
|
if (!quotes.length) return content2;
|
|
223
223
|
return content2.replace(MARK, (mark, n) => {
|
|
224
|
-
const
|
|
225
|
-
return
|
|
224
|
+
const quote2 = quotes[Number(n) - 1];
|
|
225
|
+
return quote2 ? `"${clipQuote(quote2.text).replace(/\s+/g, " ").trim()}"` : mark;
|
|
226
226
|
});
|
|
227
227
|
}
|
|
228
228
|
function clipMarked(content2, length) {
|
|
@@ -234,16 +234,16 @@ function replyExcerpt(reply) {
|
|
|
234
234
|
function expandQuotes(content2, quotes, name3) {
|
|
235
235
|
if (!quotes.length) return content2;
|
|
236
236
|
return content2.replace(/[ \t]*\[\[q(\d+)\]\][ \t]*/g, (mark, n, offset, whole) => {
|
|
237
|
-
const
|
|
238
|
-
if (!
|
|
239
|
-
const head = `${name3(
|
|
240
|
-
const block = `${head}${clipQuote(
|
|
237
|
+
const quote2 = quotes[Number(n) - 1];
|
|
238
|
+
if (!quote2) return mark;
|
|
239
|
+
const head = `${name3(quote2.senderId)}${quote2.messageId ? ` (${quote2.messageId})` : ""}: `;
|
|
240
|
+
const block = `${head}${clipQuote(quote2.text).trim()}`.split("\n").map((line) => `> ${line}`).join("\n");
|
|
241
241
|
const end = offset + mark.length;
|
|
242
242
|
return `${offset > 0 && whole[offset - 1] !== "\n" ? "\n" : ""}${block}${end < whole.length && whole[end] !== "\n" ? "\n" : ""}`;
|
|
243
243
|
});
|
|
244
244
|
}
|
|
245
|
-
function quoteComparable(
|
|
246
|
-
return
|
|
245
|
+
function quoteComparable(text4) {
|
|
246
|
+
return text4.replace(MARK, "").replace(/!\[[^\]]*\]\([^)]*\)/g, "").replace(/\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/<(https?:\/\/[^>\s]+)>/g, "$1").replace(/^[ \t|:*_=-]*$/gm, (line) => /[-*_=]{3}/.test(line) ? "" : line).replace(/^[ \t]*(?:>[ \t>]*|#{1,6}[ \t]+|(?:[-*+]|\d+[.)])[ \t]+|```.*|~~~.*)/gm, "").replace(/\\([\\`*_{}[\]()#+\-.!|~>])/g, "$1").replace(/[*_~`|]/g, "").replace(/\s+/g, "");
|
|
247
247
|
}
|
|
248
248
|
|
|
249
249
|
// src/server/api/views/chat.ts
|
|
@@ -301,7 +301,8 @@ function messageView(message) {
|
|
|
301
301
|
echoOf: message.echoOf,
|
|
302
302
|
replyTo: message.replyTo,
|
|
303
303
|
reactions: message.reactions,
|
|
304
|
-
quotes: message.quotes
|
|
304
|
+
quotes: message.quotes,
|
|
305
|
+
folded: message.folded
|
|
305
306
|
};
|
|
306
307
|
}
|
|
307
308
|
|
|
@@ -387,6 +388,10 @@ function botRoutes(bots) {
|
|
|
387
388
|
});
|
|
388
389
|
routes.put("/:id/environment", jsonBody, async (c) => {
|
|
389
390
|
const input = await readJson(c), id = c.req.param("id");
|
|
391
|
+
if (input.machineId !== void 0) {
|
|
392
|
+
if (typeof input.machineId !== "string" || typeof input.granted !== "boolean") throw new HTTPException7(400, { message: "machineId and granted (true or false) are needed" });
|
|
393
|
+
return c.json(bots.grantMachine(id, input.machineId, input.granted));
|
|
394
|
+
}
|
|
390
395
|
if (input.hostGranted === void 0) return c.json(bots.setHome(id, environmentKind(input.home)));
|
|
391
396
|
if (typeof input.hostGranted !== "boolean") throw new HTTPException7(400, { message: "hostGranted must be true or false" });
|
|
392
397
|
return c.json(bots.grantHost(id, input.hostGranted));
|
|
@@ -676,6 +681,7 @@ function channelRoutes({ chats }) {
|
|
|
676
681
|
const input = await readJson(c);
|
|
677
682
|
return c.json(messageView(chats.react(c.get("actorId"), c.req.param("id"), input.emoji, input.on ?? true)));
|
|
678
683
|
});
|
|
684
|
+
routes.put("/messages/:id/fold", jsonBody, async (c) => c.json(messageView(chats.fold(c.get("actorId"), c.req.param("id"), (await readJson(c)).folded))));
|
|
679
685
|
routes.put("/chats/:id/mute", jsonBody, async (c) => c.json(chat(c.get("actorId"), chats.mute(c.get("actorId"), c.req.param("id"), (await readJson(c)).muted).id)));
|
|
680
686
|
routes.put("/chats/:id/pin", jsonBody, async (c) => c.json(chat(c.get("actorId"), chats.pin(c.get("actorId"), c.req.param("id"), (await readJson(c)).pinned).id)));
|
|
681
687
|
routes.get("/members/:id/dms", (c) => c.json({ chats: chats.botDirects(c.get("actorId"), c.req.param("id")).map((item) => chatView(item, (id) => chats.member(id))) }));
|
|
@@ -843,15 +849,100 @@ function secretRoutes(secrets) {
|
|
|
843
849
|
return routes;
|
|
844
850
|
}
|
|
845
851
|
|
|
852
|
+
// src/server/api/routes/environments.ts
|
|
853
|
+
import { Hono as Hono12 } from "hono";
|
|
854
|
+
import { HTTPException as HTTPException12 } from "hono/http-exception";
|
|
855
|
+
function environmentRoutes(bots) {
|
|
856
|
+
const routes = new Hono12(), machines = bots.machines;
|
|
857
|
+
routes.get("/", (c) => c.json({ machines: machines.list() }));
|
|
858
|
+
routes.post("/scan", jsonBody, async (c) => {
|
|
859
|
+
const input = await readJson(c);
|
|
860
|
+
return c.json(await machines.scan({ host: input.host, port: input.port, user: input.user }));
|
|
861
|
+
});
|
|
862
|
+
routes.post("/keys", async (c) => c.json(await machines.prepareKey(), 201));
|
|
863
|
+
routes.post("/", jsonBody, async (c) => {
|
|
864
|
+
const input = await readJson(c);
|
|
865
|
+
return c.json(await machines.add({
|
|
866
|
+
name: input.name,
|
|
867
|
+
host: input.host,
|
|
868
|
+
port: input.port,
|
|
869
|
+
user: input.user,
|
|
870
|
+
directory: input.directory,
|
|
871
|
+
password: input.password,
|
|
872
|
+
...input.keyId !== void 0 ? { keyId: input.keyId } : {},
|
|
873
|
+
hostKeys: hostKeys(input.hostKeys)
|
|
874
|
+
}), 201);
|
|
875
|
+
});
|
|
876
|
+
routes.put("/:id/password", jsonBody, async (c) => {
|
|
877
|
+
const input = await readJson(c);
|
|
878
|
+
return c.json(await machines.setPassword(c.req.param("id"), input.password));
|
|
879
|
+
});
|
|
880
|
+
routes.put("/:id", jsonBody, async (c) => {
|
|
881
|
+
const input = await readJson(c);
|
|
882
|
+
return c.json(machines.update(c.req.param("id"), { name: input.name, directory: input.directory }));
|
|
883
|
+
});
|
|
884
|
+
routes.post("/:id/check", async (c) => c.json(await machines.check(c.req.param("id"))));
|
|
885
|
+
routes.post("/:id/trust", jsonBody, async (c) => {
|
|
886
|
+
const input = await readJson(c);
|
|
887
|
+
return c.json(await machines.trust(c.req.param("id"), input.fingerprints));
|
|
888
|
+
});
|
|
889
|
+
routes.delete("/:id", async (c) => {
|
|
890
|
+
await bots.removeMachine(c.req.param("id"), c.req.query("force") === "1");
|
|
891
|
+
return c.json({ ok: true });
|
|
892
|
+
});
|
|
893
|
+
routes.put("/:id/grants/:botId", jsonBody, async (c) => {
|
|
894
|
+
const input = await readJson(c);
|
|
895
|
+
if (typeof input.granted !== "boolean") throw new HTTPException12(400, { message: "granted must be true or false" });
|
|
896
|
+
bots.grantMachine(c.req.param("botId"), c.req.param("id"), input.granted);
|
|
897
|
+
return c.json(machines.get(c.req.param("id")));
|
|
898
|
+
});
|
|
899
|
+
routes.post("/requests/:chatId/:messageId", jsonBody, async (c) => {
|
|
900
|
+
const input = await readJson(c);
|
|
901
|
+
return c.json(messageView(await machines.fulfil(
|
|
902
|
+
c.get("actorId"),
|
|
903
|
+
c.req.param("chatId"),
|
|
904
|
+
c.req.param("messageId"),
|
|
905
|
+
{ password: input.password, ...input.keyId !== void 0 ? { keyId: input.keyId } : {} }
|
|
906
|
+
)));
|
|
907
|
+
});
|
|
908
|
+
return routes;
|
|
909
|
+
}
|
|
910
|
+
function hostKeys(value2) {
|
|
911
|
+
const keys = value2;
|
|
912
|
+
const strings = (list) => Array.isArray(list) && list.length > 0 && list.length <= 20 && list.every((item) => typeof item === "string" && item.length <= 2e3);
|
|
913
|
+
if (!keys || !strings(keys.keys) || !strings(keys.fingerprints)) throw new HTTPException12(400, { message: "hostKeys must be what the scan returned" });
|
|
914
|
+
return { keys: keys.keys, fingerprints: keys.fingerprints };
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
// src/server/api/routes/tasks.ts
|
|
918
|
+
import { Hono as Hono13 } from "hono";
|
|
919
|
+
function taskRoutes(services) {
|
|
920
|
+
const routes = new Hono13();
|
|
921
|
+
const bot = (id) => {
|
|
922
|
+
services.bots.describe(id);
|
|
923
|
+
return id;
|
|
924
|
+
};
|
|
925
|
+
routes.get("/bots/:id/tasks", (c) => c.json({ tasks: services.tasks.list(bot(c.req.param("id"))) }));
|
|
926
|
+
routes.post("/bots/:id/tasks", jsonBody, async (c) => c.json(services.tasks.create(bot(c.req.param("id")), await readJson(c)), 201));
|
|
927
|
+
routes.put("/bots/:id/tasks/:taskId", jsonBody, async (c) => c.json(services.tasks.update(bot(c.req.param("id")), c.req.param("taskId"), await readJson(c))));
|
|
928
|
+
routes.post("/bots/:id/tasks/:taskId/:action{pause|resume}", (c) => c.json(services.tasks.setState(bot(c.req.param("id")), c.req.param("taskId"), c.req.param("action") === "pause" ? "paused" : "active")));
|
|
929
|
+
routes.delete("/bots/:id/tasks/:taskId", (c) => {
|
|
930
|
+
services.tasks.remove(bot(c.req.param("id")), c.req.param("taskId"));
|
|
931
|
+
return c.json({ ok: true });
|
|
932
|
+
});
|
|
933
|
+
return routes;
|
|
934
|
+
}
|
|
935
|
+
|
|
846
936
|
// src/server/api/app.ts
|
|
847
937
|
function createHttpApp(services, webRoot) {
|
|
848
|
-
const app = new
|
|
938
|
+
const app = new Hono14();
|
|
849
939
|
app.use("*", headers);
|
|
850
940
|
app.onError(handleError);
|
|
851
|
-
const api = new
|
|
941
|
+
const api = new Hono14();
|
|
852
942
|
api.use("*", apiSecurity);
|
|
853
943
|
api.route("/", rootCertificateRoute(services.connection));
|
|
854
944
|
installAuthentication(api, services.account);
|
|
945
|
+
api.route("/", taskRoutes(services));
|
|
855
946
|
api.route("/bots", botRoutes(services.bots));
|
|
856
947
|
api.route("/", channelRoutes(services));
|
|
857
948
|
api.route("/chats", chatRoutes(services));
|
|
@@ -861,8 +952,9 @@ function createHttpApp(services, webRoot) {
|
|
|
861
952
|
api.route("/", liveRoutes(services));
|
|
862
953
|
api.route("/", connectionRoutes(services.connection));
|
|
863
954
|
api.route("/secrets", secretRoutes(services.secrets));
|
|
955
|
+
api.route("/environments", environmentRoutes(services.bots));
|
|
864
956
|
api.all("*", () => {
|
|
865
|
-
throw new
|
|
957
|
+
throw new HTTPException13(404, { message: "API route not found" });
|
|
866
958
|
});
|
|
867
959
|
app.route("/api", api);
|
|
868
960
|
app.get("*", serveStatic({ root: webRoot, onFound: (path, c) => {
|
|
@@ -872,16 +964,16 @@ function createHttpApp(services, webRoot) {
|
|
|
872
964
|
c.header("Cache-Control", "no-store");
|
|
873
965
|
} }));
|
|
874
966
|
app.all("*", (c) => {
|
|
875
|
-
if (c.req.method !== "GET" && c.req.method !== "HEAD") throw new
|
|
876
|
-
throw new
|
|
967
|
+
if (c.req.method !== "GET" && c.req.method !== "HEAD") throw new HTTPException13(405, { message: "Method not allowed" });
|
|
968
|
+
throw new HTTPException13(404, { message: "Web assets missing; run npm run build" });
|
|
877
969
|
});
|
|
878
970
|
return app;
|
|
879
971
|
}
|
|
880
972
|
|
|
881
973
|
// src/server/api/server.ts
|
|
882
974
|
var own = { at: 0, addresses: /* @__PURE__ */ new Set() };
|
|
883
|
-
function fromThisMachine(
|
|
884
|
-
const plain2 =
|
|
975
|
+
function fromThisMachine(address2 = "") {
|
|
976
|
+
const plain2 = address2.replace(/^::ffff:/, "");
|
|
885
977
|
if (/^127\./.test(plain2) || plain2 === "::1") return true;
|
|
886
978
|
if (Date.now() - own.at > 6e4) own = { at: Date.now(), addresses: new Set(Object.values(networkInterfaces()).flatMap((list) => (list ?? []).map((item) => item.address))) };
|
|
887
979
|
return own.addresses.has(plain2);
|
|
@@ -945,7 +1037,7 @@ var BOT_COLORS = ["gray", "blue", "green", "amber", "rose", "violet"];
|
|
|
945
1037
|
var AGENT_BOT_ID = "00000000-0000-4000-8000-000000000001";
|
|
946
1038
|
|
|
947
1039
|
// src/server/infra/storage/store.ts
|
|
948
|
-
import { createHash, randomBytes as randomBytes3, randomInt as randomInt3, randomUUID as
|
|
1040
|
+
import { createHash, randomBytes as randomBytes3, randomInt as randomInt3, randomUUID as randomUUID10, scryptSync, timingSafeEqual } from "node:crypto";
|
|
949
1041
|
import { chmodSync as chmodSync2, mkdirSync as mkdirSync2 } from "node:fs";
|
|
950
1042
|
import { homedir as homedir2 } from "node:os";
|
|
951
1043
|
import { dirname as dirname2, join as join3, resolve } from "node:path";
|
|
@@ -1301,8 +1393,8 @@ var Cipher = class _Cipher {
|
|
|
1301
1393
|
location() {
|
|
1302
1394
|
return this.master.location;
|
|
1303
1395
|
}
|
|
1304
|
-
static sealed(
|
|
1305
|
-
return
|
|
1396
|
+
static sealed(text4) {
|
|
1397
|
+
return text4.startsWith(PREFIX);
|
|
1306
1398
|
}
|
|
1307
1399
|
seal(plain2, context2) {
|
|
1308
1400
|
const iv = randomBytes(12), cipher = createCipheriv("aes-256-gcm", this.master.key, iv);
|
|
@@ -1362,6 +1454,106 @@ function migrateSecrets(db, cipher) {
|
|
|
1362
1454
|
}
|
|
1363
1455
|
}
|
|
1364
1456
|
|
|
1457
|
+
// src/server/infra/storage/migrations/ssh-schema.ts
|
|
1458
|
+
function migrateSsh(db) {
|
|
1459
|
+
const version = db.prepare("PRAGMA user_version").get().user_version;
|
|
1460
|
+
if (version >= 22) return;
|
|
1461
|
+
db.exec("PRAGMA foreign_keys=OFF; BEGIN IMMEDIATE");
|
|
1462
|
+
try {
|
|
1463
|
+
db.exec(`
|
|
1464
|
+
CREATE TABLE environments_next(id TEXT PRIMARY KEY, owner_id TEXT NOT NULL REFERENCES members(id),
|
|
1465
|
+
kind TEXT NOT NULL CHECK(kind IN ('host','computer','ssh')), name TEXT NOT NULL DEFAULT '',
|
|
1466
|
+
connection TEXT NOT NULL DEFAULT '{}', directory TEXT, bot_id TEXT REFERENCES bots(id),
|
|
1467
|
+
key TEXT, host_keys TEXT, created_at INTEGER NOT NULL, deleted_at INTEGER);
|
|
1468
|
+
INSERT INTO environments_next(id,owner_id,kind,name,connection,directory,bot_id,created_at,deleted_at)
|
|
1469
|
+
SELECT id,owner_id,kind,name,connection,directory,bot_id,created_at,deleted_at FROM environments;
|
|
1470
|
+
DROP TABLE environments;
|
|
1471
|
+
ALTER TABLE environments_next RENAME TO environments;
|
|
1472
|
+
CREATE UNIQUE INDEX environments_host ON environments(owner_id) WHERE kind='host' AND deleted_at IS NULL;
|
|
1473
|
+
CREATE UNIQUE INDEX environments_computer ON environments(bot_id) WHERE kind='computer' AND deleted_at IS NULL;
|
|
1474
|
+
PRAGMA user_version=22;
|
|
1475
|
+
`);
|
|
1476
|
+
if (db.prepare("PRAGMA foreign_key_check").all().length) throw new Error("Migration has invalid references");
|
|
1477
|
+
db.exec("COMMIT");
|
|
1478
|
+
} catch (error) {
|
|
1479
|
+
db.exec("ROLLBACK");
|
|
1480
|
+
throw error;
|
|
1481
|
+
} finally {
|
|
1482
|
+
db.exec("PRAGMA foreign_keys=ON");
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
// src/server/infra/storage/migrations/task-schema.ts
|
|
1487
|
+
function migrateTasks(db) {
|
|
1488
|
+
const version = db.prepare("PRAGMA user_version").get().user_version;
|
|
1489
|
+
if (version >= 23) return;
|
|
1490
|
+
db.exec("PRAGMA foreign_keys=OFF; BEGIN IMMEDIATE");
|
|
1491
|
+
try {
|
|
1492
|
+
const sequence = db.prepare("SELECT seq FROM sqlite_sequence WHERE name='inbox'").get()?.seq ?? 0;
|
|
1493
|
+
db.exec(`
|
|
1494
|
+
CREATE TABLE tasks(id TEXT PRIMARY KEY, bot_id TEXT NOT NULL REFERENCES bots(id) ON DELETE CASCADE,
|
|
1495
|
+
name TEXT NOT NULL, instruction TEXT NOT NULL,
|
|
1496
|
+
schedule_kind TEXT NOT NULL CHECK(schedule_kind IN ('once','every','daily','weekly')),
|
|
1497
|
+
schedule_at INTEGER, schedule_minutes INTEGER, schedule_hour INTEGER, schedule_minute INTEGER, schedule_weekday INTEGER,
|
|
1498
|
+
state TEXT NOT NULL CHECK(state IN ('active','paused','done')),
|
|
1499
|
+
next_run_at INTEGER, last_run_at INTEGER, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, deleted_at INTEGER);
|
|
1500
|
+
CREATE INDEX tasks_due ON tasks(state, next_run_at) WHERE deleted_at IS NULL;
|
|
1501
|
+
CREATE INDEX tasks_bot ON tasks(bot_id) WHERE deleted_at IS NULL;
|
|
1502
|
+
CREATE TABLE inbox_next(id INTEGER PRIMARY KEY AUTOINCREMENT, member_id TEXT NOT NULL REFERENCES members(id),
|
|
1503
|
+
kind TEXT NOT NULL CHECK(kind IN ('message','reaction','note','onboard','change','task')), reason TEXT NOT NULL,
|
|
1504
|
+
urgency TEXT NOT NULL CHECK(urgency IN ('interrupt','queue','digest')),
|
|
1505
|
+
chat_id TEXT NOT NULL REFERENCES chats(id) ON DELETE CASCADE, message_id TEXT REFERENCES messages(id) ON DELETE CASCADE,
|
|
1506
|
+
actor_id TEXT REFERENCES members(id), detail TEXT,
|
|
1507
|
+
state TEXT NOT NULL DEFAULT 'pending' CHECK(state IN ('pending','claimed','told','done','failed','interrupted','unknown','cancelled')),
|
|
1508
|
+
run_id TEXT REFERENCES bot_runs(id), created_at INTEGER NOT NULL, told_at INTEGER);
|
|
1509
|
+
INSERT INTO inbox_next(id,member_id,kind,reason,urgency,chat_id,message_id,actor_id,detail,state,run_id,created_at,told_at)
|
|
1510
|
+
SELECT id,member_id,kind,reason,urgency,chat_id,message_id,actor_id,detail,state,run_id,created_at,told_at FROM inbox ORDER BY id;
|
|
1511
|
+
DROP TABLE inbox;
|
|
1512
|
+
ALTER TABLE inbox_next RENAME TO inbox;
|
|
1513
|
+
CREATE UNIQUE INDEX inbox_one_message ON inbox(member_id, message_id) WHERE kind='message';
|
|
1514
|
+
CREATE INDEX inbox_open ON inbox(member_id, state, id);
|
|
1515
|
+
CREATE INDEX inbox_run ON inbox(run_id) WHERE run_id IS NOT NULL;
|
|
1516
|
+
`);
|
|
1517
|
+
db.prepare("UPDATE sqlite_sequence SET seq=MAX(seq,?) WHERE name='inbox'").run(sequence);
|
|
1518
|
+
db.exec("PRAGMA user_version=23");
|
|
1519
|
+
if (db.prepare("PRAGMA foreign_key_check").all().length) throw new Error("Migration has invalid references");
|
|
1520
|
+
db.exec("COMMIT");
|
|
1521
|
+
} catch (error) {
|
|
1522
|
+
db.exec("ROLLBACK");
|
|
1523
|
+
throw error;
|
|
1524
|
+
} finally {
|
|
1525
|
+
db.exec("PRAGMA foreign_keys=ON");
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
// src/server/infra/storage/migrations/ssh-login-schema.ts
|
|
1530
|
+
function migrateSshLogin(db) {
|
|
1531
|
+
const version = db.prepare("PRAGMA user_version").get().user_version;
|
|
1532
|
+
if (version >= 24) return;
|
|
1533
|
+
db.exec("BEGIN IMMEDIATE");
|
|
1534
|
+
try {
|
|
1535
|
+
db.exec("ALTER TABLE environments ADD COLUMN password TEXT; PRAGMA user_version=24");
|
|
1536
|
+
db.exec("COMMIT");
|
|
1537
|
+
} catch (error) {
|
|
1538
|
+
db.exec("ROLLBACK");
|
|
1539
|
+
throw error;
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
// src/server/infra/storage/migrations/fold-schema.ts
|
|
1544
|
+
function migrateFolds(db) {
|
|
1545
|
+
const version = db.prepare("PRAGMA user_version").get().user_version;
|
|
1546
|
+
if (version >= 25) return;
|
|
1547
|
+
db.exec("BEGIN IMMEDIATE");
|
|
1548
|
+
try {
|
|
1549
|
+
db.exec("ALTER TABLE messages ADD COLUMN folded_at INTEGER; PRAGMA user_version=25;");
|
|
1550
|
+
db.exec("COMMIT");
|
|
1551
|
+
} catch (error) {
|
|
1552
|
+
db.exec("ROLLBACK");
|
|
1553
|
+
throw error;
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1365
1557
|
// src/server/infra/storage/migrations/schema.ts
|
|
1366
1558
|
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
1367
1559
|
|
|
@@ -1484,7 +1676,7 @@ function migrateRuntime(db) {
|
|
|
1484
1676
|
// src/server/infra/storage/migrations/schema.ts
|
|
1485
1677
|
function migrateDatabase(db, cipher) {
|
|
1486
1678
|
const version = db.prepare("PRAGMA user_version").get().user_version;
|
|
1487
|
-
if (version >
|
|
1679
|
+
if (version > 25) throw new Error(`Unsupported database version: ${version}`);
|
|
1488
1680
|
migratePrototype(db);
|
|
1489
1681
|
if (version >= 5) {
|
|
1490
1682
|
migrateRuntime(db);
|
|
@@ -1503,6 +1695,10 @@ function migrateDatabase(db, cipher) {
|
|
|
1503
1695
|
migrateAgent(db);
|
|
1504
1696
|
migrateEnvironments(db);
|
|
1505
1697
|
migrateSecrets(db, cipher);
|
|
1698
|
+
migrateSsh(db);
|
|
1699
|
+
migrateTasks(db);
|
|
1700
|
+
migrateSshLogin(db);
|
|
1701
|
+
migrateFolds(db);
|
|
1506
1702
|
return;
|
|
1507
1703
|
}
|
|
1508
1704
|
db.exec("PRAGMA foreign_keys=OFF; BEGIN IMMEDIATE");
|
|
@@ -1579,6 +1775,10 @@ function migrateDatabase(db, cipher) {
|
|
|
1579
1775
|
migrateAgent(db);
|
|
1580
1776
|
migrateEnvironments(db);
|
|
1581
1777
|
migrateSecrets(db, cipher);
|
|
1778
|
+
migrateSsh(db);
|
|
1779
|
+
migrateTasks(db);
|
|
1780
|
+
migrateSshLogin(db);
|
|
1781
|
+
migrateFolds(db);
|
|
1582
1782
|
}
|
|
1583
1783
|
|
|
1584
1784
|
// src/server/infra/storage/chat-repository.ts
|
|
@@ -1691,14 +1891,14 @@ var ChannelRepository = class {
|
|
|
1691
1891
|
}
|
|
1692
1892
|
/** Channel notice / thread summary: any member who can write there, people and Bots alike. The routed
|
|
1693
1893
|
* deliveries (A41) are written with the new version; `detail` is that version. */
|
|
1694
|
-
setNote(actorId, chatId,
|
|
1894
|
+
setNote(actorId, chatId, text4, deliveries = []) {
|
|
1695
1895
|
this.chats.assertAccess(actorId, chatId, true);
|
|
1696
1896
|
const type = this.db.prepare("SELECT type FROM chats WHERE id=?").get(chatId).type;
|
|
1697
1897
|
if (type === "dm") throw new ChatError(400, "Direct messages have no notice");
|
|
1698
1898
|
const now = Date.now();
|
|
1699
1899
|
inTransaction(this.db, () => {
|
|
1700
1900
|
this.db.prepare(`INSERT INTO chat_notes(chat_id,text,updated_by,updated_at,version) VALUES(?,?,?,?,1)
|
|
1701
|
-
ON CONFLICT(chat_id) DO UPDATE SET text=excluded.text,updated_by=excluded.updated_by,updated_at=excluded.updated_at,version=version+1`).run(chatId,
|
|
1901
|
+
ON CONFLICT(chat_id) DO UPDATE SET text=excluded.text,updated_by=excluded.updated_by,updated_at=excluded.updated_at,version=version+1`).run(chatId, text4, actorId, now);
|
|
1702
1902
|
this.db.prepare("UPDATE chats SET updated_at=? WHERE id=?").run(now, chatId);
|
|
1703
1903
|
this.markNoteSeen(actorId, chatId);
|
|
1704
1904
|
const version = String(this.db.prepare("SELECT version FROM chat_notes WHERE chat_id=?").get(chatId).version);
|
|
@@ -1904,7 +2104,7 @@ var InteractionRepository = class {
|
|
|
1904
2104
|
notices(botId) {
|
|
1905
2105
|
const rows2 = this.db.prepare(`SELECT n.id,n.kind,n.urgency,n.chat_id AS chatId,c.title AS chatTitle,c.type AS chatType,n.message_id AS messageId,n.actor_id AS actorId,n.detail,n.created_at AS createdAt
|
|
1906
2106
|
FROM inbox n JOIN chats c ON c.id=n.chat_id WHERE n.member_id=? AND n.kind<>'message' AND n.state='pending' ORDER BY n.id`).all(botId);
|
|
1907
|
-
return rows2.filter((row) => this.chats.canAccess(botId, row.chatId)).map(({ actorId, ...row }) => ({ ...row, actor: this.chats.member(actorId) }));
|
|
2107
|
+
return rows2.filter((row) => this.chats.canAccess(botId, row.chatId)).map(({ actorId, ...row }) => ({ ...row, actor: actorId ? this.chats.member(actorId) : null }));
|
|
1908
2108
|
}
|
|
1909
2109
|
/** These notices reached the Bot (steered into its run, or in a batch it was given). */
|
|
1910
2110
|
tell(botId, ids) {
|
|
@@ -1940,6 +2140,10 @@ var InteractionRepository = class {
|
|
|
1940
2140
|
return this.db.prepare(`SELECT DISTINCT member_id AS id FROM chat_states WHERE muted=1 AND chat_id IN
|
|
1941
2141
|
(SELECT id FROM chats WHERE id=? UNION SELECT parent_id FROM chats WHERE id=? AND parent_id IS NOT NULL)`).all(chatId, chatId).map((row) => row.id);
|
|
1942
2142
|
}
|
|
2143
|
+
/** Folds a message to one line in the interface, or unfolds it (A60); its content and everything else stay. */
|
|
2144
|
+
setFolded(messageId, folded) {
|
|
2145
|
+
this.db.prepare("UPDATE messages SET folded_at=? WHERE id=?").run(folded ? Date.now() : null, messageId);
|
|
2146
|
+
}
|
|
1943
2147
|
setPinned(memberId, chatId, pinned) {
|
|
1944
2148
|
this.chats.assertAccess(memberId, chatId);
|
|
1945
2149
|
this.db.prepare(`INSERT INTO chat_states(chat_id,member_id,pinned) VALUES(?,?,?)
|
|
@@ -1954,7 +2158,7 @@ var QuoteRepository = class {
|
|
|
1954
2158
|
}
|
|
1955
2159
|
/** Inside the caller's transaction. */
|
|
1956
2160
|
insert(messageId, quotes) {
|
|
1957
|
-
quotes.forEach((
|
|
2161
|
+
quotes.forEach((quote2, index) => this.db.prepare("INSERT INTO message_quotes(message_id,position,source_id,source_sender_id,text) VALUES(?,?,?,?,?)").run(messageId, index + 1, quote2.messageId, quote2.senderId, quote2.text));
|
|
1958
2162
|
}
|
|
1959
2163
|
of(messageId) {
|
|
1960
2164
|
return this.db.prepare("SELECT source_id AS messageId,source_sender_id AS senderId,text FROM message_quotes WHERE message_id=? ORDER BY position").all(messageId);
|
|
@@ -1962,7 +2166,7 @@ var QuoteRepository = class {
|
|
|
1962
2166
|
};
|
|
1963
2167
|
|
|
1964
2168
|
// src/server/infra/storage/chat-repository.ts
|
|
1965
|
-
var MESSAGE_COLUMNS = "id,chat_id AS chatId,sequence,sender_id AS senderId,content,created_at AS createdAt,history_id AS historyId,tool_call_id AS toolCallId,question,echo_of AS echoOf,reply_to AS replyTo";
|
|
2169
|
+
var MESSAGE_COLUMNS = "id,chat_id AS chatId,sequence,sender_id AS senderId,content,created_at AS createdAt,history_id AS historyId,tool_call_id AS toolCallId,question,echo_of AS echoOf,reply_to AS replyTo,folded_at AS foldedAt";
|
|
1966
2170
|
var CHAT_COLUMNS = `c.id,c.type,c.title,c.owner_id AS ownerId,c.created_at AS createdAt,c.updated_at AS updatedAt,
|
|
1967
2171
|
c.section_id AS sectionId,c.topic,c.parent_id AS parentId,c.root_message_id AS rootMessageId,c.status,
|
|
1968
2172
|
(SELECT content FROM messages WHERE chat_id=c.id ORDER BY sequence DESC LIMIT 1) AS lastMessage,
|
|
@@ -2117,12 +2321,12 @@ var ChatRepository = class {
|
|
|
2117
2321
|
return rows2.map((row) => this.messageFromRow(row));
|
|
2118
2322
|
}
|
|
2119
2323
|
/** Search every chat the member can read: its DMs and channels and their threads (newest first). `within` keeps
|
|
2120
|
-
* one chat, a channel with its threads. */
|
|
2121
|
-
searchAll(actorId, query, limit, within) {
|
|
2122
|
-
const scope = within ? "AND (c.id=? OR c.parent_id=?)" : "";
|
|
2324
|
+
* one chat, a channel with its threads; `sender` keeps one member's messages. */
|
|
2325
|
+
searchAll(actorId, query, limit, within, sender) {
|
|
2326
|
+
const scope = (within ? "AND (c.id=? OR c.parent_id=?) " : "") + (sender ? "AND m.sender_id=?" : "");
|
|
2123
2327
|
const rows2 = this.db.prepare(`SELECT ${MESSAGE_COLUMNS.split(",").map((column) => `m.${column}`).join(",")} FROM messages m JOIN chats c ON c.id=m.chat_id
|
|
2124
2328
|
JOIN chat_members cm ON cm.chat_id=COALESCE(c.parent_id,c.id) AND cm.member_id=? AND cm.can_read=1
|
|
2125
|
-
WHERE instr(lower(m.content),lower(?))>0 ${scope} ORDER BY m.position DESC LIMIT ?`).all(actorId, query, ...within ? [within, within] : [], limit);
|
|
2329
|
+
WHERE instr(lower(m.content),lower(?))>0 ${scope} ORDER BY m.position DESC LIMIT ?`).all(actorId, query, ...within ? [within, within] : [], ...sender ? [sender] : [], limit);
|
|
2126
2330
|
return rows2.map((row) => this.messageFromRow(row));
|
|
2127
2331
|
}
|
|
2128
2332
|
/** Channels and threads the member can read whose title, topic or notice / summary contains the query (newest first). */
|
|
@@ -2141,9 +2345,10 @@ var ChatRepository = class {
|
|
|
2141
2345
|
const row = this.db.prepare(`SELECT ${MESSAGE_COLUMNS} FROM messages WHERE id=?`).get(id);
|
|
2142
2346
|
return row && this.canAccess(actorId, row.chatId) ? this.messageFromRow(row) : null;
|
|
2143
2347
|
}
|
|
2144
|
-
search(actorId, chatId, query, limit) {
|
|
2348
|
+
search(actorId, chatId, query, limit, sender) {
|
|
2145
2349
|
this.assertAccess(actorId, chatId);
|
|
2146
|
-
const rows2 = this.db.prepare(`SELECT ${MESSAGE_COLUMNS} FROM messages WHERE chat_id=? AND instr(lower(content),lower(?))>0
|
|
2350
|
+
const rows2 = this.db.prepare(`SELECT ${MESSAGE_COLUMNS} FROM messages WHERE chat_id=? AND instr(lower(content),lower(?))>0 ${sender ? "AND sender_id=?" : ""}
|
|
2351
|
+
ORDER BY sequence DESC LIMIT ?`).all(chatId, query, ...sender ? [sender] : [], limit);
|
|
2147
2352
|
return rows2.map((row) => this.messageFromRow(row));
|
|
2148
2353
|
}
|
|
2149
2354
|
sentActivity(actorId, senderId, before, limit) {
|
|
@@ -2174,7 +2379,8 @@ var ChatRepository = class {
|
|
|
2174
2379
|
echoOf: row.echoOf === null ? null : { id: row.echoOf, title: this.db.prepare("SELECT title FROM chats WHERE id=?").get(row.echoOf).title },
|
|
2175
2380
|
replyTo: row.replyTo === null ? null : this.quote(row.replyTo),
|
|
2176
2381
|
reactions: this.interactions.reactions(row.id),
|
|
2177
|
-
quotes: this.quotes.of(row.id)
|
|
2382
|
+
quotes: this.quotes.of(row.id),
|
|
2383
|
+
folded: row.foldedAt !== null
|
|
2178
2384
|
};
|
|
2179
2385
|
}
|
|
2180
2386
|
/** What a reply shows of the message it answers: who wrote it and the start of its content, with its quotes ("" is attachments only). */
|
|
@@ -2236,14 +2442,15 @@ var ChatRepository = class {
|
|
|
2236
2442
|
toolCallId: draft.source?.toolCallId ?? null,
|
|
2237
2443
|
question: null,
|
|
2238
2444
|
echoOf: chatId,
|
|
2239
|
-
replyTo: null
|
|
2445
|
+
replyTo: null,
|
|
2446
|
+
foldedAt: null
|
|
2240
2447
|
});
|
|
2241
2448
|
}
|
|
2242
2449
|
}
|
|
2243
2450
|
const file = this.db.prepare("SELECT name FROM attachments WHERE message_id=? ORDER BY created_at,rowid LIMIT 1").get(id);
|
|
2244
2451
|
const title = draft.content ? previewQuotes(draft.content, draft.quotes ?? []).slice(0, 46).replace(/\s+/g, " ") : file?.name.slice(0, 46) ?? "";
|
|
2245
2452
|
this.db.prepare("UPDATE chats SET title=CASE WHEN title='' AND ?=1 THEN ? ELSE title END,updated_at=? WHERE id=?").run(sequence, title, createdAt, chatId);
|
|
2246
|
-
const message = this.messageFromRow({ id, chatId, sequence, senderId: actorId, content: draft.content, createdAt, historyId: draft.source?.historyId ?? null, toolCallId: draft.source?.toolCallId ?? null, question, echoOf: null, replyTo: draft.replyTo ?? null });
|
|
2453
|
+
const message = this.messageFromRow({ id, chatId, sequence, senderId: actorId, content: draft.content, createdAt, historyId: draft.source?.historyId ?? null, toolCallId: draft.source?.toolCallId ?? null, question, echoOf: null, replyTo: draft.replyTo ?? null, foldedAt: null });
|
|
2247
2454
|
this.db.exec("COMMIT");
|
|
2248
2455
|
return { message, duplicate: false, ...echo ? { echo } : {} };
|
|
2249
2456
|
} catch (error) {
|
|
@@ -2633,11 +2840,21 @@ var AgentRepository = class {
|
|
|
2633
2840
|
|
|
2634
2841
|
// src/server/infra/storage/environment-repository.ts
|
|
2635
2842
|
import { randomUUID as randomUUID7 } from "node:crypto";
|
|
2636
|
-
var
|
|
2637
|
-
var
|
|
2843
|
+
var FIELDS = (table) => ["id", "owner_id AS ownerId", "kind", "bot_id AS botId", "name", "directory", "connection"].map((column) => `${table}${column}`).join(", ");
|
|
2844
|
+
var COLUMNS = FIELDS("");
|
|
2845
|
+
var record = (row) => ({
|
|
2846
|
+
id: row.id,
|
|
2847
|
+
ownerId: row.ownerId,
|
|
2848
|
+
kind: row.kind,
|
|
2849
|
+
botId: row.botId,
|
|
2850
|
+
name: row.name,
|
|
2851
|
+
directory: row.directory,
|
|
2852
|
+
spec: JSON.parse(row.connection)
|
|
2853
|
+
});
|
|
2638
2854
|
var EnvironmentRepository = class {
|
|
2639
|
-
constructor(db, ownerId) {
|
|
2855
|
+
constructor(db, cipher, ownerId) {
|
|
2640
2856
|
this.db = db;
|
|
2857
|
+
this.cipher = cipher;
|
|
2641
2858
|
this.ownerId = ownerId;
|
|
2642
2859
|
}
|
|
2643
2860
|
get(id) {
|
|
@@ -2659,7 +2876,7 @@ var EnvironmentRepository = class {
|
|
|
2659
2876
|
}
|
|
2660
2877
|
/** Every live Bot's computer. */
|
|
2661
2878
|
computers() {
|
|
2662
|
-
return this.db.prepare(`SELECT e.
|
|
2879
|
+
return this.db.prepare(`SELECT ${FIELDS("e.")} FROM environments e JOIN bots b ON b.id=e.bot_id
|
|
2663
2880
|
WHERE e.kind='computer' AND e.deleted_at IS NULL AND b.deleted_at IS NULL`).all().map(record);
|
|
2664
2881
|
}
|
|
2665
2882
|
/** Gives the Bot a computer, reached by the container's name. */
|
|
@@ -2703,7 +2920,7 @@ var EnvironmentRepository = class {
|
|
|
2703
2920
|
}
|
|
2704
2921
|
/** The environments the owner granted the Bot, oldest grant first. */
|
|
2705
2922
|
grants(botId) {
|
|
2706
|
-
return this.db.prepare(`SELECT e.
|
|
2923
|
+
return this.db.prepare(`SELECT ${FIELDS("e.")} FROM environment_grants g JOIN environments e ON e.id=g.environment_id
|
|
2707
2924
|
WHERE g.bot_id=? AND e.deleted_at IS NULL ORDER BY g.granted_at, e.id`).all(botId).map(record);
|
|
2708
2925
|
}
|
|
2709
2926
|
/** A grant lasts until it is revoked (A50); false when nothing changed. */
|
|
@@ -2713,6 +2930,68 @@ var EnvironmentRepository = class {
|
|
|
2713
2930
|
revoke(environmentId, botId) {
|
|
2714
2931
|
return Number(this.db.prepare("DELETE FROM environment_grants WHERE environment_id=? AND bot_id=?").run(environmentId, botId).changes) > 0;
|
|
2715
2932
|
}
|
|
2933
|
+
/** The SSH machines, oldest first. */
|
|
2934
|
+
sshMachines() {
|
|
2935
|
+
return this.db.prepare(`SELECT ${COLUMNS} FROM environments WHERE kind='ssh' AND deleted_at IS NULL ORDER BY created_at, id`).all().map(record);
|
|
2936
|
+
}
|
|
2937
|
+
addSsh(input, id = randomUUID7()) {
|
|
2938
|
+
const { host, port, user, home, platform } = input;
|
|
2939
|
+
this.db.prepare("INSERT INTO environments(id,owner_id,kind,name,connection,directory,key,host_keys,password,created_at) VALUES(?,?,'ssh',?,?,?,?,?,?,?)").run(
|
|
2940
|
+
id,
|
|
2941
|
+
this.ownerId(),
|
|
2942
|
+
input.name,
|
|
2943
|
+
JSON.stringify({ host, port: String(port), user, home, platform }),
|
|
2944
|
+
input.directory,
|
|
2945
|
+
this.cipher.seal(input.privateKey, `environment:${id}`),
|
|
2946
|
+
JSON.stringify(input.hostKeys),
|
|
2947
|
+
input.password ? this.cipher.seal(input.password, `environment-password:${id}`) : null,
|
|
2948
|
+
Date.now()
|
|
2949
|
+
);
|
|
2950
|
+
return this.get(id);
|
|
2951
|
+
}
|
|
2952
|
+
/** `password`: a new one for a machine reached with its password; null goes back to the key (A59). */
|
|
2953
|
+
updateSsh(id, input) {
|
|
2954
|
+
if (input.password !== void 0) this.db.prepare("UPDATE environments SET password=? WHERE id=? AND kind='ssh'").run(input.password === null ? null : this.cipher.seal(input.password, `environment-password:${id}`), id);
|
|
2955
|
+
if (input.name !== void 0) this.db.prepare("UPDATE environments SET name=? WHERE id=? AND kind='ssh'").run(input.name, id);
|
|
2956
|
+
if (input.directory !== void 0) this.db.prepare("UPDATE environments SET directory=? WHERE id=? AND kind='ssh'").run(input.directory, id);
|
|
2957
|
+
if (input.hostKeys !== void 0) this.db.prepare("UPDATE environments SET host_keys=? WHERE id=? AND kind='ssh'").run(JSON.stringify(input.hostKeys), id);
|
|
2958
|
+
}
|
|
2959
|
+
/** The machine's private key (null when it doesn't open under this installation's key), its password when it is
|
|
2960
|
+
* reached with one (A59; `passwordUnreadable` when it is kept but doesn't open) and the accepted host keys. */
|
|
2961
|
+
sshSecrets(id) {
|
|
2962
|
+
const row = this.db.prepare("SELECT key, password, host_keys AS hostKeys FROM environments WHERE id=? AND kind='ssh' AND deleted_at IS NULL").get(id);
|
|
2963
|
+
if (!row) return null;
|
|
2964
|
+
const password = row.password ? this.cipher.open(row.password, `environment-password:${id}`) : null;
|
|
2965
|
+
return {
|
|
2966
|
+
privateKey: row.key ? this.cipher.open(row.key, `environment:${id}`) : null,
|
|
2967
|
+
password,
|
|
2968
|
+
passwordUnreadable: !!row.password && password === null,
|
|
2969
|
+
hostKeys: row.hostKeys ? JSON.parse(row.hostKeys) : { keys: [], fingerprints: [] }
|
|
2970
|
+
};
|
|
2971
|
+
}
|
|
2972
|
+
/** Whether a machine is reached with its password rather than the key (A59). */
|
|
2973
|
+
usesPassword(id) {
|
|
2974
|
+
return !!this.db.prepare("SELECT password FROM environments WHERE id=? AND kind='ssh'").get(id)?.password;
|
|
2975
|
+
}
|
|
2976
|
+
/** The Bots that may use an environment. */
|
|
2977
|
+
grantees(environmentId) {
|
|
2978
|
+
return this.db.prepare("SELECT g.bot_id AS botId FROM environment_grants g JOIN bots b ON b.id=g.bot_id WHERE g.environment_id=? AND b.deleted_at IS NULL ORDER BY g.granted_at").all(environmentId).map((row) => row.botId);
|
|
2979
|
+
}
|
|
2980
|
+
/** Deletes an SSH machine with its key: Bots working there are back in their default. Returns those Bots. */
|
|
2981
|
+
dropSsh(id) {
|
|
2982
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
2983
|
+
try {
|
|
2984
|
+
const moved = this.db.prepare("SELECT id FROM bots WHERE current_environment_id=?").all(id).map((row) => row.id);
|
|
2985
|
+
this.db.prepare("UPDATE bots SET current_environment_id=home_environment_id WHERE current_environment_id=?").run(id);
|
|
2986
|
+
this.db.prepare("DELETE FROM environment_grants WHERE environment_id=?").run(id);
|
|
2987
|
+
this.db.prepare("UPDATE environments SET deleted_at=?, key=NULL, password=NULL WHERE id=? AND kind='ssh'").run(Date.now(), id);
|
|
2988
|
+
this.db.exec("COMMIT");
|
|
2989
|
+
return moved;
|
|
2990
|
+
} catch (error) {
|
|
2991
|
+
this.db.exec("ROLLBACK");
|
|
2992
|
+
throw error;
|
|
2993
|
+
}
|
|
2994
|
+
}
|
|
2716
2995
|
};
|
|
2717
2996
|
|
|
2718
2997
|
// src/server/infra/storage/settings-repository.ts
|
|
@@ -2834,6 +3113,193 @@ var SecretRepository = class {
|
|
|
2834
3113
|
}
|
|
2835
3114
|
};
|
|
2836
3115
|
|
|
3116
|
+
// src/server/infra/storage/task-repository.ts
|
|
3117
|
+
import { randomUUID as randomUUID9 } from "node:crypto";
|
|
3118
|
+
|
|
3119
|
+
// src/server/shared/task.ts
|
|
3120
|
+
var TASK_MINUTES_MIN = 5;
|
|
3121
|
+
function localAt(day, hour, minute, addDays) {
|
|
3122
|
+
const at2 = new Date(day.getFullYear(), day.getMonth(), day.getDate() + addDays, hour, minute, 0, 0);
|
|
3123
|
+
return at2.getTime();
|
|
3124
|
+
}
|
|
3125
|
+
function nextAfter(schedule, from) {
|
|
3126
|
+
if (schedule.kind === "once") return schedule.at > from ? schedule.at : null;
|
|
3127
|
+
if (schedule.kind === "every") {
|
|
3128
|
+
const step = schedule.minutes * 6e4;
|
|
3129
|
+
return schedule.since + Math.max(1, Math.floor((from - schedule.since) / step) + 1) * step;
|
|
3130
|
+
}
|
|
3131
|
+
const day = new Date(from);
|
|
3132
|
+
if (schedule.kind === "daily") {
|
|
3133
|
+
const today = localAt(day, schedule.hour, schedule.minute, 0);
|
|
3134
|
+
return today > from ? today : localAt(day, schedule.hour, schedule.minute, 1);
|
|
3135
|
+
}
|
|
3136
|
+
const ahead = (schedule.weekday - day.getDay() + 7) % 7;
|
|
3137
|
+
const candidate = localAt(day, schedule.hour, schedule.minute, ahead);
|
|
3138
|
+
return candidate > from ? candidate : localAt(day, schedule.hour, schedule.minute, ahead + 7);
|
|
3139
|
+
}
|
|
3140
|
+
function describeSchedule(schedule) {
|
|
3141
|
+
const clock = (hour, minute) => `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`;
|
|
3142
|
+
if (schedule.kind === "once") return `once at ${localIso(schedule.at)}`;
|
|
3143
|
+
if (schedule.kind === "every") return schedule.minutes % 60 === 0 && schedule.minutes >= 120 ? `every ${schedule.minutes / 60} hours` : `every ${schedule.minutes} minutes`;
|
|
3144
|
+
if (schedule.kind === "daily") return `daily at ${clock(schedule.hour, schedule.minute)}`;
|
|
3145
|
+
const days = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
|
3146
|
+
return `every ${days[schedule.weekday]} at ${clock(schedule.hour, schedule.minute)}`;
|
|
3147
|
+
}
|
|
3148
|
+
function localIso(time2) {
|
|
3149
|
+
const at2 = new Date(time2), offset = -at2.getTimezoneOffset(), sign2 = offset >= 0 ? "+" : "-", abs = Math.abs(offset);
|
|
3150
|
+
const pad = (value2) => String(value2).padStart(2, "0");
|
|
3151
|
+
return `${at2.getFullYear()}-${pad(at2.getMonth() + 1)}-${pad(at2.getDate())}T${pad(at2.getHours())}:${pad(at2.getMinutes())}:${pad(at2.getSeconds())}${sign2}${pad(Math.floor(abs / 60))}:${pad(abs % 60)}`;
|
|
3152
|
+
}
|
|
3153
|
+
|
|
3154
|
+
// src/server/infra/storage/task-repository.ts
|
|
3155
|
+
var COLUMNS3 = `id,bot_id,name,instruction,schedule_kind,schedule_at,schedule_minutes,schedule_hour,schedule_minute,schedule_weekday,
|
|
3156
|
+
state,next_run_at,last_run_at,created_at,updated_at`;
|
|
3157
|
+
var LIMIT = 200;
|
|
3158
|
+
function scheduleOf(row) {
|
|
3159
|
+
switch (row.schedule_kind) {
|
|
3160
|
+
case "once":
|
|
3161
|
+
return { kind: "once", at: row.schedule_at };
|
|
3162
|
+
case "every":
|
|
3163
|
+
return { kind: "every", minutes: row.schedule_minutes, since: row.schedule_at };
|
|
3164
|
+
case "daily":
|
|
3165
|
+
return { kind: "daily", hour: row.schedule_hour, minute: row.schedule_minute };
|
|
3166
|
+
case "weekly":
|
|
3167
|
+
return { kind: "weekly", weekday: row.schedule_weekday, hour: row.schedule_hour, minute: row.schedule_minute };
|
|
3168
|
+
}
|
|
3169
|
+
}
|
|
3170
|
+
function toTask(row) {
|
|
3171
|
+
return {
|
|
3172
|
+
id: row.id,
|
|
3173
|
+
botId: row.bot_id,
|
|
3174
|
+
name: row.name,
|
|
3175
|
+
instruction: row.instruction,
|
|
3176
|
+
schedule: scheduleOf(row),
|
|
3177
|
+
state: row.state,
|
|
3178
|
+
nextRunAt: row.next_run_at,
|
|
3179
|
+
lastRunAt: row.last_run_at,
|
|
3180
|
+
createdAt: row.created_at,
|
|
3181
|
+
updatedAt: row.updated_at
|
|
3182
|
+
};
|
|
3183
|
+
}
|
|
3184
|
+
function scheduleColumns(schedule) {
|
|
3185
|
+
return {
|
|
3186
|
+
kind: schedule.kind,
|
|
3187
|
+
at: schedule.kind === "once" ? schedule.at : schedule.kind === "every" ? schedule.since : null,
|
|
3188
|
+
minutes: schedule.kind === "every" ? schedule.minutes : null,
|
|
3189
|
+
hour: schedule.kind === "daily" || schedule.kind === "weekly" ? schedule.hour : null,
|
|
3190
|
+
minute: schedule.kind === "daily" || schedule.kind === "weekly" ? schedule.minute : null,
|
|
3191
|
+
weekday: schedule.kind === "weekly" ? schedule.weekday : null
|
|
3192
|
+
};
|
|
3193
|
+
}
|
|
3194
|
+
var TaskRepository = class {
|
|
3195
|
+
constructor(db) {
|
|
3196
|
+
this.db = db;
|
|
3197
|
+
}
|
|
3198
|
+
/** A Bot's tasks: active ones first, soonest first; then paused and done ones. */
|
|
3199
|
+
list(botId) {
|
|
3200
|
+
const rows2 = this.db.prepare(`SELECT ${COLUMNS3} FROM tasks WHERE bot_id=? AND deleted_at IS NULL
|
|
3201
|
+
ORDER BY state='active' DESC, COALESCE(next_run_at,0), created_at LIMIT ${LIMIT}`).all(botId);
|
|
3202
|
+
return rows2.map(toTask);
|
|
3203
|
+
}
|
|
3204
|
+
get(botId, id) {
|
|
3205
|
+
const row = this.db.prepare(`SELECT ${COLUMNS3} FROM tasks WHERE id=? AND bot_id=? AND deleted_at IS NULL`).get(id, botId);
|
|
3206
|
+
return row ? toTask(row) : null;
|
|
3207
|
+
}
|
|
3208
|
+
create(input) {
|
|
3209
|
+
const now = Date.now(), at2 = nextAfter(input.schedule, now);
|
|
3210
|
+
if (at2 === null) throw new ValidationError("A one-off task has to be in the future", "task_time_past");
|
|
3211
|
+
const id = randomUUID9(), columns = scheduleColumns(input.schedule);
|
|
3212
|
+
this.db.prepare(`INSERT INTO tasks(id,bot_id,name,instruction,schedule_kind,schedule_at,schedule_minutes,schedule_hour,schedule_minute,schedule_weekday,
|
|
3213
|
+
state,next_run_at,last_run_at,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,'active',?,NULL,?,?)`).run(id, input.botId, input.name, input.instruction, columns.kind, columns.at, columns.minutes, columns.hour, columns.minute, columns.weekday, at2, now, now);
|
|
3214
|
+
return this.get(input.botId, id);
|
|
3215
|
+
}
|
|
3216
|
+
/** Replaces what is given; a new schedule recomputes the next run from now, and turns a one-off that already ran back on. */
|
|
3217
|
+
update(botId, id, input) {
|
|
3218
|
+
const task = this.get(botId, id);
|
|
3219
|
+
if (!task) return null;
|
|
3220
|
+
const now = Date.now(), schedule = input.schedule ?? task.schedule, columns = scheduleColumns(schedule);
|
|
3221
|
+
if (input.schedule && nextAfter(schedule, now) === null) throw new ValidationError("A one-off task has to be in the future", "task_time_past");
|
|
3222
|
+
const state = input.schedule && task.state === "done" ? "active" : task.state;
|
|
3223
|
+
const next = state !== "active" ? null : input.schedule ? nextAfter(schedule, now) : task.nextRunAt;
|
|
3224
|
+
this.db.prepare(`UPDATE tasks SET name=?,instruction=?,schedule_kind=?,schedule_at=?,schedule_minutes=?,schedule_hour=?,schedule_minute=?,
|
|
3225
|
+
schedule_weekday=?,state=?,next_run_at=?,updated_at=? WHERE id=? AND bot_id=?`).run(
|
|
3226
|
+
input.name ?? task.name,
|
|
3227
|
+
input.instruction ?? task.instruction,
|
|
3228
|
+
columns.kind,
|
|
3229
|
+
columns.at,
|
|
3230
|
+
columns.minutes,
|
|
3231
|
+
columns.hour,
|
|
3232
|
+
columns.minute,
|
|
3233
|
+
columns.weekday,
|
|
3234
|
+
state,
|
|
3235
|
+
next,
|
|
3236
|
+
now,
|
|
3237
|
+
id,
|
|
3238
|
+
botId
|
|
3239
|
+
);
|
|
3240
|
+
return this.get(botId, id);
|
|
3241
|
+
}
|
|
3242
|
+
/** Pauses or resumes it; resuming a one-off already past fails, as making one in the past does. */
|
|
3243
|
+
setState(botId, id, state) {
|
|
3244
|
+
const task = this.get(botId, id);
|
|
3245
|
+
if (!task) return null;
|
|
3246
|
+
const now = Date.now(), next = state === "active" ? nextAfter(task.schedule, now) : null;
|
|
3247
|
+
if (state === "active" && next === null) throw new ValidationError("A one-off task has to be in the future", "task_time_past");
|
|
3248
|
+
this.db.prepare("UPDATE tasks SET state=?,next_run_at=?,updated_at=? WHERE id=? AND bot_id=?").run(state, next, now, id, botId);
|
|
3249
|
+
return this.get(botId, id);
|
|
3250
|
+
}
|
|
3251
|
+
/** Gone from the lists and from the scheduler; the row stays for the record. */
|
|
3252
|
+
remove(botId, id) {
|
|
3253
|
+
const now = Date.now();
|
|
3254
|
+
return Number(this.db.prepare("UPDATE tasks SET deleted_at=?,next_run_at=NULL,updated_at=? WHERE id=? AND bot_id=? AND deleted_at IS NULL").run(now, now, id, botId).changes) > 0;
|
|
3255
|
+
}
|
|
3256
|
+
/** The soonest time any task runs; the scheduler sleeps until then. Null when nothing is scheduled. */
|
|
3257
|
+
soonest() {
|
|
3258
|
+
const row = this.db.prepare("SELECT min(next_run_at) AS at FROM tasks WHERE deleted_at IS NULL AND state='active' AND next_run_at IS NOT NULL").get();
|
|
3259
|
+
return row?.at ?? null;
|
|
3260
|
+
}
|
|
3261
|
+
due(now) {
|
|
3262
|
+
return this.db.prepare(`SELECT ${COLUMNS3.replace(/(\w+)/g, "t.$1")} FROM tasks t JOIN bots b ON b.id=t.bot_id
|
|
3263
|
+
WHERE t.deleted_at IS NULL AND t.state='active' AND t.next_run_at<=? AND b.deleted_at IS NULL ORDER BY t.next_run_at`).all(now);
|
|
3264
|
+
}
|
|
3265
|
+
/** The Bots with a task due now, so the caller can find each one's DM before `fire`. */
|
|
3266
|
+
dueBots(now = Date.now()) {
|
|
3267
|
+
return [...new Set(this.due(now).map((row) => row.bot_id))];
|
|
3268
|
+
}
|
|
3269
|
+
/** The tasks that are due, oldest first, each already moved on: its inbox row (in `dms`, the Bot's DM with its owner)
|
|
3270
|
+
* is written and its next run set in one transaction, so a crash can neither lose nor repeat a run. A Bot missing
|
|
3271
|
+
* from `dms` keeps its task due for the next pass. `at` is when it was due. */
|
|
3272
|
+
fire(dms, now = Date.now()) {
|
|
3273
|
+
const due = this.due(now).filter((row) => dms.has(row.bot_id));
|
|
3274
|
+
if (!due.length) return [];
|
|
3275
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
3276
|
+
try {
|
|
3277
|
+
const fired = [];
|
|
3278
|
+
for (const row of due) {
|
|
3279
|
+
const at2 = row.next_run_at, next = nextAfter(scheduleOf(row), now);
|
|
3280
|
+
this.db.prepare("UPDATE tasks SET last_run_at=?,next_run_at=?,state=?,updated_at=? WHERE id=?").run(at2, next, next === null ? "done" : "active", now, row.id);
|
|
3281
|
+
this.db.prepare(`INSERT INTO inbox(member_id,kind,reason,urgency,chat_id,actor_id,detail,created_at) VALUES(?,'task','schedule','queue',?,NULL,?,?)`).run(row.bot_id, dms.get(row.bot_id), JSON.stringify({ task: row.id, due: at2 }), now);
|
|
3282
|
+
fired.push({ taskId: row.id, botId: row.bot_id, chatId: dms.get(row.bot_id), at: at2 });
|
|
3283
|
+
}
|
|
3284
|
+
this.db.exec("COMMIT");
|
|
3285
|
+
return fired;
|
|
3286
|
+
} catch (error) {
|
|
3287
|
+
this.db.exec("ROLLBACK");
|
|
3288
|
+
throw error;
|
|
3289
|
+
}
|
|
3290
|
+
}
|
|
3291
|
+
/** At start: tasks whose time passed while the server was not running move on to their next time; a one-off already
|
|
3292
|
+
* past is done without running. Returns how many were skipped. */
|
|
3293
|
+
skipOverdue(now = Date.now()) {
|
|
3294
|
+
const rows2 = this.db.prepare(`SELECT ${COLUMNS3} FROM tasks WHERE deleted_at IS NULL AND state='active' AND next_run_at<=?`).all(now);
|
|
3295
|
+
for (const row of rows2) {
|
|
3296
|
+
const next = nextAfter(scheduleOf(row), now);
|
|
3297
|
+
this.db.prepare("UPDATE tasks SET next_run_at=?,state=?,updated_at=? WHERE id=?").run(next, next === null ? "done" : "active", now, row.id);
|
|
3298
|
+
}
|
|
3299
|
+
return rows2.length;
|
|
3300
|
+
}
|
|
3301
|
+
};
|
|
3302
|
+
|
|
2837
3303
|
// src/server/infra/vault/master-key.ts
|
|
2838
3304
|
import { randomBytes as randomBytes2 } from "node:crypto";
|
|
2839
3305
|
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
@@ -2937,6 +3403,8 @@ var Store = class {
|
|
|
2937
3403
|
agent;
|
|
2938
3404
|
environments;
|
|
2939
3405
|
settings;
|
|
3406
|
+
/** Scheduled tasks (A58). */
|
|
3407
|
+
tasks;
|
|
2940
3408
|
secrets;
|
|
2941
3409
|
/** Seals secrets and model credentials under the master key (A52). */
|
|
2942
3410
|
cipher;
|
|
@@ -2958,8 +3426,9 @@ var Store = class {
|
|
|
2958
3426
|
this.runtime = new RuntimeRepository(this.db);
|
|
2959
3427
|
this.credentials = new CredentialRepository(this.db, this.cipher);
|
|
2960
3428
|
this.agent = new AgentRepository(this.db, () => this.userMemberId());
|
|
2961
|
-
this.environments = new EnvironmentRepository(this.db, () => this.userMemberId());
|
|
3429
|
+
this.environments = new EnvironmentRepository(this.db, this.cipher, () => this.userMemberId());
|
|
2962
3430
|
this.settings = new SettingsRepository(this.db);
|
|
3431
|
+
this.tasks = new TaskRepository(this.db);
|
|
2963
3432
|
this.secrets = new SecretRepository(this.db, this.cipher, () => this.userMemberId());
|
|
2964
3433
|
this.agent.ensure();
|
|
2965
3434
|
}
|
|
@@ -2970,7 +3439,7 @@ var Store = class {
|
|
|
2970
3439
|
installationDeviceId() {
|
|
2971
3440
|
const row = this.db.prepare("SELECT device_id FROM installation WHERE id=1").get();
|
|
2972
3441
|
if (row) return row.device_id;
|
|
2973
|
-
const id =
|
|
3442
|
+
const id = randomUUID10();
|
|
2974
3443
|
this.db.prepare("INSERT INTO installation(id,device_id) VALUES(1,?)").run(id);
|
|
2975
3444
|
return id;
|
|
2976
3445
|
}
|
|
@@ -3042,7 +3511,7 @@ var Store = class {
|
|
|
3042
3511
|
if (!this.getBot(id)) return false;
|
|
3043
3512
|
this.db.exec("BEGIN IMMEDIATE");
|
|
3044
3513
|
try {
|
|
3045
|
-
this.db.prepare("UPDATE bots SET deleted_at
|
|
3514
|
+
this.db.prepare("UPDATE bots SET deleted_at=?, connection_id=NULL WHERE id=?").run(Date.now(), id);
|
|
3046
3515
|
this.db.prepare("DELETE FROM chats WHERE type='dm' AND id IN (SELECT chat_id FROM chat_members WHERE member_id=?)").run(id);
|
|
3047
3516
|
for (const table of ["chat_members", "chat_states", "inbox"]) this.db.prepare(`DELETE FROM ${table} WHERE member_id=?`).run(id);
|
|
3048
3517
|
this.db.prepare("DELETE FROM bot_sessions WHERE bot_id=?").run(id);
|
|
@@ -3064,7 +3533,7 @@ var Store = class {
|
|
|
3064
3533
|
/** The caller names the Bot (A45: the interface picks a free default in its own language). */
|
|
3065
3534
|
createBot(name3) {
|
|
3066
3535
|
const clean = this.botName(name3);
|
|
3067
|
-
const id =
|
|
3536
|
+
const id = randomUUID10(), createdAt = Date.now();
|
|
3068
3537
|
this.db.exec("BEGIN IMMEDIATE");
|
|
3069
3538
|
try {
|
|
3070
3539
|
this.db.prepare("INSERT INTO members VALUES(?,'bot')").run(id);
|
|
@@ -3136,7 +3605,7 @@ var Store = class {
|
|
|
3136
3605
|
}
|
|
3137
3606
|
const key = input.apiKey === void 0 ? null : requireText(input.apiKey, "API Key", 8192);
|
|
3138
3607
|
if (key && authType !== "api_key") throw new ValidationError("Invalid credential type");
|
|
3139
|
-
const id =
|
|
3608
|
+
const id = randomUUID10(), now = Date.now();
|
|
3140
3609
|
this.db.exec("BEGIN IMMEDIATE");
|
|
3141
3610
|
try {
|
|
3142
3611
|
this.db.prepare("INSERT INTO provider_connections(id,provider_name,display_name,kind,auth_type,base_url,api,created_at,updated_at,owner_id) VALUES(?,?,?,?,?,?,?,?,?,?)").run(id, providerName, displayName, kind, authType, baseUrl, api, now, now, this.userMemberId());
|
|
@@ -3168,10 +3637,21 @@ var Store = class {
|
|
|
3168
3637
|
this.db.prepare("UPDATE provider_connections SET display_name=?,base_url=?,api=?,updated_at=?,provider_name=? WHERE id=?").run(name3, baseUrl, api, Date.now(), providerName, id);
|
|
3169
3638
|
return this.getModelConnection(id);
|
|
3170
3639
|
}
|
|
3640
|
+
/** Deleted Bots keep their row (deleteBot) but use nothing: their link to the connection is cleared with it. */
|
|
3171
3641
|
deleteModelConnection(id) {
|
|
3172
3642
|
if (!this.getModelConnection(id)) return false;
|
|
3173
|
-
|
|
3174
|
-
|
|
3643
|
+
const users = this.db.prepare("SELECT name FROM bots WHERE connection_id=? AND deleted_at IS NULL ORDER BY name").all(id).map((row) => row.name);
|
|
3644
|
+
if (users.length) throw new ConflictError(`In use by ${users.join(", ")}`, "connection_in_use", { bots: users.join(", "), count: users.length });
|
|
3645
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
3646
|
+
try {
|
|
3647
|
+
this.db.prepare("UPDATE bots SET connection_id=NULL WHERE connection_id=? AND deleted_at IS NOT NULL").run(id);
|
|
3648
|
+
const deleted = Number(this.db.prepare("DELETE FROM provider_connections WHERE id=?").run(id).changes) > 0;
|
|
3649
|
+
this.db.exec("COMMIT");
|
|
3650
|
+
return deleted;
|
|
3651
|
+
} catch (error) {
|
|
3652
|
+
this.db.exec("ROLLBACK");
|
|
3653
|
+
throw error;
|
|
3654
|
+
}
|
|
3175
3655
|
}
|
|
3176
3656
|
saveModelCredential(id, input) {
|
|
3177
3657
|
const connection = this.getModelConnection(id);
|
|
@@ -3261,10 +3741,10 @@ var set = (...items) => tlv(49, Buffer.concat(items));
|
|
|
3261
3741
|
var integer2 = (bytes) => tlv(2, bytes[0] & 128 ? Buffer.concat([Buffer.from([0]), bytes]) : bytes);
|
|
3262
3742
|
var bits = (bytes, unused = 0) => tlv(3, Buffer.concat([Buffer.from([unused]), bytes]));
|
|
3263
3743
|
var octets = (bytes) => tlv(4, bytes);
|
|
3264
|
-
var utf8 = (
|
|
3744
|
+
var utf8 = (text4) => tlv(12, Buffer.from(text4, "utf8"));
|
|
3265
3745
|
var TRUE = tlv(1, Buffer.from([255]));
|
|
3266
|
-
function oid(
|
|
3267
|
-
const [a, b, ...rest] =
|
|
3746
|
+
function oid(text4) {
|
|
3747
|
+
const [a, b, ...rest] = text4.split(".").map(Number);
|
|
3268
3748
|
const out = [a * 40 + b];
|
|
3269
3749
|
for (const value2 of rest) {
|
|
3270
3750
|
const chunk = [value2 & 127];
|
|
@@ -3281,10 +3761,10 @@ var ECDSA_SHA256 = seq(oid("1.2.840.10045.4.3.2"));
|
|
|
3281
3761
|
var name = (common) => seq(set(seq(oid("2.5.4.10"), utf8("Jarvy"))), set(seq(oid("2.5.4.3"), utf8(common))));
|
|
3282
3762
|
var extension = (id, value2, critical = false) => seq(oid(id), ...critical ? [TRUE] : [], octets(value2));
|
|
3283
3763
|
var keyId = (key) => createHash2("sha1").update(key.export({ type: "spki", format: "der" })).digest();
|
|
3284
|
-
function ipBytes(
|
|
3285
|
-
if (isIPv4(
|
|
3286
|
-
if (!isIPv6(
|
|
3287
|
-
const [head, tail] =
|
|
3764
|
+
function ipBytes(address2) {
|
|
3765
|
+
if (isIPv4(address2)) return Buffer.from(address2.split(".").map(Number));
|
|
3766
|
+
if (!isIPv6(address2) || address2.includes(".")) return null;
|
|
3767
|
+
const [head, tail] = address2.split("::");
|
|
3288
3768
|
const groups = (part) => part ? part.split(":") : [];
|
|
3289
3769
|
const left = groups(head), right = groups(tail);
|
|
3290
3770
|
const all = tail === void 0 ? left : [...left, ...Array(8 - left.length - right.length).fill("0"), ...right];
|
|
@@ -3366,8 +3846,8 @@ function read2(dir, file) {
|
|
|
3366
3846
|
const path = join4(dir, file);
|
|
3367
3847
|
return existsSync2(path) ? readFileSync2(path, "utf8") : null;
|
|
3368
3848
|
}
|
|
3369
|
-
function write(dir, file,
|
|
3370
|
-
writeFileSync2(join4(dir, file),
|
|
3849
|
+
function write(dir, file, text4, secret) {
|
|
3850
|
+
writeFileSync2(join4(dir, file), text4, { mode: secret ? 384 : 420 });
|
|
3371
3851
|
}
|
|
3372
3852
|
function readKey(dir, name3, cipher, log) {
|
|
3373
3853
|
const plain2 = read2(dir, `${name3}.pem`);
|
|
@@ -3415,7 +3895,7 @@ function certificates(dataDir, host, cipher, log = console.log) {
|
|
|
3415
3895
|
log(`[tls] signed a server certificate for ${names.join(", ")}`);
|
|
3416
3896
|
}
|
|
3417
3897
|
const parsedRoot = new X509Certificate2(root.certPem);
|
|
3418
|
-
return { key: server.keyPem, cert: server.certPem, rootDer: parsedRoot.raw, fingerprint: parsedRoot.fingerprint256, names };
|
|
3898
|
+
return { key: server.keyPem, cert: server.certPem, rootDer: parsedRoot.raw, fingerprint: parsedRoot.fingerprint256, fingerprintSha1: parsedRoot.fingerprint, names };
|
|
3419
3899
|
}
|
|
3420
3900
|
|
|
3421
3901
|
// src/server/service/account/service.ts
|
|
@@ -3847,13 +4327,13 @@ var ModelService = class {
|
|
|
3847
4327
|
}
|
|
3848
4328
|
/** Suggests model fields from Pi's catalog (with pi.dev's newer entries, A49); the caller decides whether to save them. */
|
|
3849
4329
|
autoconfigure(input) {
|
|
3850
|
-
const
|
|
3851
|
-
const modelId =
|
|
4330
|
+
const text4 = (value2) => typeof value2 === "string" && value2.trim() && value2.length <= 200 ? value2.trim() : null;
|
|
4331
|
+
const modelId = text4(input.modelId), api = text4(input.api);
|
|
3852
4332
|
if (!modelId || !api) throw new RuntimeFault("autoconfig_model", "Enter the model ID.", 400);
|
|
3853
4333
|
let choice;
|
|
3854
4334
|
if (input.choice !== void 0) {
|
|
3855
4335
|
const raw = input.choice;
|
|
3856
|
-
const provider =
|
|
4336
|
+
const provider = text4(raw?.provider), chosen = text4(raw?.modelId);
|
|
3857
4337
|
if (!provider || !chosen) throw new RuntimeFault("autoconfig_source", "The setup source isn't valid.", 400);
|
|
3858
4338
|
choice = { provider, modelId: chosen };
|
|
3859
4339
|
}
|
|
@@ -3884,7 +4364,7 @@ var ModelService = class {
|
|
|
3884
4364
|
};
|
|
3885
4365
|
|
|
3886
4366
|
// src/server/infra/pi/auth.ts
|
|
3887
|
-
import { randomUUID as
|
|
4367
|
+
import { randomUUID as randomUUID11 } from "node:crypto";
|
|
3888
4368
|
import { ModelRuntime as ModelRuntime2 } from "@earendil-works/pi-coding-agent";
|
|
3889
4369
|
var CredentialLogins = class {
|
|
3890
4370
|
constructor(store, credentials, changed) {
|
|
@@ -3932,7 +4412,7 @@ var CredentialLogins = class {
|
|
|
3932
4412
|
prompt(flow, prompt) {
|
|
3933
4413
|
const signal = prompt.signal ? AbortSignal.any([flow.controller.signal, prompt.signal]) : flow.controller.signal;
|
|
3934
4414
|
signal.throwIfAborted();
|
|
3935
|
-
const id =
|
|
4415
|
+
const id = randomUUID11();
|
|
3936
4416
|
flow.state.prompt = {
|
|
3937
4417
|
id,
|
|
3938
4418
|
type: prompt.type,
|
|
@@ -4018,21 +4498,21 @@ var User = class extends Member {
|
|
|
4018
4498
|
|
|
4019
4499
|
// src/server/application.ts
|
|
4020
4500
|
import { createHash as createHash7 } from "node:crypto";
|
|
4021
|
-
import { mkdir as
|
|
4022
|
-
import { join as
|
|
4501
|
+
import { mkdir as mkdir7, realpath } from "node:fs/promises";
|
|
4502
|
+
import { join as join14, resolve as resolve3 } from "node:path";
|
|
4023
4503
|
import lockfile from "proper-lockfile";
|
|
4024
4504
|
|
|
4025
4505
|
// src/server/service/chat/mentions.ts
|
|
4026
4506
|
var WORD = /[\p{Script=Latin}\p{Nd}_]/u;
|
|
4027
4507
|
function findMentions(source2, candidates2) {
|
|
4028
|
-
const
|
|
4029
|
-
const lower =
|
|
4508
|
+
const text4 = source2.replace(/```[\s\S]*?```|`[^`\n]*`/g, (code) => " ".repeat(code.length));
|
|
4509
|
+
const lower = text4.toLowerCase(), sorted = [...candidates2].sort((a, b) => b.name.length - a.name.length), found = [];
|
|
4030
4510
|
for (let at2 = lower.indexOf("@"); at2 >= 0; at2 = lower.indexOf("@", at2 + 1)) {
|
|
4031
|
-
if (at2 > 0 && WORD.test(
|
|
4511
|
+
if (at2 > 0 && WORD.test(text4[at2 - 1])) continue;
|
|
4032
4512
|
const match = sorted.find((candidate) => {
|
|
4033
4513
|
const name3 = candidate.name.toLowerCase();
|
|
4034
4514
|
if (!name3 || !lower.startsWith(name3, at2 + 1)) return false;
|
|
4035
|
-
const next =
|
|
4515
|
+
const next = text4[at2 + 1 + name3.length];
|
|
4036
4516
|
return !(next && WORD.test(next) && WORD.test(name3.at(-1)));
|
|
4037
4517
|
});
|
|
4038
4518
|
if (match && !found.includes(match)) found.push(match);
|
|
@@ -4045,14 +4525,14 @@ function messageTargets({ dm, sender, members, mentioned, replied, subscribers }
|
|
|
4045
4525
|
if (dm) return members.filter((member) => member.id !== sender.id).map((member) => ({ member, reason: "dm" }));
|
|
4046
4526
|
const targets = mentioned.filter((member) => member.id !== sender.id).map((member) => ({ member, reason: "mention" }));
|
|
4047
4527
|
const add = (member, reason) => {
|
|
4048
|
-
if (member.id !== sender.id && !targets.some((
|
|
4528
|
+
if (member.id !== sender.id && !targets.some((target3) => target3.member.id === member.id)) targets.push({ member, reason });
|
|
4049
4529
|
};
|
|
4050
4530
|
if (replied && members.some((member) => member.id === replied.id)) add(replied, "reply");
|
|
4051
4531
|
for (const member of members) if (subscribers.includes(member.id)) add(member, "subscription");
|
|
4052
4532
|
return targets;
|
|
4053
4533
|
}
|
|
4054
4534
|
function echoTargets({ sender, members, subscribers, reached }) {
|
|
4055
|
-
return members.filter((member) => member.id !== sender.id && subscribers.includes(member.id) && !reached.some((
|
|
4535
|
+
return members.filter((member) => member.id !== sender.id && subscribers.includes(member.id) && !reached.some((target3) => target3.member.id === member.id)).map((member) => ({ member, reason: "subscription" }));
|
|
4056
4536
|
}
|
|
4057
4537
|
function reactionTargets({ actorId, author }) {
|
|
4058
4538
|
return author && author.id !== actorId ? [{ member: author, reason: "author" }] : [];
|
|
@@ -4074,7 +4554,10 @@ var POLICY = {
|
|
|
4074
4554
|
// What the agent Bot changed about a Bot rides along with that Bot's next batch, without waking it. The owner moving
|
|
4075
4555
|
// a Bot to another work environment stops its running turn like an @, so it doesn't go on in the old place (A50); a
|
|
4076
4556
|
// Bot not running, and a change of access alone, hear of it with their next batch instead.
|
|
4077
|
-
change: { agent: "digest", owner: "interrupt" }
|
|
4557
|
+
change: { agent: "digest", owner: "interrupt" },
|
|
4558
|
+
// A task that is due wakes its Bot but never interrupts it, and is not steered into a running turn: it waits for
|
|
4559
|
+
// the turn to end (A58).
|
|
4560
|
+
task: { schedule: "queue" }
|
|
4078
4561
|
};
|
|
4079
4562
|
function urgencyOf(kind, reason) {
|
|
4080
4563
|
return POLICY[kind][reason];
|
|
@@ -4085,9 +4568,9 @@ function mutedUrgency(reason, urgency) {
|
|
|
4085
4568
|
|
|
4086
4569
|
// src/server/service/delivery/delivery.ts
|
|
4087
4570
|
function plan(kind, targets, muted = []) {
|
|
4088
|
-
return targets.flatMap((
|
|
4089
|
-
const urgency = urgencyOf(kind,
|
|
4090
|
-
return urgency ? [{ ...
|
|
4571
|
+
return targets.flatMap((target3) => {
|
|
4572
|
+
const urgency = urgencyOf(kind, target3.reason);
|
|
4573
|
+
return urgency ? [{ ...target3, kind, urgency: muted.includes(target3.member.id) ? mutedUrgency(target3.reason, urgency) : urgency }] : [];
|
|
4091
4574
|
});
|
|
4092
4575
|
}
|
|
4093
4576
|
var Dispatcher = class {
|
|
@@ -4270,7 +4753,8 @@ function questionSpec(input) {
|
|
|
4270
4753
|
options: parsed,
|
|
4271
4754
|
multiple: input.multiple === true,
|
|
4272
4755
|
allowCustom: input.allowCustom === true,
|
|
4273
|
-
...input.secret === void 0 ? {} : { secret: secretAsk(input.secret) }
|
|
4756
|
+
...input.secret === void 0 ? {} : { secret: secretAsk(input.secret) },
|
|
4757
|
+
...input.machine === void 0 ? {} : { machine: machineAsk(input.machine) }
|
|
4274
4758
|
};
|
|
4275
4759
|
}
|
|
4276
4760
|
function secretAsk(value2) {
|
|
@@ -4278,6 +4762,22 @@ function secretAsk(value2) {
|
|
|
4278
4762
|
if (!ask || typeof ask !== "object" || typeof ask.name !== "string" || !/^[A-Z][A-Z0-9_]{0,63}$/.test(ask.name) || !text(ask.purpose, 300) || typeof ask.exists !== "boolean" || typeof ask.needsValue !== "boolean") throw new ChatError(400, "Invalid secret request");
|
|
4279
4763
|
return { name: ask.name, purpose: ask.purpose.trim(), exists: ask.exists, needsValue: ask.needsValue };
|
|
4280
4764
|
}
|
|
4765
|
+
function machineAsk(value2) {
|
|
4766
|
+
const ask = value2, keys = ask?.hostKeys;
|
|
4767
|
+
const strings = (list) => Array.isArray(list) && list.length <= 20 && list.every((item) => text(item, 2e3));
|
|
4768
|
+
if (!ask || typeof ask !== "object" || !text(ask.name, 40) || !text(ask.host, 253) || !text(ask.user, 64) || !Number.isInteger(ask.port) || !text(ask.purpose, 300) || ask.directory !== null && !text(ask.directory, 1e3) || !keys || !strings(keys.keys) || !strings(keys.fingerprints)) throw new ChatError(400, "Invalid machine request");
|
|
4769
|
+
const existing = ask.existing;
|
|
4770
|
+
return {
|
|
4771
|
+
name: ask.name,
|
|
4772
|
+
host: ask.host,
|
|
4773
|
+
port: ask.port,
|
|
4774
|
+
user: ask.user,
|
|
4775
|
+
directory: ask.directory,
|
|
4776
|
+
purpose: ask.purpose.trim(),
|
|
4777
|
+
hostKeys: { keys: keys.keys, fingerprints: keys.fingerprints },
|
|
4778
|
+
existing: existing && typeof existing.id === "string" && typeof existing.name === "string" ? { id: existing.id, name: existing.name } : null
|
|
4779
|
+
};
|
|
4780
|
+
}
|
|
4281
4781
|
function answerOf(question, input) {
|
|
4282
4782
|
const selected = input.selected ?? [], custom = input.custom === void 0 || input.custom === null || input.custom === "" ? null : input.custom;
|
|
4283
4783
|
if (!Array.isArray(selected) || new Set(selected).size !== selected.length || !selected.every((index) => Number.isInteger(index) && index >= 0 && index < question.options.length)) throw new ChatError(400, "Invalid selection");
|
|
@@ -4619,9 +5119,9 @@ var ChatService = class {
|
|
|
4619
5119
|
/** A DM with another member of the actor's space (a Bot DMs its owner or another Bot); one per pair. */
|
|
4620
5120
|
openDirect(actorId, targetId) {
|
|
4621
5121
|
const owner = this.repository.channels.ownerOf(actorId), directory = this.repository.channels.directory(owner);
|
|
4622
|
-
const actor = directory.find((member) => member.id === actorId),
|
|
4623
|
-
if (!actor || !
|
|
4624
|
-
const chat = this.repository.openDirect(actorId, targetId, owner, `${actor.name} \xB7 ${
|
|
5122
|
+
const actor = directory.find((member) => member.id === actorId), target3 = directory.find((member) => member.id === targetId);
|
|
5123
|
+
if (!actor || !target3) throw new ChatError(404, "Member not found");
|
|
5124
|
+
const chat = this.repository.openDirect(actorId, targetId, owner, `${actor.name} \xB7 ${target3.name}`);
|
|
4625
5125
|
this.notifySaved(chat.id);
|
|
4626
5126
|
return chat;
|
|
4627
5127
|
}
|
|
@@ -4668,6 +5168,15 @@ var ChatService = class {
|
|
|
4668
5168
|
if (added) this.dispatcher.dispatch(deliveries);
|
|
4669
5169
|
return this.repository.message(actorId, messageId);
|
|
4670
5170
|
}
|
|
5171
|
+
/** Folds a message to one line in the owner's interface, or unfolds it (A60). Nobody is told: a Bot reads it as before. */
|
|
5172
|
+
fold(actorId, messageId, folded) {
|
|
5173
|
+
if (typeof folded !== "boolean") throw new ChatError(400, "Invalid folded state");
|
|
5174
|
+
const message = this.repository.message(actorId, messageId);
|
|
5175
|
+
if (!message) throw new ChatError(404, "Message not found");
|
|
5176
|
+
this.repository.interactions.setFolded(messageId, folded);
|
|
5177
|
+
this.notifySaved(message.chatId);
|
|
5178
|
+
return this.repository.message(actorId, messageId);
|
|
5179
|
+
}
|
|
4671
5180
|
mute(actorId, chatId, muted) {
|
|
4672
5181
|
if (typeof muted !== "boolean") throw new ChatError(400, "Invalid muted");
|
|
4673
5182
|
this.repository.interactions.setMuted(actorId, chatId, muted);
|
|
@@ -4692,10 +5201,11 @@ var ChatService = class {
|
|
|
4692
5201
|
unreadMessages(actorId, chatId, limit) {
|
|
4693
5202
|
return this.repository.unreadMessages(actorId, chatId, limit);
|
|
4694
5203
|
}
|
|
4695
|
-
|
|
5204
|
+
/** `sender`: only that member's messages (a Bot looking for what it sent itself). */
|
|
5205
|
+
searchAll(actorId, query, limit = 30, within, sender) {
|
|
4696
5206
|
if (!query.trim() || query.length > 500 || !Number.isInteger(limit) || limit < 1 || limit > 100) throw new ChatError(400, "Invalid search query or limit");
|
|
4697
5207
|
if (within) this.repository.assertAccess(actorId, within);
|
|
4698
|
-
return this.repository.searchAll(actorId, query.trim(), limit, within);
|
|
5208
|
+
return this.repository.searchAll(actorId, query.trim(), limit, within, sender);
|
|
4699
5209
|
}
|
|
4700
5210
|
/** The search box: channels and threads by title, topic or notice / summary, and messages by text, with the chat
|
|
4701
5211
|
* each message is in. `within` searches one chat (a channel with its threads) and returns no chats. */
|
|
@@ -4766,9 +5276,9 @@ var ChatService = class {
|
|
|
4766
5276
|
message(actorId, id) {
|
|
4767
5277
|
return this.repository.message(actorId, id);
|
|
4768
5278
|
}
|
|
4769
|
-
search(actorId, chatId, query, limit = 30) {
|
|
5279
|
+
search(actorId, chatId, query, limit = 30, sender) {
|
|
4770
5280
|
if (!query.trim() || query.length > 500 || !Number.isInteger(limit) || limit < 1 || limit > 100) throw new ChatError(400, "Invalid search query or limit");
|
|
4771
|
-
return this.repository.search(actorId, chatId, query, limit);
|
|
5281
|
+
return this.repository.search(actorId, chatId, query, limit, sender);
|
|
4772
5282
|
}
|
|
4773
5283
|
notifySaved(chatId) {
|
|
4774
5284
|
try {
|
|
@@ -4889,8 +5399,8 @@ function activityPage(messages, history, limit) {
|
|
|
4889
5399
|
}
|
|
4890
5400
|
|
|
4891
5401
|
// src/server/service/bot/service.ts
|
|
4892
|
-
import { dirname as
|
|
4893
|
-
import { rm as
|
|
5402
|
+
import { dirname as dirname9, join as join13 } from "node:path";
|
|
5403
|
+
import { rm as rm4 } from "node:fs/promises";
|
|
4894
5404
|
|
|
4895
5405
|
// src/server/infra/pi/history.ts
|
|
4896
5406
|
var value = (input) => JSON.parse(JSON.stringify(input));
|
|
@@ -4954,9 +5464,9 @@ var PiHistoryAdapter = class {
|
|
|
4954
5464
|
if (m.role === "system") {
|
|
4955
5465
|
if (m.sections) {
|
|
4956
5466
|
const sections = {};
|
|
4957
|
-
for (const [name3,
|
|
4958
|
-
sections[name3] =
|
|
4959
|
-
if (
|
|
5467
|
+
for (const [name3, text4] of Object.entries(m.sections)) {
|
|
5468
|
+
sections[name3] = text4 === null ? null : message.content.length;
|
|
5469
|
+
if (text4 !== null) message.content.push({ type: "text", text: text4 });
|
|
4960
5470
|
}
|
|
4961
5471
|
pi.sections = sections;
|
|
4962
5472
|
}
|
|
@@ -5029,6 +5539,99 @@ function reportedContext(branch) {
|
|
|
5029
5539
|
return { tokens: null, stale: false };
|
|
5030
5540
|
}
|
|
5031
5541
|
|
|
5542
|
+
// src/server/shared/prompt.ts
|
|
5543
|
+
var joinPrompt = (parts) => parts.map((part) => part.text).join("");
|
|
5544
|
+
|
|
5545
|
+
// src/server/infra/pi/prompt-change.ts
|
|
5546
|
+
var CHANGE_LINES = 40;
|
|
5547
|
+
var CONTEXT_LINES = 2;
|
|
5548
|
+
var TEXT_LIMIT = 8e3;
|
|
5549
|
+
var DIFF_LIMIT = 1500;
|
|
5550
|
+
function lineDiff(before, after2) {
|
|
5551
|
+
const rows2 = before.length, columns = after2.length;
|
|
5552
|
+
const lcs = Array.from({ length: rows2 + 1 }, () => new Uint32Array(columns + 1));
|
|
5553
|
+
for (let i2 = rows2 - 1; i2 >= 0; i2--) for (let j2 = columns - 1; j2 >= 0; j2--)
|
|
5554
|
+
lcs[i2][j2] = before[i2] === after2[j2] ? lcs[i2 + 1][j2 + 1] + 1 : Math.max(lcs[i2 + 1][j2], lcs[i2][j2 + 1]);
|
|
5555
|
+
const out = [];
|
|
5556
|
+
let i = 0, j = 0;
|
|
5557
|
+
while (i < rows2 && j < columns) {
|
|
5558
|
+
if (before[i] === after2[j]) {
|
|
5559
|
+
out.push({ kind: " ", text: after2[j] });
|
|
5560
|
+
i++;
|
|
5561
|
+
j++;
|
|
5562
|
+
} else if (lcs[i + 1][j] >= lcs[i][j + 1]) out.push({ kind: "-", text: before[i++] });
|
|
5563
|
+
else out.push({ kind: "+", text: after2[j++] });
|
|
5564
|
+
}
|
|
5565
|
+
while (i < rows2) out.push({ kind: "-", text: before[i++] });
|
|
5566
|
+
while (j < columns) out.push({ kind: "+", text: after2[j++] });
|
|
5567
|
+
return out;
|
|
5568
|
+
}
|
|
5569
|
+
function hunks(lines) {
|
|
5570
|
+
const keep = /* @__PURE__ */ new Set();
|
|
5571
|
+
lines.forEach((line, index) => {
|
|
5572
|
+
if (line.kind === " ") return;
|
|
5573
|
+
for (let at2 = Math.max(0, index - CONTEXT_LINES); at2 <= Math.min(lines.length - 1, index + CONTEXT_LINES); at2++) keep.add(at2);
|
|
5574
|
+
});
|
|
5575
|
+
const out = [];
|
|
5576
|
+
let last = -1;
|
|
5577
|
+
for (const index of [...keep].sort((a, b) => a - b)) {
|
|
5578
|
+
if (last >= 0 && index > last + 1) out.push("\u2026");
|
|
5579
|
+
const line = lines[index];
|
|
5580
|
+
out.push(`${line.kind}${line.text}`);
|
|
5581
|
+
last = index;
|
|
5582
|
+
}
|
|
5583
|
+
return out;
|
|
5584
|
+
}
|
|
5585
|
+
function promptChange(before, after2) {
|
|
5586
|
+
if (joinPrompt(before) === joinPrompt(after2)) return null;
|
|
5587
|
+
const next = after2;
|
|
5588
|
+
const old = new Map(before.map((part) => [part.name, part.text]));
|
|
5589
|
+
const changed = after2.filter((part) => (old.get(part.name) ?? "") !== part.text);
|
|
5590
|
+
const gone = before.filter((part) => !after2.some((next2) => next2.name === part.name));
|
|
5591
|
+
const parts = [...changed.map((part) => part.name), ...gone.map((part) => part.name)];
|
|
5592
|
+
const lines = [];
|
|
5593
|
+
let tooLong = false;
|
|
5594
|
+
for (const name3 of parts) {
|
|
5595
|
+
const from = (old.get(name3) ?? "").split("\n"), to = (after2.find((part) => part.name === name3)?.text ?? "").split("\n");
|
|
5596
|
+
if (from.length > DIFF_LIMIT || to.length > DIFF_LIMIT) {
|
|
5597
|
+
tooLong = true;
|
|
5598
|
+
break;
|
|
5599
|
+
}
|
|
5600
|
+
lines.push(`@@ ${name3}`, ...hunks(lineDiff(from, to)));
|
|
5601
|
+
if (lines.length > CHANGE_LINES) {
|
|
5602
|
+
tooLong = true;
|
|
5603
|
+
break;
|
|
5604
|
+
}
|
|
5605
|
+
}
|
|
5606
|
+
const cut = (text4) => text4.length > TEXT_LIMIT ? `${text4.slice(0, TEXT_LIMIT)}\u2026` : text4;
|
|
5607
|
+
if (!tooLong) return { parts, diff: cut(lines.join("\n")), whole: false, next };
|
|
5608
|
+
return { parts, diff: cut(changed.map((part) => `@@ ${part.name}
|
|
5609
|
+
${part.text.trim()}`).join("\n\n")), whole: true, next };
|
|
5610
|
+
}
|
|
5611
|
+
var PromptLog = class {
|
|
5612
|
+
adopted;
|
|
5613
|
+
told;
|
|
5614
|
+
constructor(now) {
|
|
5615
|
+
this.adopted = this.told = now();
|
|
5616
|
+
}
|
|
5617
|
+
/** The prompt every request goes out with, until a change is taken up. */
|
|
5618
|
+
get current() {
|
|
5619
|
+
return joinPrompt(this.adopted);
|
|
5620
|
+
}
|
|
5621
|
+
/** What to tell the model with the next batch: how the live prompt differs from the newest one it knows. */
|
|
5622
|
+
pending(now) {
|
|
5623
|
+
return promptChange(this.told, now());
|
|
5624
|
+
}
|
|
5625
|
+
/** The model was told: the next change is measured from here. */
|
|
5626
|
+
sent(change) {
|
|
5627
|
+
this.told = change.next;
|
|
5628
|
+
}
|
|
5629
|
+
/** A new model or a compaction: the live prompt goes out from now on, with nothing left to tell. */
|
|
5630
|
+
take(now) {
|
|
5631
|
+
this.adopted = this.told = now();
|
|
5632
|
+
}
|
|
5633
|
+
};
|
|
5634
|
+
|
|
5032
5635
|
// src/server/infra/pi/http.ts
|
|
5033
5636
|
import { EventEmitter } from "node:events";
|
|
5034
5637
|
import * as undici from "undici";
|
|
@@ -5128,7 +5731,7 @@ import { StringDecoder } from "node:string_decoder";
|
|
|
5128
5731
|
var placeholder = (name3) => `{{secret:${name3}}}`;
|
|
5129
5732
|
var MIN_MASKED = 4;
|
|
5130
5733
|
var MIN_FRAGMENT = 8;
|
|
5131
|
-
var xml = (
|
|
5734
|
+
var xml = (text4) => text4.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
|
|
5132
5735
|
function base64Fragments(value2) {
|
|
5133
5736
|
const bytes = Buffer.from(value2, "utf8"), found = [];
|
|
5134
5737
|
for (const offset of [0, 1, 2]) {
|
|
@@ -5157,8 +5760,8 @@ var Masker = class {
|
|
|
5157
5760
|
get empty() {
|
|
5158
5761
|
return this.pairs.length === 0;
|
|
5159
5762
|
}
|
|
5160
|
-
mask(
|
|
5161
|
-
let result =
|
|
5763
|
+
mask(text4) {
|
|
5764
|
+
let result = text4;
|
|
5162
5765
|
for (const { form, name: name3 } of this.pairs) if (result.includes(form)) result = result.split(form).join(placeholder(name3));
|
|
5163
5766
|
return result;
|
|
5164
5767
|
}
|
|
@@ -5240,9 +5843,9 @@ var u16le = (bytes, offset) => at(bytes, offset) + (at(bytes, offset + 1) << 8);
|
|
|
5240
5843
|
var u32be = (bytes, offset) => at(bytes, offset) * 16777216 + (at(bytes, offset + 1) << 16) + (at(bytes, offset + 2) << 8) + at(bytes, offset + 3);
|
|
5241
5844
|
var u32le = (bytes, offset) => at(bytes, offset) + (at(bytes, offset + 1) << 8) + (at(bytes, offset + 2) << 16) + at(bytes, offset + 3) * 16777216;
|
|
5242
5845
|
var startsWith = (bytes, prefix) => bytes.length >= prefix.length && prefix.every((byte, index) => bytes[index] === byte);
|
|
5243
|
-
function ascii(bytes, offset,
|
|
5244
|
-
if (bytes.length < offset +
|
|
5245
|
-
for (let index = 0; index <
|
|
5846
|
+
function ascii(bytes, offset, text4) {
|
|
5847
|
+
if (bytes.length < offset + text4.length) return false;
|
|
5848
|
+
for (let index = 0; index < text4.length; index++) if (bytes[offset + index] !== text4.charCodeAt(index)) return false;
|
|
5246
5849
|
return true;
|
|
5247
5850
|
}
|
|
5248
5851
|
|
|
@@ -5364,7 +5967,7 @@ function environmentTools(lookup, secrets) {
|
|
|
5364
5967
|
const access = async (path) => {
|
|
5365
5968
|
if (!await current().exists(path)) throw Object.assign(new Error(`ENOENT: no such file or directory, access '${path}'`), { code: "ENOENT" });
|
|
5366
5969
|
};
|
|
5367
|
-
const
|
|
5970
|
+
const readFile4 = (path) => current().readFile(path), writeFile5 = (path, content2) => current().writeFile(path, content2);
|
|
5368
5971
|
const cwd = current().cwd;
|
|
5369
5972
|
const definitions2 = [
|
|
5370
5973
|
relocated(createBashToolDefinition(cwd, { operations: {
|
|
@@ -5387,9 +5990,9 @@ function environmentTools(lookup, secrets) {
|
|
|
5387
5990
|
}
|
|
5388
5991
|
} }), current),
|
|
5389
5992
|
// Pictures go to the model as pictures; the kind comes from a file's first bytes, as Pi's local read does.
|
|
5390
|
-
createReadToolDefinition(cwd, { operations: { readFile:
|
|
5993
|
+
createReadToolDefinition(cwd, { operations: { readFile: readFile4, access, detectImageMimeType: async (path) => imageKind(await current().head(path, IMAGE_SNIFF_BYTES)) } }),
|
|
5391
5994
|
createWriteToolDefinition(cwd, { operations: { writeFile: writeFile5, mkdir: (path) => current().mkdir(path) } }),
|
|
5392
|
-
createEditToolDefinition(cwd, { operations: { readFile:
|
|
5995
|
+
createEditToolDefinition(cwd, { operations: { readFile: readFile4, writeFile: writeFile5, access } })
|
|
5393
5996
|
];
|
|
5394
5997
|
return definitions2.map((definition) => inside(definition, lookup, pinned));
|
|
5395
5998
|
}
|
|
@@ -5397,7 +6000,7 @@ function inside(definition, lookup, pinned) {
|
|
|
5397
6000
|
const execute = definition.execute.bind(definition);
|
|
5398
6001
|
return { ...definition, execute: ((...args) => {
|
|
5399
6002
|
const env = lookup(), ctx = args[4];
|
|
5400
|
-
if (ctx && typeof ctx === "object") args[4] = new Proxy(ctx, { get: (
|
|
6003
|
+
if (ctx && typeof ctx === "object") args[4] = new Proxy(ctx, { get: (target3, key) => key === "cwd" ? env.cwd : Reflect.get(target3, key, target3) });
|
|
5401
6004
|
return pinned.run({ env, toolCallId: typeof args[0] === "string" ? args[0] : null }, () => execute(...args));
|
|
5402
6005
|
}) };
|
|
5403
6006
|
}
|
|
@@ -5417,12 +6020,12 @@ function relocated(definition, current) {
|
|
|
5417
6020
|
try {
|
|
5418
6021
|
const result = await execute(...args), path = result.details?.fullOutputPath;
|
|
5419
6022
|
if (typeof path !== "string") return result;
|
|
5420
|
-
const
|
|
6023
|
+
const target3 = await moveOut(path, env), swap = (text4) => text4?.replaceAll(path, target3);
|
|
5421
6024
|
return {
|
|
5422
6025
|
...result,
|
|
5423
6026
|
content: result.content.map((part) => part.type === "text" ? { ...part, text: swap(part.text) } : part),
|
|
5424
|
-
details: { ...result.details, fullOutputPath:
|
|
5425
|
-
...result.structuredContent?.full_output_path ? { structuredContent: { ...result.structuredContent, full_output_path:
|
|
6027
|
+
details: { ...result.details, fullOutputPath: target3 },
|
|
6028
|
+
...result.structuredContent?.full_output_path ? { structuredContent: { ...result.structuredContent, full_output_path: target3 } } : {}
|
|
5426
6029
|
};
|
|
5427
6030
|
} catch (error) {
|
|
5428
6031
|
const path = error instanceof Error ? /Full output: (\S+?)\]/.exec(error.message)?.[1] : void 0;
|
|
@@ -5432,10 +6035,10 @@ function relocated(definition, current) {
|
|
|
5432
6035
|
}) };
|
|
5433
6036
|
}
|
|
5434
6037
|
async function moveOut(path, env) {
|
|
5435
|
-
const
|
|
5436
|
-
await env.writeFile(
|
|
6038
|
+
const target3 = `${env.temp ?? `${env.cwd}/temp`}/${basename(path)}`;
|
|
6039
|
+
await env.writeFile(target3, await readLocal(path));
|
|
5437
6040
|
await rm(path, { force: true });
|
|
5438
|
-
return
|
|
6041
|
+
return target3;
|
|
5439
6042
|
}
|
|
5440
6043
|
function defined(vars) {
|
|
5441
6044
|
return Object.fromEntries(Object.entries(vars).filter((entry) => entry[1] !== void 0));
|
|
@@ -5466,6 +6069,7 @@ var PiSession = class _PiSession {
|
|
|
5466
6069
|
result = { outcome: "completed" };
|
|
5467
6070
|
signature = "";
|
|
5468
6071
|
translator = new LiveTranslator();
|
|
6072
|
+
prompts;
|
|
5469
6073
|
get context() {
|
|
5470
6074
|
const usage = this.session.getContextUsage();
|
|
5471
6075
|
if (!usage) return void 0;
|
|
@@ -5489,7 +6093,7 @@ var PiSession = class _PiSession {
|
|
|
5489
6093
|
if (!resolve2(reference.file).startsWith(expected) || dirname3(resolve2(reference.file)) !== resolve2(sessionsDir))
|
|
5490
6094
|
throw new RuntimeFault("session_path", "The session file path isn't valid.");
|
|
5491
6095
|
const exists = existsSync3(reference.file);
|
|
5492
|
-
if (!exists && (reference.persisted || store.runtime.runs(bot.id).some((
|
|
6096
|
+
if (!exists && (reference.persisted || store.runtime.runs(bot.id).some((run2) => run2.state === "unknown")))
|
|
5493
6097
|
throw new RuntimeFault("session_missing", "The session file is missing; check the run records before recovering.");
|
|
5494
6098
|
if (exists) {
|
|
5495
6099
|
let header;
|
|
@@ -5512,6 +6116,7 @@ var PiSession = class _PiSession {
|
|
|
5512
6116
|
const model = installModel(models, connection, configured2, store.modelHeaders(connection.id, configured2.id));
|
|
5513
6117
|
const sessionManager = SessionManager.open(reference.file, sessionsDir, bot.workspacePath);
|
|
5514
6118
|
const adapter = new _PiSession(options, models, credentials);
|
|
6119
|
+
adapter.prompts = new PromptLog(options.systemPrompt);
|
|
5515
6120
|
configureHttp(HTTP_IDLE_TIMEOUT_MS);
|
|
5516
6121
|
const settingsManager = SettingsManager.inMemory({
|
|
5517
6122
|
httpIdleTimeoutMs: HTTP_IDLE_TIMEOUT_MS,
|
|
@@ -5526,7 +6131,7 @@ var PiSession = class _PiSession {
|
|
|
5526
6131
|
noSkills: true,
|
|
5527
6132
|
noPromptTemplates: true,
|
|
5528
6133
|
noContextFiles: true,
|
|
5529
|
-
systemPromptOverride:
|
|
6134
|
+
systemPromptOverride: () => adapter.prompts.current,
|
|
5530
6135
|
appendSystemPromptOverride: () => [],
|
|
5531
6136
|
extensionFactories: [createCodemodeExtension(), (api) => {
|
|
5532
6137
|
for (const tool of options.tools) api.registerTool({
|
|
@@ -5544,17 +6149,17 @@ var PiSession = class _PiSession {
|
|
|
5544
6149
|
return { content: [{ type: "text", text: typeof output === "string" ? output : JSON.stringify(output) }], details: {} };
|
|
5545
6150
|
}
|
|
5546
6151
|
});
|
|
5547
|
-
api.on("before_agent_start", () => ({ systemPrompt:
|
|
6152
|
+
api.on("before_agent_start", () => ({ systemPrompt: adapter.prompts.current }));
|
|
5548
6153
|
api.on("tool_result", (event) => {
|
|
5549
6154
|
const masker = options.secrets?.masker();
|
|
5550
6155
|
if (!masker || masker.empty) return void 0;
|
|
5551
6156
|
let changed = false;
|
|
5552
6157
|
const content2 = event.content.map((part) => {
|
|
5553
6158
|
if (part.type !== "text") return part;
|
|
5554
|
-
const
|
|
5555
|
-
if (
|
|
6159
|
+
const text4 = masker.mask(part.text);
|
|
6160
|
+
if (text4 === part.text) return part;
|
|
5556
6161
|
changed = true;
|
|
5557
|
-
return { ...part, text:
|
|
6162
|
+
return { ...part, text: text4 };
|
|
5558
6163
|
});
|
|
5559
6164
|
if (!changed) return void 0;
|
|
5560
6165
|
return { content: content2, ...event.structuredContent !== void 0 ? { structuredContent: masker.maskDeep(event.structuredContent) } : {} };
|
|
@@ -5624,6 +6229,7 @@ var PiSession = class _PiSession {
|
|
|
5624
6229
|
this.options.activity("compacting");
|
|
5625
6230
|
console.log("[pi] compaction started", { botId: this.options.bot.id, reason: event.reason });
|
|
5626
6231
|
}
|
|
6232
|
+
if (event.type === "compaction_end" && event.result && !event.aborted && !event.errorMessage) this.prompts.take(this.options.systemPrompt);
|
|
5627
6233
|
if (event.type === "compaction_end") console.log(
|
|
5628
6234
|
event.errorMessage ? "[pi] compaction failed" : "[pi] compaction finished",
|
|
5629
6235
|
{
|
|
@@ -5718,12 +6324,19 @@ var PiSession = class _PiSession {
|
|
|
5718
6324
|
this.settings.applyOverrides({ compaction: { reserveTokens: compactionReserve(model.contextWindow) } });
|
|
5719
6325
|
this.session.setThinkingLevel(resolveThinking(model, selection.thinkingLevel), { persist: false });
|
|
5720
6326
|
this.signature = signature;
|
|
6327
|
+
this.prompts.take(this.options.systemPrompt);
|
|
5721
6328
|
return this.session.thinkingLevel;
|
|
5722
6329
|
} catch {
|
|
5723
6330
|
this.credentials.current = previous;
|
|
5724
6331
|
throw new RuntimeFault("model_switch_uncertain", "The model switch didn't finish. Pick the model again.", 409);
|
|
5725
6332
|
}
|
|
5726
6333
|
}
|
|
6334
|
+
promptChange() {
|
|
6335
|
+
return this.prompts.pending(this.options.systemPrompt);
|
|
6336
|
+
}
|
|
6337
|
+
promptTold(change) {
|
|
6338
|
+
this.prompts.sent(change);
|
|
6339
|
+
}
|
|
5727
6340
|
async dispose() {
|
|
5728
6341
|
this.requestAbort();
|
|
5729
6342
|
await this.abortTask;
|
|
@@ -5771,12 +6384,13 @@ function overDurable(id, kind, env, options) {
|
|
|
5771
6384
|
id,
|
|
5772
6385
|
kind,
|
|
5773
6386
|
cwd: env.cwd,
|
|
6387
|
+
...options.temp ? { temp: options.temp } : {},
|
|
5774
6388
|
async exec(command, exec) {
|
|
5775
6389
|
const result = await env.exec(command, {
|
|
5776
6390
|
cwd: exec.cwd,
|
|
5777
6391
|
...options.passEnv && exec.env ? { env: { ...exec.env, ...exec.secrets }, inheritEnv: false } : { ...exec.secrets ? { env: exec.secrets } : {}, inheritEnv: true },
|
|
5778
6392
|
...exec.timeout ? { timeout: exec.timeout } : {},
|
|
5779
|
-
onOutput: (
|
|
6393
|
+
onOutput: (text4) => exec.onData(Buffer.from(text4))
|
|
5780
6394
|
}, context(exec.signal));
|
|
5781
6395
|
if (result.ok) return { exitCode: result.value.exitCode };
|
|
5782
6396
|
if (result.error.code === "aborted") throw new Error("aborted");
|
|
@@ -5950,16 +6564,16 @@ async function removeContainer(name3) {
|
|
|
5950
6564
|
async function syncDaemon(dir) {
|
|
5951
6565
|
const arch = process.arch === "arm64" ? "arm64" : "x64";
|
|
5952
6566
|
const wanted = await readFile2(packagedDaemon({ platform: "linux", arch }));
|
|
5953
|
-
const
|
|
5954
|
-
const current = await readFile2(
|
|
6567
|
+
const target3 = join6(dir, "pi-env");
|
|
6568
|
+
const current = await readFile2(target3).catch(() => null);
|
|
5955
6569
|
if (current?.equals(wanted)) return;
|
|
5956
6570
|
await mkdir3(dir, { recursive: true, mode: 448 });
|
|
5957
|
-
const temp = `${
|
|
6571
|
+
const temp = `${target3}.${process.pid}.tmp`;
|
|
5958
6572
|
await writeFile2(temp, wanted, { mode: 493 });
|
|
5959
|
-
await rename2(temp,
|
|
6573
|
+
await rename2(temp, target3);
|
|
5960
6574
|
}
|
|
5961
6575
|
function containerEnvironment(id, container, log) {
|
|
5962
|
-
const connection = new Connection({ command: ["docker", "exec", "-i", container, `${DAEMON_DIR}/pi-env`], onLog: (
|
|
6576
|
+
const connection = new Connection({ command: ["docker", "exec", "-i", container, `${DAEMON_DIR}/pi-env`], onLog: (text4) => log(text4.trim()) });
|
|
5963
6577
|
return overDurable(id, "computer", new RemoteExecutionEnv({ connection, id, cwd: COMPUTER_CWD }), { passEnv: false, close: () => connection.close() });
|
|
5964
6578
|
}
|
|
5965
6579
|
|
|
@@ -5972,32 +6586,35 @@ function hostEnvironment(cwd) {
|
|
|
5972
6586
|
// src/server/service/bot/environments.ts
|
|
5973
6587
|
var ABOUT = {
|
|
5974
6588
|
computer: "A Linux container only you use, with Node, Python, git, build tools, and Playwright with its browsers. You can install software there with sudo; it stays until the owner resets your sandbox, and /workspace, your home there, is kept when it is reset.",
|
|
5975
|
-
host: "The machine
|
|
6589
|
+
host: "The machine where this chat platform's server runs. Your tools run there as your owner and can reach their files: work in your directory, and leave other members' workspaces, live data directories (such as ~/.jarvy), main repository checkouts and system directories alone unless the task needs it; say in your reply what you changed there."
|
|
5976
6590
|
};
|
|
5977
6591
|
var BotEnvironments = class {
|
|
5978
|
-
constructor(store, computers) {
|
|
6592
|
+
constructor(store, computers, machines) {
|
|
5979
6593
|
this.store = store;
|
|
5980
6594
|
this.computers = computers;
|
|
6595
|
+
this.machines = machines;
|
|
5981
6596
|
}
|
|
5982
6597
|
open = /* @__PURE__ */ new Map();
|
|
5983
6598
|
current(botId, workspacePath) {
|
|
5984
|
-
const
|
|
6599
|
+
const target3 = this.store.environments.placement(botId).current, signature = this.signature(target3);
|
|
5985
6600
|
const open = this.open.get(botId);
|
|
5986
|
-
if (open?.id ===
|
|
6601
|
+
if (open?.id === signature) return open.view;
|
|
5987
6602
|
if (open) this.retire(open);
|
|
5988
|
-
const opened = this.track(
|
|
6603
|
+
const opened = this.track(signature, target3.kind === "computer" ? this.computers.environment(target3.botId ?? botId, target3) : target3.kind === "ssh" ? this.machines.environment(target3, botId) : hostEnvironment(workspacePath));
|
|
5989
6604
|
this.open.set(botId, opened);
|
|
5990
6605
|
return opened.view;
|
|
5991
6606
|
}
|
|
5992
6607
|
placement(botId) {
|
|
5993
6608
|
const environments = this.store.environments, { home, current } = environments.placement(botId);
|
|
5994
|
-
const hostId = environments.host().id;
|
|
6609
|
+
const hostId = environments.host().id, granted = new Set(environments.grants(botId).map((grant) => grant.id));
|
|
5995
6610
|
return {
|
|
5996
|
-
home: home.kind,
|
|
6611
|
+
home: home.kind === "computer" ? "computer" : "host",
|
|
5997
6612
|
current: current.kind,
|
|
6613
|
+
...current.kind === "ssh" ? { currentName: current.name } : {},
|
|
5998
6614
|
computer: this.computers.status(botId),
|
|
5999
6615
|
docker: this.computers.available,
|
|
6000
|
-
hostGranted:
|
|
6616
|
+
hostGranted: granted.has(hostId),
|
|
6617
|
+
machines: environments.sshMachines().map((machine) => ({ id: machine.id, name: machine.name, granted: granted.has(machine.id) }))
|
|
6001
6618
|
};
|
|
6002
6619
|
}
|
|
6003
6620
|
/** The environments the Bot may be in: its sandbox, its default, and the ones its owner granted it. */
|
|
@@ -6008,11 +6625,12 @@ var BotEnvironments = class {
|
|
|
6008
6625
|
}
|
|
6009
6626
|
place(botId, record3, workspacePath) {
|
|
6010
6627
|
if (record3.kind === "host") return { id: record3.id, kind: "host", name: "your owner's computer", directory: workspacePath };
|
|
6628
|
+
if (record3.kind === "ssh") return { id: record3.id, kind: "ssh", name: record3.name, directory: record3.directory ?? record3.spec.home ?? "/" };
|
|
6011
6629
|
return { id: record3.id, kind: "sandbox", name: record3.botId === botId ? "your sandbox" : "another Bot's sandbox", directory: COMPUTER_CWD };
|
|
6012
6630
|
}
|
|
6013
6631
|
/** A Bot's environments as other Bots see them in bots.list. */
|
|
6014
6632
|
summary(botId) {
|
|
6015
|
-
const name3 = (record3) => record3.kind === "host" ? "owner's computer" : record3.botId === botId ? "own sandbox" : "another Bot's sandbox";
|
|
6633
|
+
const name3 = (record3) => record3.kind === "host" ? "owner's computer" : record3.kind === "ssh" ? record3.name : record3.botId === botId ? "own sandbox" : "another Bot's sandbox";
|
|
6016
6634
|
const { home, current } = this.store.environments.placement(botId);
|
|
6017
6635
|
return { default: name3(home), current: name3(current), usable: this.usable(botId).map(name3) };
|
|
6018
6636
|
}
|
|
@@ -6030,18 +6648,37 @@ var BotEnvironments = class {
|
|
|
6030
6648
|
return { current: current.id, environments: this.usable(botId).map((record3) => ({
|
|
6031
6649
|
...this.place(botId, record3, workspacePath),
|
|
6032
6650
|
...record3.id === home.id ? { default: true } : {},
|
|
6033
|
-
...record3.kind === "computer" ? { state: this.computers.status(record3.botId ?? botId)?.state ?? "stopped" } : {}
|
|
6651
|
+
...record3.kind === "computer" ? { state: this.computers.status(record3.botId ?? botId)?.state ?? "stopped" } : {},
|
|
6652
|
+
...record3.kind === "ssh" ? { address: this.machines.address(record3) } : {}
|
|
6034
6653
|
})) };
|
|
6035
6654
|
}
|
|
6036
|
-
/** environments.switch: the Bot moves itself, like cd, until it switches back. A sandbox is started first
|
|
6037
|
-
*
|
|
6655
|
+
/** environments.switch: the Bot moves itself, like cd, until it switches back. A sandbox is started first, an SSH
|
|
6656
|
+
* machine connected first; if that fails, the Bot stays where it was. */
|
|
6038
6657
|
async switch(botId, environmentId, workspacePath) {
|
|
6039
|
-
const
|
|
6040
|
-
if (!
|
|
6041
|
-
const
|
|
6042
|
-
if (
|
|
6043
|
-
this.store.environments.setCurrent(botId,
|
|
6044
|
-
|
|
6658
|
+
const target3 = this.usable(botId).find((record3) => record3.id === environmentId);
|
|
6659
|
+
if (!target3) throw new ChatError(404, "No work environment with this id that you may use; see environments.list");
|
|
6660
|
+
const previous = this.store.environments.placement(botId).current, already = previous.id === target3.id;
|
|
6661
|
+
if (target3.kind === "computer") await this.computers.ensure(target3.botId ?? botId);
|
|
6662
|
+
this.store.environments.setCurrent(botId, target3.id);
|
|
6663
|
+
if (target3.kind === "ssh" && !already) {
|
|
6664
|
+
const place = this.place(botId, target3, workspacePath);
|
|
6665
|
+
try {
|
|
6666
|
+
if (!await this.current(botId, workspacePath).exists(place.directory)) throw new Error(`${place.directory} doesn't exist there; ask your owner to change the machine's directory, or switch back`);
|
|
6667
|
+
} catch (error) {
|
|
6668
|
+
const opened = this.open.get(botId);
|
|
6669
|
+
if (opened) {
|
|
6670
|
+
this.open.delete(botId);
|
|
6671
|
+
this.retire(opened);
|
|
6672
|
+
}
|
|
6673
|
+
this.store.environments.setCurrent(botId, previous.id);
|
|
6674
|
+
throw new ChatError(409, `Couldn't connect to ${target3.name}, so you stay where you were: ${error instanceof Error ? error.message : String(error)}`);
|
|
6675
|
+
}
|
|
6676
|
+
}
|
|
6677
|
+
return {
|
|
6678
|
+
...this.place(botId, target3, workspacePath),
|
|
6679
|
+
about: target3.kind === "ssh" ? this.machines.about(target3, botId) : ABOUT[target3.kind],
|
|
6680
|
+
...already ? { note: "You were already here." } : {}
|
|
6681
|
+
};
|
|
6045
6682
|
}
|
|
6046
6683
|
/** The owner makes the Bot's sandbox (given it on first use) or their computer its default, and moves it there. */
|
|
6047
6684
|
setHome(botId, kind, workspacePath) {
|
|
@@ -6058,15 +6695,18 @@ var BotEnvironments = class {
|
|
|
6058
6695
|
const to = this.here(botId, workspacePath);
|
|
6059
6696
|
return from.id === to.id ? null : { from, to };
|
|
6060
6697
|
}
|
|
6061
|
-
/** The owner grants or takes back their computer (A50); taking it back while the Bot works
|
|
6062
|
-
|
|
6063
|
-
|
|
6064
|
-
|
|
6065
|
-
|
|
6066
|
-
|
|
6698
|
+
/** The owner grants or takes back their computer (A50) or an SSH machine (A57); taking it back while the Bot works
|
|
6699
|
+
* there sends it home. */
|
|
6700
|
+
grant(botId, environmentId, granted, by, workspacePath) {
|
|
6701
|
+
const environments = this.store.environments, target3 = environmentId === "host" ? environments.host() : environments.get(environmentId);
|
|
6702
|
+
if (!target3 || target3.kind === "computer") throw new ChatError(404, "No such environment");
|
|
6703
|
+
const name3 = this.place(botId, target3, workspacePath).name;
|
|
6704
|
+
if (granted) return { changed: environments.grant(target3.id, botId, by), moved: null, name: name3 };
|
|
6705
|
+
const changed = environments.revoke(target3.id, botId), { home, current } = environments.placement(botId);
|
|
6706
|
+
if (current.id !== target3.id || home.id === target3.id) return { changed, moved: null, name: name3 };
|
|
6067
6707
|
const from = this.here(botId, workspacePath);
|
|
6068
6708
|
environments.setCurrent(botId, home.id);
|
|
6069
|
-
return { changed, moved: { from, to: this.here(botId, workspacePath) } };
|
|
6709
|
+
return { changed, moved: { from, to: this.here(botId, workspacePath) }, name: name3 };
|
|
6070
6710
|
}
|
|
6071
6711
|
async release(botId) {
|
|
6072
6712
|
const open = this.open.get(botId);
|
|
@@ -6080,6 +6720,10 @@ var BotEnvironments = class {
|
|
|
6080
6720
|
await Promise.all(all.map((open) => open.env.close()));
|
|
6081
6721
|
await this.computers.close();
|
|
6082
6722
|
}
|
|
6723
|
+
/** What an open environment is for: changing a machine's directory reopens it where the owner put it. */
|
|
6724
|
+
signature(record3) {
|
|
6725
|
+
return record3.kind === "ssh" ? `${record3.id}@${record3.directory ?? ""}` : record3.id;
|
|
6726
|
+
}
|
|
6083
6727
|
retire(open) {
|
|
6084
6728
|
open.retired = true;
|
|
6085
6729
|
if (!open.busy) void open.env.close();
|
|
@@ -6087,7 +6731,7 @@ var BotEnvironments = class {
|
|
|
6087
6731
|
/** Counts the operations running on an environment, so one the Bot has left closes only when they are done. */
|
|
6088
6732
|
track(id, env) {
|
|
6089
6733
|
const opened = { id, env, view: env, busy: 0, retired: false };
|
|
6090
|
-
const
|
|
6734
|
+
const run2 = async (work) => {
|
|
6091
6735
|
opened.busy++;
|
|
6092
6736
|
try {
|
|
6093
6737
|
return await work();
|
|
@@ -6099,13 +6743,14 @@ var BotEnvironments = class {
|
|
|
6099
6743
|
id: env.id,
|
|
6100
6744
|
kind: env.kind,
|
|
6101
6745
|
cwd: env.cwd,
|
|
6102
|
-
|
|
6103
|
-
|
|
6104
|
-
|
|
6105
|
-
|
|
6106
|
-
|
|
6107
|
-
|
|
6108
|
-
|
|
6746
|
+
...env.temp ? { temp: env.temp } : {},
|
|
6747
|
+
exec: (command, options) => run2(() => env.exec(command, options)),
|
|
6748
|
+
readFile: (path) => run2(() => env.readFile(path)),
|
|
6749
|
+
head: (path, bytes) => run2(() => env.head(path, bytes)),
|
|
6750
|
+
writeFile: (path, content2) => run2(() => env.writeFile(path, content2)),
|
|
6751
|
+
mkdir: (path) => run2(() => env.mkdir(path)),
|
|
6752
|
+
exists: (path) => run2(() => env.exists(path)),
|
|
6753
|
+
fileSize: (path) => run2(() => env.fileSize(path)),
|
|
6109
6754
|
close: () => env.close()
|
|
6110
6755
|
};
|
|
6111
6756
|
return opened;
|
|
@@ -6174,7 +6819,7 @@ var Computers = class {
|
|
|
6174
6819
|
}
|
|
6175
6820
|
/** What the Bot's tools use while it works on its computer: every call first makes sure the container runs. */
|
|
6176
6821
|
environment(botId, record3) {
|
|
6177
|
-
const
|
|
6822
|
+
const run2 = async (op) => {
|
|
6178
6823
|
try {
|
|
6179
6824
|
return await op(await this.connect(botId, record3));
|
|
6180
6825
|
} catch (error) {
|
|
@@ -6188,13 +6833,13 @@ var Computers = class {
|
|
|
6188
6833
|
id: record3.id,
|
|
6189
6834
|
kind: "computer",
|
|
6190
6835
|
cwd: COMPUTER_CWD,
|
|
6191
|
-
exec: (command, options) =>
|
|
6192
|
-
readFile: (path) =>
|
|
6193
|
-
head: (path, bytes) =>
|
|
6194
|
-
writeFile: (path, content2) =>
|
|
6195
|
-
mkdir: (path) =>
|
|
6196
|
-
exists: (path) =>
|
|
6197
|
-
fileSize: (path) =>
|
|
6836
|
+
exec: (command, options) => run2((env) => env.exec(command, options)),
|
|
6837
|
+
readFile: (path) => run2((env) => env.readFile(path)),
|
|
6838
|
+
head: (path, bytes) => run2((env) => env.head(path, bytes)),
|
|
6839
|
+
writeFile: (path, content2) => run2((env) => env.writeFile(path, content2)),
|
|
6840
|
+
mkdir: (path) => run2((env) => env.mkdir(path)),
|
|
6841
|
+
exists: (path) => run2((env) => env.exists(path)),
|
|
6842
|
+
fileSize: (path) => run2((env) => env.fileSize(path)),
|
|
6198
6843
|
close: () => this.disconnect(botId)
|
|
6199
6844
|
};
|
|
6200
6845
|
}
|
|
@@ -6208,7 +6853,7 @@ var Computers = class {
|
|
|
6208
6853
|
await home;
|
|
6209
6854
|
let env = this.connections.get(botId);
|
|
6210
6855
|
if (!env) {
|
|
6211
|
-
env = containerEnvironment(record3.id, container, (
|
|
6856
|
+
env = containerEnvironment(record3.id, container, (text4) => this.log(`[${botId.slice(0, 8)}] ${text4}`));
|
|
6212
6857
|
this.connections.set(botId, env);
|
|
6213
6858
|
}
|
|
6214
6859
|
return env;
|
|
@@ -6336,15 +6981,502 @@ var Computers = class {
|
|
|
6336
6981
|
this.set(botId, { state: "running" });
|
|
6337
6982
|
}
|
|
6338
6983
|
}
|
|
6339
|
-
log(
|
|
6340
|
-
(this.deps.log ?? console.log)(`[computers] ${
|
|
6984
|
+
log(text4) {
|
|
6985
|
+
(this.deps.log ?? console.log)(`[computers] ${text4}`);
|
|
6341
6986
|
}
|
|
6342
6987
|
};
|
|
6343
6988
|
|
|
6989
|
+
// src/server/service/bot/machines.ts
|
|
6990
|
+
import { randomUUID as randomUUID12 } from "node:crypto";
|
|
6991
|
+
import { dirname as dirname5, join as join9, posix } from "node:path";
|
|
6992
|
+
|
|
6993
|
+
// src/server/infra/env/ssh.ts
|
|
6994
|
+
import { spawn as spawn2 } from "node:child_process";
|
|
6995
|
+
import { mkdirSync as mkdirSync4, rmSync as rmSync2, writeFileSync as writeFileSync3 } from "node:fs";
|
|
6996
|
+
import { mkdir as mkdir4, mkdtemp as mkdtemp2, readFile as readFile3, rm as rm3 } from "node:fs/promises";
|
|
6997
|
+
import { join as join8 } from "node:path";
|
|
6998
|
+
import { HostKeyChangedError, HostKeyUnknownError, RemoteExecutionEnv as RemoteExecutionEnv2, detectPlatform, scanHostKey, sshArguments, sshConnection } from "@earendil-works/pi-env";
|
|
6999
|
+
var SshFault = class extends Error {
|
|
7000
|
+
constructor(code, message) {
|
|
7001
|
+
super(message);
|
|
7002
|
+
this.code = code;
|
|
7003
|
+
}
|
|
7004
|
+
};
|
|
7005
|
+
var SCAN_ALIAS = "jarvy-scan";
|
|
7006
|
+
var TIMEOUT_MS2 = 3e4;
|
|
7007
|
+
async function scanHost(address2, runtime) {
|
|
7008
|
+
await mkdir4(runtime, { recursive: true, mode: 448 });
|
|
7009
|
+
try {
|
|
7010
|
+
const scanned = await scanHostKey({ ...target(address2, SCAN_ALIAS), knownHostsFile: join8(runtime, "none") });
|
|
7011
|
+
return {
|
|
7012
|
+
keys: scanned.lines.map((line) => line.trim().split(/\s+/).slice(1, 3).join(" ")),
|
|
7013
|
+
fingerprints: scanned.fingerprints.map((line) => line.replace(` ${SCAN_ALIAS} `, " ").replace(/^\d+\s+/, ""))
|
|
7014
|
+
};
|
|
7015
|
+
} catch (error) {
|
|
7016
|
+
throw classify(error, address2);
|
|
7017
|
+
}
|
|
7018
|
+
}
|
|
7019
|
+
async function generateKey(runtime, comment) {
|
|
7020
|
+
await mkdir4(runtime, { recursive: true, mode: 448 });
|
|
7021
|
+
const directory = await mkdtemp2(join8(runtime, "keygen-"));
|
|
7022
|
+
try {
|
|
7023
|
+
await run("ssh-keygen", ["-q", "-t", "ed25519", "-N", "", "-C", comment, "-f", join8(directory, "key")]);
|
|
7024
|
+
return { privateKey: await readFile3(join8(directory, "key"), "utf8"), publicKey: (await readFile3(join8(directory, "key.pub"), "utf8")).trim() };
|
|
7025
|
+
} finally {
|
|
7026
|
+
await rm3(directory, { recursive: true, force: true });
|
|
7027
|
+
}
|
|
7028
|
+
}
|
|
7029
|
+
function publicKeyOf(privateKey, comment) {
|
|
7030
|
+
try {
|
|
7031
|
+
const body = Buffer.from(privateKey.replace(/-----[^-]+-----/g, "").replace(/\s+/g, ""), "base64"), magic = "openssh-key-v1\0";
|
|
7032
|
+
if (body.subarray(0, magic.length).toString("latin1") !== magic) return null;
|
|
7033
|
+
let at2 = magic.length;
|
|
7034
|
+
const field2 = () => {
|
|
7035
|
+
const size = body.readUInt32BE(at2);
|
|
7036
|
+
const value2 = body.subarray(at2 + 4, at2 + 4 + size);
|
|
7037
|
+
at2 += 4 + size;
|
|
7038
|
+
return value2;
|
|
7039
|
+
};
|
|
7040
|
+
field2();
|
|
7041
|
+
field2();
|
|
7042
|
+
field2();
|
|
7043
|
+
if (body.readUInt32BE(at2) !== 1) return null;
|
|
7044
|
+
at2 += 4;
|
|
7045
|
+
const blob = field2(), type = blob.subarray(4, 4 + blob.readUInt32BE(0)).toString("latin1");
|
|
7046
|
+
return `${type} ${blob.toString("base64")} ${comment}`;
|
|
7047
|
+
} catch {
|
|
7048
|
+
return null;
|
|
7049
|
+
}
|
|
7050
|
+
}
|
|
7051
|
+
async function installKey(address2, runtime, hostKeys2, password, publicKey) {
|
|
7052
|
+
const access = { ...address2, runtime, alias: SCAN_ALIAS, hostKeys: hostKeys2, privateKey: "", password };
|
|
7053
|
+
await withFiles(access, async (files) => {
|
|
7054
|
+
const script = [
|
|
7055
|
+
"umask 077",
|
|
7056
|
+
'mkdir -p "$HOME/.ssh"',
|
|
7057
|
+
'f="$HOME/.ssh/authorized_keys"',
|
|
7058
|
+
'touch "$f"',
|
|
7059
|
+
"IFS= read -r key",
|
|
7060
|
+
`grep -qxF "$key" "$f" || printf '%s\\n' "$key" >> "$f"`
|
|
7061
|
+
].join("\n");
|
|
7062
|
+
await runSsh(access, files, `sh -c ${quote(script)}`, `${publicKey}
|
|
7063
|
+
`, "password");
|
|
7064
|
+
});
|
|
7065
|
+
}
|
|
7066
|
+
async function probeHost(access) {
|
|
7067
|
+
return withFiles(access, async (files) => {
|
|
7068
|
+
try {
|
|
7069
|
+
const remote = await detectPlatform({ ...target(access, access.alias), ...files });
|
|
7070
|
+
if (remote.platform !== "linux" && remote.platform !== "darwin") throw new SshFault("ssh_unsupported", `Unsupported system: ${remote.platform}`);
|
|
7071
|
+
return { platform: remote.platform, home: remote.home };
|
|
7072
|
+
} catch (error) {
|
|
7073
|
+
throw classify(error, access, access.password ? "password" : "key");
|
|
7074
|
+
}
|
|
7075
|
+
});
|
|
7076
|
+
}
|
|
7077
|
+
async function removeKey(access, comment) {
|
|
7078
|
+
await withFiles(access, async (files) => {
|
|
7079
|
+
const script = [
|
|
7080
|
+
"umask 077",
|
|
7081
|
+
'f="$HOME/.ssh/authorized_keys"',
|
|
7082
|
+
'[ -f "$f" ] || exit 0',
|
|
7083
|
+
`awk ${quote(`$NF != "${comment}"`)} "$f" > "$f.jarvy" && cat "$f.jarvy" > "$f"`,
|
|
7084
|
+
"s=$?",
|
|
7085
|
+
'rm -f "$f.jarvy"',
|
|
7086
|
+
"exit $s"
|
|
7087
|
+
].join("\n");
|
|
7088
|
+
await runSsh(access, files, `sh -c ${quote(script)}`, "", access.password ? "password" : "key");
|
|
7089
|
+
});
|
|
7090
|
+
}
|
|
7091
|
+
function sshEnvironment(id, access, directory, temp, onLog) {
|
|
7092
|
+
mkdirSync4(access.runtime, { recursive: true, mode: 448 });
|
|
7093
|
+
const folder = join8(access.runtime, `open-${id}-${process.pid}-${Date.now().toString(36)}`);
|
|
7094
|
+
const files = writeFilesSync(folder, access);
|
|
7095
|
+
const { connection } = sshConnection({ ...target(access, access.alias), ...files, ...onLog ? { onLog } : {} });
|
|
7096
|
+
return overDurable(id, "ssh", new RemoteExecutionEnv2({ connection, id: `ssh:${id}`, cwd: directory }), {
|
|
7097
|
+
passEnv: false,
|
|
7098
|
+
temp,
|
|
7099
|
+
close: () => {
|
|
7100
|
+
connection.close();
|
|
7101
|
+
rmSync2(folder, { recursive: true, force: true });
|
|
7102
|
+
}
|
|
7103
|
+
});
|
|
7104
|
+
}
|
|
7105
|
+
function clearRuntime(runtime) {
|
|
7106
|
+
rmSync2(runtime, { recursive: true, force: true });
|
|
7107
|
+
}
|
|
7108
|
+
function target(address2, alias2) {
|
|
7109
|
+
return { host: address2.host, port: address2.port, user: address2.user, hostKeyAlias: alias2, configFile: "/dev/null" };
|
|
7110
|
+
}
|
|
7111
|
+
async function withFiles(access, work) {
|
|
7112
|
+
await mkdir4(access.runtime, { recursive: true, mode: 448 });
|
|
7113
|
+
const directory = await mkdtemp2(join8(access.runtime, "use-"));
|
|
7114
|
+
try {
|
|
7115
|
+
return await work(writeFilesSync(directory, access));
|
|
7116
|
+
} finally {
|
|
7117
|
+
await rm3(directory, { recursive: true, force: true });
|
|
7118
|
+
}
|
|
7119
|
+
}
|
|
7120
|
+
function writeFilesSync(directory, access) {
|
|
7121
|
+
mkdirSync4(directory, { recursive: true, mode: 448 });
|
|
7122
|
+
const knownHostsFile = join8(directory, "known_hosts");
|
|
7123
|
+
writeFileSync3(knownHostsFile, access.hostKeys.map((key) => `${access.alias} ${key}
|
|
7124
|
+
`).join(""), { mode: 384 });
|
|
7125
|
+
if (access.password) {
|
|
7126
|
+
const secret = join8(directory, "password"), askpass = join8(directory, "askpass"), ssh = join8(directory, "ssh");
|
|
7127
|
+
writeFileSync3(secret, `${access.password}
|
|
7128
|
+
`, { mode: 384 });
|
|
7129
|
+
writeFileSync3(askpass, `#!/bin/sh
|
|
7130
|
+
exec cat ${quote(secret)}
|
|
7131
|
+
`, { mode: 448 });
|
|
7132
|
+
writeFileSync3(ssh, ["#!/bin/sh", `exec env -u SSH_AUTH_SOCK SSH_ASKPASS=${quote(askpass)} SSH_ASKPASS_REQUIRE=force DISPLAY=none ssh -o BatchMode=no -o PreferredAuthentications=keyboard-interactive,password -o PubkeyAuthentication=no -o NumberOfPasswordPrompts=1 -o ConnectTimeout=15 "$@"`, ""].join("\n"), { mode: 448 });
|
|
7133
|
+
return { knownHostsFile, ssh };
|
|
7134
|
+
}
|
|
7135
|
+
const identityFile = join8(directory, "key");
|
|
7136
|
+
writeFileSync3(identityFile, access.privateKey.endsWith("\n") ? access.privateKey : `${access.privateKey}
|
|
7137
|
+
`, { mode: 384 });
|
|
7138
|
+
return { knownHostsFile, identityFile };
|
|
7139
|
+
}
|
|
7140
|
+
function runSsh(access, files, command, stdin, how) {
|
|
7141
|
+
const { ssh, ...rest } = files;
|
|
7142
|
+
return run(ssh ?? "ssh", [...sshArguments({ ...target(access, access.alias), ...rest }), command], stdin).catch((error) => {
|
|
7143
|
+
throw classify(error, access, how);
|
|
7144
|
+
});
|
|
7145
|
+
}
|
|
7146
|
+
function run(program, args, stdin = "", env) {
|
|
7147
|
+
return new Promise((resolve5, reject) => {
|
|
7148
|
+
const child = spawn2(program, args, { stdio: ["pipe", "pipe", "pipe"], ...env ? { env } : {}, detached: true });
|
|
7149
|
+
const out = [], err = [];
|
|
7150
|
+
const timer = setTimeout(() => child.kill(), TIMEOUT_MS2);
|
|
7151
|
+
child.stdout.on("data", (chunk) => out.push(chunk));
|
|
7152
|
+
child.stderr.on("data", (chunk) => err.push(chunk));
|
|
7153
|
+
child.stdin.on("error", () => {
|
|
7154
|
+
});
|
|
7155
|
+
child.on("error", (error) => {
|
|
7156
|
+
clearTimeout(timer);
|
|
7157
|
+
reject(error);
|
|
7158
|
+
});
|
|
7159
|
+
child.on("close", (code) => {
|
|
7160
|
+
clearTimeout(timer);
|
|
7161
|
+
if (code === 0) resolve5(Buffer.concat(out).toString("utf8"));
|
|
7162
|
+
else reject(new Error(Buffer.concat(err).toString("utf8").trim() || `${program} exited with ${code ?? "a signal"}`));
|
|
7163
|
+
});
|
|
7164
|
+
child.stdin.end(stdin);
|
|
7165
|
+
});
|
|
7166
|
+
}
|
|
7167
|
+
function classify(error, address2, how = "key") {
|
|
7168
|
+
if (error instanceof SshFault) return error;
|
|
7169
|
+
const text4 = error instanceof Error ? error.message : String(error), where2 = `${address2.user}@${address2.host}:${address2.port}`;
|
|
7170
|
+
if (error instanceof HostKeyChangedError || /REMOTE HOST IDENTIFICATION HAS CHANGED|Host key verification failed/.test(text4))
|
|
7171
|
+
return new SshFault("ssh_host_key_changed", `The host key of ${where2} is not the one accepted`);
|
|
7172
|
+
if (error instanceof HostKeyUnknownError) return new SshFault("ssh_host_key_changed", `The host key of ${where2} is not the one accepted`);
|
|
7173
|
+
const denied = /Permission denied \(([^)]*)\)/.exec(text4);
|
|
7174
|
+
if (denied || /Permission denied|Too many authentication failures/.test(text4)) {
|
|
7175
|
+
const methods = denied ? denied[1].split(",").map((method) => method.trim()) : [];
|
|
7176
|
+
if (how === "password" && methods.length && !methods.some((method) => method === "password" || method === "keyboard-interactive"))
|
|
7177
|
+
return new SshFault("ssh_password_refused", `${where2} doesn't accept password logins`);
|
|
7178
|
+
if (how === "key") return new SshFault("ssh_key_refused", `${where2} didn't accept Jarvy's key`);
|
|
7179
|
+
return new SshFault("ssh_auth_failed", `${where2} refused the password`);
|
|
7180
|
+
}
|
|
7181
|
+
if (/Connection refused|timed out|No route to host|Could not resolve|Network is unreachable|Connection closed|No host key received/i.test(text4))
|
|
7182
|
+
return new SshFault("ssh_unreachable", `${where2} can't be reached: ${text4.split("\n").pop()}`);
|
|
7183
|
+
return new SshFault("ssh_failed", text4.split("\n").slice(-3).join(" ").slice(0, 300) || `ssh ${where2} failed`);
|
|
7184
|
+
}
|
|
7185
|
+
var quote = (text4) => `'${text4.replaceAll("'", "'\\''")}'`;
|
|
7186
|
+
|
|
7187
|
+
// src/server/service/bot/machines.ts
|
|
7188
|
+
var HOST = /^(?!-)[A-Za-z0-9._:-]{1,253}$/;
|
|
7189
|
+
var USER = /^(?!-)[A-Za-z0-9._][A-Za-z0-9._-]{0,63}$/;
|
|
7190
|
+
var PREPARED_MS = 60 * 6e4;
|
|
7191
|
+
var SshMachines = class {
|
|
7192
|
+
/** `changed`: what Bots may use changed, so the owner's page reloads them. */
|
|
7193
|
+
constructor(store, chats, changed = () => {
|
|
7194
|
+
}) {
|
|
7195
|
+
this.store = store;
|
|
7196
|
+
this.chats = chats;
|
|
7197
|
+
this.changed = changed;
|
|
7198
|
+
this.runtime = join9(dirname5(store.path), "ssh");
|
|
7199
|
+
clearRuntime(this.runtime);
|
|
7200
|
+
}
|
|
7201
|
+
runtime;
|
|
7202
|
+
prepared = /* @__PURE__ */ new Map();
|
|
7203
|
+
list() {
|
|
7204
|
+
return this.store.environments.sshMachines().map((record3) => this.view(record3));
|
|
7205
|
+
}
|
|
7206
|
+
get(id) {
|
|
7207
|
+
return this.view(this.need(id));
|
|
7208
|
+
}
|
|
7209
|
+
/** The host keys a machine presents, to show the owner before the password is typed. */
|
|
7210
|
+
async scan(input) {
|
|
7211
|
+
return this.wrap(() => scanHost(address(input), this.runtime));
|
|
7212
|
+
}
|
|
7213
|
+
/** A key for a machine the owner will add its public half to themselves; `keyId` then adds the machine. */
|
|
7214
|
+
async prepareKey() {
|
|
7215
|
+
const now = Date.now();
|
|
7216
|
+
for (const [keyId2, key2] of this.prepared) if (key2.until < now) this.prepared.delete(keyId2);
|
|
7217
|
+
const id = randomUUID12(), key = await this.wrap(() => generateKey(this.runtime, keyComment(id)));
|
|
7218
|
+
this.prepared.set(id, { id, ...key, until: now + PREPARED_MS });
|
|
7219
|
+
return { keyId: id, publicKey: key.publicKey };
|
|
7220
|
+
}
|
|
7221
|
+
/** Adds a machine and saves it once a login works; nothing is saved when a step fails. With `password`, Jarvy makes
|
|
7222
|
+
* a key and installs it with that password; when the machine then refuses the key, the password itself is kept
|
|
7223
|
+
* (sealed) and used to log in (A59), and the key stays installed for when keys are allowed. With `keyId`, the owner
|
|
7224
|
+
* has added that prepared key to the machine, and a login with it is checked. */
|
|
7225
|
+
async add(input, grantTo) {
|
|
7226
|
+
const where2 = address(input), name3 = this.freeName(text3(input.name, 40) || `${where2.user}@${where2.host}`);
|
|
7227
|
+
const password = typeof input.password === "string" && input.password ? input.password : null;
|
|
7228
|
+
const prepared = typeof input.keyId === "string" ? this.prepared.get(input.keyId) : void 0;
|
|
7229
|
+
if (!password && input.keyId === void 0) throw new ChatError(400, "A password is needed to install the key", "ssh_password_missing");
|
|
7230
|
+
if (!password && (!prepared || prepared.until < Date.now())) throw new ChatError(409, "That key is no longer kept; show a new one and add it to the machine", "ssh_key_expired");
|
|
7231
|
+
if (!input.hostKeys.keys.length) throw new ChatError(400, "The host keys are missing; scan the machine first");
|
|
7232
|
+
directoryIn("/", input.directory);
|
|
7233
|
+
const id = prepared && !password ? prepared.id : randomUUID12(), comment = keyComment(id);
|
|
7234
|
+
const record3 = await this.wrap(async () => {
|
|
7235
|
+
const key = prepared && !password ? prepared : await generateKey(this.runtime, comment);
|
|
7236
|
+
const access = { ...where2, runtime: this.runtime, alias: alias(id), hostKeys: input.hostKeys.keys, privateKey: key.privateKey };
|
|
7237
|
+
const save = (remote, kept) => this.store.environments.addSsh({
|
|
7238
|
+
name: name3,
|
|
7239
|
+
...where2,
|
|
7240
|
+
...remote,
|
|
7241
|
+
directory: directoryIn(remote.home, input.directory),
|
|
7242
|
+
hostKeys: input.hostKeys,
|
|
7243
|
+
privateKey: key.privateKey,
|
|
7244
|
+
...kept ? { password: kept } : {}
|
|
7245
|
+
}, id);
|
|
7246
|
+
if (!password) return save(await probeHost(access));
|
|
7247
|
+
await installKey(where2, this.runtime, input.hostKeys.keys, password, key.publicKey);
|
|
7248
|
+
try {
|
|
7249
|
+
try {
|
|
7250
|
+
return save(await probeHost(access));
|
|
7251
|
+
} catch (error) {
|
|
7252
|
+
if (!(error instanceof SshFault) || error.code !== "ssh_key_refused") throw error;
|
|
7253
|
+
return save(await probeHost({ ...access, password }), password);
|
|
7254
|
+
}
|
|
7255
|
+
} catch (error) {
|
|
7256
|
+
await removeKey(password ? { ...access, password } : access, comment).catch(() => {
|
|
7257
|
+
});
|
|
7258
|
+
throw error;
|
|
7259
|
+
}
|
|
7260
|
+
});
|
|
7261
|
+
if (prepared) this.prepared.delete(prepared.id);
|
|
7262
|
+
if (grantTo) this.store.environments.grant(record3.id, grantTo.botId, grantTo.by);
|
|
7263
|
+
this.changed();
|
|
7264
|
+
return this.view(record3);
|
|
7265
|
+
}
|
|
7266
|
+
/** A new password for a machine reached with its password, checked with a login before it is kept. */
|
|
7267
|
+
async setPassword(id, password) {
|
|
7268
|
+
const record3 = this.need(id);
|
|
7269
|
+
if (typeof password !== "string" || !password) throw new ChatError(400, "A password is needed", "ssh_password_missing");
|
|
7270
|
+
if (!this.store.environments.usesPassword(id)) throw new ChatError(409, "This machine is reached with Jarvy's key, not a password");
|
|
7271
|
+
await this.wrap(() => probeHost({ ...this.access(record3), password }));
|
|
7272
|
+
this.store.environments.updateSsh(id, { password });
|
|
7273
|
+
return this.get(id);
|
|
7274
|
+
}
|
|
7275
|
+
update(id, input) {
|
|
7276
|
+
const record3 = this.need(id);
|
|
7277
|
+
const name3 = input.name === void 0 ? void 0 : text3(input.name, 40);
|
|
7278
|
+
if (name3 !== void 0 && !name3) throw new ChatError(400, "A name is needed");
|
|
7279
|
+
if (name3 !== void 0 && name3 !== record3.name) this.freeName(name3);
|
|
7280
|
+
this.store.environments.updateSsh(id, {
|
|
7281
|
+
...name3 !== void 0 ? { name: name3 } : {},
|
|
7282
|
+
...input.directory !== void 0 ? { directory: directoryIn(record3.spec.home ?? "/", input.directory) } : {}
|
|
7283
|
+
});
|
|
7284
|
+
this.changed();
|
|
7285
|
+
return this.get(id);
|
|
7286
|
+
}
|
|
7287
|
+
/** Scans the machine again: `changed` when it no longer presents the keys the owner accepted. A machine reached
|
|
7288
|
+
* with its password whose keys are unchanged is tried with Jarvy's key: once that works, the password is dropped
|
|
7289
|
+
* and the key is used from then on (A59). `machine` is how it stands after. */
|
|
7290
|
+
async check(id) {
|
|
7291
|
+
const record3 = this.need(id), accepted = this.store.environments.sshSecrets(id).hostKeys;
|
|
7292
|
+
const hostKeys2 = await this.scan(this.addressOf(record3)), changed = hostKeys2.keys.some((key) => !accepted.keys.includes(key));
|
|
7293
|
+
if (!changed && this.store.environments.usesPassword(id)) {
|
|
7294
|
+
const { password: _password, ...withKey } = this.access(record3);
|
|
7295
|
+
try {
|
|
7296
|
+
await probeHost(withKey);
|
|
7297
|
+
this.store.environments.updateSsh(id, { password: null });
|
|
7298
|
+
this.changed();
|
|
7299
|
+
} catch (error) {
|
|
7300
|
+
if (!(error instanceof SshFault) || error.code !== "ssh_key_refused") throw this.fault(error);
|
|
7301
|
+
}
|
|
7302
|
+
}
|
|
7303
|
+
return { hostKeys: hostKeys2, changed, machine: this.get(id) };
|
|
7304
|
+
}
|
|
7305
|
+
/** The owner accepts the keys the machine presents now (after a reinstall, say). */
|
|
7306
|
+
async trust(id, fingerprints) {
|
|
7307
|
+
const { hostKeys: hostKeys2 } = await this.check(id);
|
|
7308
|
+
if (!Array.isArray(fingerprints) || hostKeys2.fingerprints.join("\n") !== fingerprints.join("\n")) throw new ChatError(409, "The machine now presents other keys; check again", "ssh_host_key_changed");
|
|
7309
|
+
this.store.environments.updateSsh(id, { hostKeys: hostKeys2 });
|
|
7310
|
+
return this.get(id);
|
|
7311
|
+
}
|
|
7312
|
+
/** Removes Jarvy's key from the machine, then forgets it. When the machine can't be reached the key stays
|
|
7313
|
+
* there; `force` forgets it anyway. Returns the Bots that were working there. */
|
|
7314
|
+
async remove(id, force) {
|
|
7315
|
+
const record3 = this.need(id);
|
|
7316
|
+
try {
|
|
7317
|
+
await this.wrap(() => removeKey(this.access(record3), keyComment(id)));
|
|
7318
|
+
} catch (error) {
|
|
7319
|
+
if (!force) throw error;
|
|
7320
|
+
}
|
|
7321
|
+
const moved = this.store.environments.dropSsh(id);
|
|
7322
|
+
this.changed();
|
|
7323
|
+
return moved;
|
|
7324
|
+
}
|
|
7325
|
+
/** The machine as a Bot's environment. Its own files go in ~/.cache/jarvy/<Bot> there. */
|
|
7326
|
+
environment(record3, botId) {
|
|
7327
|
+
const home = record3.spec.home ?? "/tmp";
|
|
7328
|
+
return sshEnvironment(
|
|
7329
|
+
record3.id,
|
|
7330
|
+
this.access(record3),
|
|
7331
|
+
record3.directory ?? home,
|
|
7332
|
+
posix.join(home, ".cache", "jarvy", botId),
|
|
7333
|
+
(text4) => console.error(`[ssh ${record3.name}] ${text4.trim()}`)
|
|
7334
|
+
);
|
|
7335
|
+
}
|
|
7336
|
+
/** What a Bot is told of a machine. */
|
|
7337
|
+
about(record3, botId) {
|
|
7338
|
+
const temp = posix.join(record3.spec.home ?? "~", ".cache", "jarvy", botId);
|
|
7339
|
+
return `A ${record3.spec.platform === "darwin" ? "macOS" : "Linux"} machine your owner added, reached over SSH as ${record3.spec.user}@${record3.spec.host}. Your tools run there as that user and can reach their files: work in your directory, leave other people's files and system directories alone unless the task needs it, and say in your reply what you changed there. Files from the chat and long command outputs go in ${temp}.`;
|
|
7340
|
+
}
|
|
7341
|
+
address(record3) {
|
|
7342
|
+
return `${record3.spec.user}@${record3.spec.host}${record3.spec.port === "22" ? "" : `:${record3.spec.port}`}`;
|
|
7343
|
+
}
|
|
7344
|
+
/** environments.request: a machine the Bot may already use needs no card; one already added needs only a grant;
|
|
7345
|
+
* else the card shows its fingerprints and takes the password. */
|
|
7346
|
+
async prepareRequest(botId, input) {
|
|
7347
|
+
const where2 = address(input);
|
|
7348
|
+
const existing = this.store.environments.sshMachines().find((record3) => record3.spec.host === where2.host && record3.spec.user === where2.user && record3.spec.port === String(where2.port));
|
|
7349
|
+
if (existing && this.store.environments.grantees(existing.id).includes(botId)) return { granted: existing };
|
|
7350
|
+
const hostKeys2 = existing ? this.store.environments.sshSecrets(existing.id).hostKeys : await this.scan(where2);
|
|
7351
|
+
const machine = {
|
|
7352
|
+
name: existing?.name ?? (text3(input.name, 40) || `${where2.user}@${where2.host}`),
|
|
7353
|
+
...where2,
|
|
7354
|
+
directory: typeof input.directory === "string" && input.directory.trim() ? input.directory.trim() : null,
|
|
7355
|
+
purpose: input.purpose,
|
|
7356
|
+
hostKeys: hostKeys2,
|
|
7357
|
+
existing: existing ? { id: existing.id, name: existing.name } : null
|
|
7358
|
+
};
|
|
7359
|
+
return { question: { question: input.purpose, options: [{ label: "Add", value: "add" }], multiple: false, allowCustom: false, machine } };
|
|
7360
|
+
}
|
|
7361
|
+
/** The owner's answer on a request card: adds the machine with the password or a prepared key (or grants the one
|
|
7362
|
+
* already added) to the Bot that asked, then settles the card. The password never enters the message. */
|
|
7363
|
+
async fulfil(actorId, chatId, messageId, input) {
|
|
7364
|
+
const message = this.chats.message(actorId, messageId), ask = message?.question?.machine;
|
|
7365
|
+
if (!message || message.chatId !== chatId || !ask || message.question?.state !== "pending") throw new ChatError(409, "The question is already settled", "question_closed");
|
|
7366
|
+
if (!this.chats.questions.waitingFor(messageId)) throw new ChatError(409, "The question has expired", "question_expired");
|
|
7367
|
+
const botId = message.sender.id, by = this.store.userMemberId();
|
|
7368
|
+
const existing = ask.existing && this.store.environments.get(ask.existing.id);
|
|
7369
|
+
if (existing) {
|
|
7370
|
+
this.store.environments.grant(existing.id, botId, by);
|
|
7371
|
+
this.changed();
|
|
7372
|
+
} else await this.add({
|
|
7373
|
+
name: ask.name,
|
|
7374
|
+
host: ask.host,
|
|
7375
|
+
port: ask.port,
|
|
7376
|
+
user: ask.user,
|
|
7377
|
+
directory: ask.directory ?? void 0,
|
|
7378
|
+
password: input.password,
|
|
7379
|
+
...input.keyId !== void 0 ? { keyId: input.keyId } : {},
|
|
7380
|
+
hostKeys: ask.hostKeys
|
|
7381
|
+
}, { botId, by });
|
|
7382
|
+
return this.chats.questions.respond(actorId, chatId, messageId, { action: "answer", selected: [0] });
|
|
7383
|
+
}
|
|
7384
|
+
/** The machine a request card added or granted, by its address. */
|
|
7385
|
+
find(botId, input) {
|
|
7386
|
+
const where2 = address(input);
|
|
7387
|
+
return this.store.environments.grants(botId).find((record3) => record3.kind === "ssh" && record3.spec.host === where2.host && record3.spec.user === where2.user && record3.spec.port === String(where2.port)) ?? null;
|
|
7388
|
+
}
|
|
7389
|
+
view(record3) {
|
|
7390
|
+
const { spec } = record3, secrets = this.store.environments.sshSecrets(record3.id);
|
|
7391
|
+
return {
|
|
7392
|
+
id: record3.id,
|
|
7393
|
+
name: record3.name,
|
|
7394
|
+
host: spec.host ?? "",
|
|
7395
|
+
port: Number(spec.port ?? 22),
|
|
7396
|
+
user: spec.user ?? "",
|
|
7397
|
+
home: spec.home ?? "",
|
|
7398
|
+
directory: record3.directory ?? spec.home ?? "",
|
|
7399
|
+
platform: spec.platform ?? "",
|
|
7400
|
+
fingerprints: secrets?.hostKeys.fingerprints ?? [],
|
|
7401
|
+
botIds: this.store.environments.grantees(record3.id),
|
|
7402
|
+
login: secrets?.password || secrets?.passwordUnreadable ? "password" : "key",
|
|
7403
|
+
publicKey: secrets?.privateKey ? publicKeyOf(secrets.privateKey, keyComment(record3.id)) : null
|
|
7404
|
+
};
|
|
7405
|
+
}
|
|
7406
|
+
need(id) {
|
|
7407
|
+
const record3 = this.store.environments.get(id);
|
|
7408
|
+
if (!record3 || record3.kind !== "ssh") throw new ChatError(404, "No machine with this id", "machine_missing");
|
|
7409
|
+
return record3;
|
|
7410
|
+
}
|
|
7411
|
+
addressOf(record3) {
|
|
7412
|
+
return { host: record3.spec.host, port: Number(record3.spec.port), user: record3.spec.user };
|
|
7413
|
+
}
|
|
7414
|
+
access(record3) {
|
|
7415
|
+
const secrets = this.store.environments.sshSecrets(record3.id);
|
|
7416
|
+
if (!secrets?.privateKey || secrets.passwordUnreadable) throw new ChatError(409, "This machine's key doesn't open under this installation's master key; delete the machine and add it again", "ssh_key_unreadable");
|
|
7417
|
+
return {
|
|
7418
|
+
...this.addressOf(record3),
|
|
7419
|
+
runtime: this.runtime,
|
|
7420
|
+
alias: alias(record3.id),
|
|
7421
|
+
hostKeys: secrets.hostKeys.keys,
|
|
7422
|
+
privateKey: secrets.privateKey,
|
|
7423
|
+
...secrets.password ? { password: secrets.password } : {}
|
|
7424
|
+
};
|
|
7425
|
+
}
|
|
7426
|
+
freeName(name3) {
|
|
7427
|
+
if (this.store.environments.sshMachines().some((record3) => record3.name === name3)) throw new ChatError(409, "Another machine has this name", "machine_name_taken");
|
|
7428
|
+
return name3;
|
|
7429
|
+
}
|
|
7430
|
+
/** SSH failures reach people and Bots as chat errors with a code the interface can word. */
|
|
7431
|
+
async wrap(work) {
|
|
7432
|
+
try {
|
|
7433
|
+
return await work();
|
|
7434
|
+
} catch (error) {
|
|
7435
|
+
throw this.fault(error);
|
|
7436
|
+
}
|
|
7437
|
+
}
|
|
7438
|
+
fault(error) {
|
|
7439
|
+
if (!(error instanceof SshFault)) return error;
|
|
7440
|
+
switch (error.code) {
|
|
7441
|
+
case "ssh_auth_failed":
|
|
7442
|
+
return new ChatError(403, error.message, "ssh_auth_failed");
|
|
7443
|
+
case "ssh_password_refused":
|
|
7444
|
+
return new ChatError(403, error.message, "ssh_password_refused");
|
|
7445
|
+
case "ssh_key_refused":
|
|
7446
|
+
return new ChatError(403, error.message, "ssh_key_refused");
|
|
7447
|
+
case "ssh_unreachable":
|
|
7448
|
+
return new ChatError(409, error.message, "ssh_unreachable");
|
|
7449
|
+
case "ssh_host_key_changed":
|
|
7450
|
+
return new ChatError(409, error.message, "ssh_host_key_changed");
|
|
7451
|
+
case "ssh_unsupported":
|
|
7452
|
+
return new ChatError(409, error.message, "ssh_unsupported");
|
|
7453
|
+
default:
|
|
7454
|
+
return new ChatError(409, error.message, "ssh_failed", { detail: error.message });
|
|
7455
|
+
}
|
|
7456
|
+
}
|
|
7457
|
+
};
|
|
7458
|
+
var alias = (id) => `jarvy-${id}`;
|
|
7459
|
+
var keyComment = (id) => `jarvy-${id}`;
|
|
7460
|
+
var text3 = (value2, max) => typeof value2 === "string" ? value2.trim().slice(0, max) : "";
|
|
7461
|
+
function address(input) {
|
|
7462
|
+
const host = text3(input.host, 253), user = text3(input.user, 64), port = input.port === void 0 || input.port === "" ? 22 : Number(input.port);
|
|
7463
|
+
if (!HOST.test(host)) throw new ChatError(400, "host must be a host name or IP address", "ssh_bad_host");
|
|
7464
|
+
if (!USER.test(user)) throw new ChatError(400, "user must be a user name on that machine", "ssh_bad_user");
|
|
7465
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new ChatError(400, "port must be 1 to 65535", "ssh_bad_port");
|
|
7466
|
+
return { host, port, user };
|
|
7467
|
+
}
|
|
7468
|
+
function directoryIn(home, value2) {
|
|
7469
|
+
const raw = text3(value2, 1e3);
|
|
7470
|
+
if (!raw || raw === "~") return home;
|
|
7471
|
+
if (raw.startsWith("~/")) return posix.join(home, raw.slice(2));
|
|
7472
|
+
if (!raw.startsWith("/") || /[\x00-\x1f]/.test(raw)) throw new ChatError(400, "directory must be an absolute path or start with ~/", "ssh_bad_directory");
|
|
7473
|
+
return posix.normalize(raw);
|
|
7474
|
+
}
|
|
7475
|
+
|
|
6344
7476
|
// src/server/infra/storage/history-repository.ts
|
|
6345
7477
|
import { DatabaseSync as DatabaseSync2 } from "node:sqlite";
|
|
6346
|
-
import { chmodSync as chmodSync3, mkdirSync as
|
|
6347
|
-
import { dirname as
|
|
7478
|
+
import { chmodSync as chmodSync3, mkdirSync as mkdirSync5 } from "node:fs";
|
|
7479
|
+
import { dirname as dirname6 } from "node:path";
|
|
6348
7480
|
var json = (value2) => value2 == null ? null : JSON.stringify(value2);
|
|
6349
7481
|
var CALL = "EXISTS (SELECT 1 FROM json_each(history.message, '$.content') b WHERE json_extract(b.value, '$.type')=? AND json_extract(b.value, '$.tool_call_id')=?)";
|
|
6350
7482
|
var BLOCKS = "FROM json_each(history.message,'$.content') b WHERE json_extract(b.value,'$.type')";
|
|
@@ -6356,7 +7488,7 @@ var SUMMARY = `SELECT id,timestamp,role,model,
|
|
|
6356
7488
|
var HistoryRepository = class {
|
|
6357
7489
|
db;
|
|
6358
7490
|
constructor(path) {
|
|
6359
|
-
|
|
7491
|
+
mkdirSync5(dirname6(path), { recursive: true, mode: 448 });
|
|
6360
7492
|
this.db = new DatabaseSync2(path);
|
|
6361
7493
|
chmodSync3(path, 384);
|
|
6362
7494
|
const version = this.db.prepare("PRAGMA user_version").get().user_version;
|
|
@@ -6668,24 +7800,24 @@ var Model = class {
|
|
|
6668
7800
|
};
|
|
6669
7801
|
|
|
6670
7802
|
// src/server/service/bot/workspace.ts
|
|
6671
|
-
import { posix } from "node:path";
|
|
7803
|
+
import { posix as posix2 } from "node:path";
|
|
6672
7804
|
|
|
6673
7805
|
// src/server/infra/files/workspace.ts
|
|
6674
|
-
import { mkdir as
|
|
6675
|
-
import { join as
|
|
7806
|
+
import { mkdir as mkdir5, writeFile as writeFile3 } from "node:fs/promises";
|
|
7807
|
+
import { join as join10 } from "node:path";
|
|
6676
7808
|
var LocalWorkspace = class {
|
|
6677
7809
|
constructor(cwd) {
|
|
6678
7810
|
this.cwd = cwd;
|
|
6679
7811
|
}
|
|
6680
7812
|
get tempDir() {
|
|
6681
|
-
return
|
|
7813
|
+
return join10(this.cwd, "temp");
|
|
6682
7814
|
}
|
|
6683
7815
|
async prepare() {
|
|
6684
|
-
await
|
|
7816
|
+
await mkdir5(this.tempDir, { recursive: true, mode: 448 });
|
|
6685
7817
|
}
|
|
6686
7818
|
async writeTempFile(name3, content2, signal) {
|
|
6687
7819
|
await this.prepare();
|
|
6688
|
-
const path =
|
|
7820
|
+
const path = join10(this.tempDir, name3);
|
|
6689
7821
|
await writeFile3(path, content2, { encoding: "utf8", flag: "wx", mode: 384, signal });
|
|
6690
7822
|
return path;
|
|
6691
7823
|
}
|
|
@@ -6693,8 +7825,8 @@ var LocalWorkspace = class {
|
|
|
6693
7825
|
|
|
6694
7826
|
// src/server/infra/files/skills.ts
|
|
6695
7827
|
import { existsSync as existsSync4, readdirSync, readFileSync as readFileSync4 } from "node:fs";
|
|
6696
|
-
import { mkdir as
|
|
6697
|
-
import { dirname as
|
|
7828
|
+
import { mkdir as mkdir6, writeFile as writeFile4 } from "node:fs/promises";
|
|
7829
|
+
import { dirname as dirname7, join as join11 } from "node:path";
|
|
6698
7830
|
import { fileURLToPath } from "node:url";
|
|
6699
7831
|
|
|
6700
7832
|
// src/server/shared/skill.ts
|
|
@@ -6707,7 +7839,7 @@ function builtinSkills() {
|
|
|
6707
7839
|
const skills = [];
|
|
6708
7840
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
6709
7841
|
if (!entry.isDirectory()) continue;
|
|
6710
|
-
const file =
|
|
7842
|
+
const file = join11(dir, entry.name, "SKILL.md");
|
|
6711
7843
|
if (!existsSync4(file)) continue;
|
|
6712
7844
|
const skill = parseSkill(entry.name, readFileSync4(file, "utf8"));
|
|
6713
7845
|
if (skill) skills.push(skill);
|
|
@@ -6717,16 +7849,16 @@ function builtinSkills() {
|
|
|
6717
7849
|
}
|
|
6718
7850
|
async function installSkills(workspace, skills) {
|
|
6719
7851
|
for (const skill of skills) {
|
|
6720
|
-
const file =
|
|
6721
|
-
await
|
|
7852
|
+
const file = join11(workspace, skillPath(skill.name));
|
|
7853
|
+
await mkdir6(dirname7(file), { recursive: true });
|
|
6722
7854
|
await writeFile4(file, skill.content, "utf8");
|
|
6723
7855
|
}
|
|
6724
7856
|
}
|
|
6725
7857
|
function skillsDir() {
|
|
6726
|
-
let dir =
|
|
7858
|
+
let dir = dirname7(fileURLToPath(import.meta.url));
|
|
6727
7859
|
for (let depth = 0; depth < 6; depth++) {
|
|
6728
|
-
if (existsSync4(
|
|
6729
|
-
const parent =
|
|
7860
|
+
if (existsSync4(join11(dir, "package.json"))) return existsSync4(join11(dir, "skills")) ? join11(dir, "skills") : null;
|
|
7861
|
+
const parent = dirname7(dir);
|
|
6730
7862
|
if (parent === dir) break;
|
|
6731
7863
|
dir = parent;
|
|
6732
7864
|
}
|
|
@@ -6759,7 +7891,7 @@ var Workspace = class {
|
|
|
6759
7891
|
async writeTempFile(name3, content2, signal) {
|
|
6760
7892
|
const env = this.current();
|
|
6761
7893
|
if (env.kind === "host") return this.local.writeTempFile(name3, content2, signal);
|
|
6762
|
-
const path = `${env
|
|
7894
|
+
const path = `${tempOf(env)}/${name3}`;
|
|
6763
7895
|
await env.writeFile(path, content2);
|
|
6764
7896
|
return path;
|
|
6765
7897
|
}
|
|
@@ -6767,14 +7899,14 @@ var Workspace = class {
|
|
|
6767
7899
|
* id when another file has that name). Returns where it went. */
|
|
6768
7900
|
async put(name3, id, data, path) {
|
|
6769
7901
|
const env = this.current();
|
|
6770
|
-
let
|
|
6771
|
-
if (!path && await env.exists(
|
|
7902
|
+
let target3 = path ? posix2.resolve(env.cwd, path) : `${tempOf(env)}/files/${plain(name3)}`;
|
|
7903
|
+
if (!path && await env.exists(target3)) {
|
|
6772
7904
|
const dot = plain(name3).lastIndexOf("."), stem = dot > 0 ? plain(name3).slice(0, dot) : plain(name3), extension2 = dot > 0 ? plain(name3).slice(dot) : "";
|
|
6773
|
-
|
|
7905
|
+
target3 = `${tempOf(env)}/files/${stem}-${id.slice(0, 8)}${extension2}`;
|
|
6774
7906
|
}
|
|
6775
|
-
await env.mkdir(
|
|
6776
|
-
await env.writeFile(
|
|
6777
|
-
return
|
|
7907
|
+
await env.mkdir(posix2.dirname(target3));
|
|
7908
|
+
await env.writeFile(target3, data);
|
|
7909
|
+
return target3;
|
|
6778
7910
|
}
|
|
6779
7911
|
/** The files a Bot attaches to a message, read from its environment: paths there, relative to its directory. */
|
|
6780
7912
|
async readOutgoing(items, signal) {
|
|
@@ -6782,7 +7914,7 @@ var Workspace = class {
|
|
|
6782
7914
|
const env = this.current(), files = [];
|
|
6783
7915
|
for (const item of items) {
|
|
6784
7916
|
signal?.throwIfAborted();
|
|
6785
|
-
const path =
|
|
7917
|
+
const path = posix2.resolve(env.cwd, item.path), size = await env.fileSize(path);
|
|
6786
7918
|
if (size === null) throw new ChatError(400, `No file at ${path} in your environment`);
|
|
6787
7919
|
if (!size || size > MAX_FILE) throw new ChatError(400, "Invalid attachment size or type");
|
|
6788
7920
|
const data = await env.readFile(path);
|
|
@@ -6799,6 +7931,7 @@ function plain(name3) {
|
|
|
6799
7931
|
const cleaned = name3.replace(/[/\\\0]/g, "_").trim();
|
|
6800
7932
|
return cleaned && cleaned !== "." && cleaned !== ".." ? cleaned : "file";
|
|
6801
7933
|
}
|
|
7934
|
+
var tempOf = (env) => env.temp ?? `${env.cwd}/temp`;
|
|
6802
7935
|
|
|
6803
7936
|
// src/server/service/bot/state.ts
|
|
6804
7937
|
var BotState = class {
|
|
@@ -6817,20 +7950,20 @@ var BotState = class {
|
|
|
6817
7950
|
};
|
|
6818
7951
|
|
|
6819
7952
|
// src/server/service/bot/tools.ts
|
|
6820
|
-
import { Type as
|
|
6821
|
-
import { Value } from "typebox/value";
|
|
7953
|
+
import { Type as Type6 } from "typebox";
|
|
7954
|
+
import { Value as Value2 } from "typebox/value";
|
|
6822
7955
|
|
|
6823
7956
|
// src/server/service/bot/history-read.ts
|
|
6824
|
-
import { randomUUID as
|
|
7957
|
+
import { randomUUID as randomUUID13 } from "node:crypto";
|
|
6825
7958
|
import { Type as Type2 } from "typebox";
|
|
6826
7959
|
|
|
6827
7960
|
// src/server/service/bot/history-format.ts
|
|
6828
|
-
function fence(
|
|
7961
|
+
function fence(text4, language = "text") {
|
|
6829
7962
|
let length = 3;
|
|
6830
|
-
for (const match of
|
|
7963
|
+
for (const match of text4.matchAll(/`+/g)) length = Math.max(length, match[0].length + 1);
|
|
6831
7964
|
const marker = "`".repeat(length);
|
|
6832
7965
|
return `${marker}${language}
|
|
6833
|
-
${
|
|
7966
|
+
${text4}
|
|
6834
7967
|
${marker}`;
|
|
6835
7968
|
}
|
|
6836
7969
|
var jsonBlock = (value2) => fence(JSON.stringify(value2, null, 2), "json");
|
|
@@ -6901,12 +8034,12 @@ async function readHistory(memory, workspace, input, signal) {
|
|
|
6901
8034
|
const rendered = formatHistory(result, format);
|
|
6902
8035
|
signal?.throwIfAborted();
|
|
6903
8036
|
if (!input.to_file) return rendered;
|
|
6904
|
-
const name3 = `history-${
|
|
8037
|
+
const name3 = `history-${randomUUID13()}.${format === "json" ? "json" : "md"}`;
|
|
6905
8038
|
return workspace.writeTempFile(name3, rendered, signal);
|
|
6906
8039
|
}
|
|
6907
8040
|
|
|
6908
8041
|
// src/server/service/bot/file-view.ts
|
|
6909
|
-
var
|
|
8042
|
+
var TEXT_LIMIT2 = 64 * 1024;
|
|
6910
8043
|
var PICTURE_LIMIT = 4 * 1024 * 1024;
|
|
6911
8044
|
function viewFile(file, data) {
|
|
6912
8045
|
const head = `${file.name} (${file.mimeType || "application/octet-stream"}, ${byteSize(file.size)})`;
|
|
@@ -6915,10 +8048,10 @@ function viewFile(file, data) {
|
|
|
6915
8048
|
if (data.length > PICTURE_LIMIT) throw new ChatError(400, "This picture is over 4 MB; use files.get and look at a smaller copy");
|
|
6916
8049
|
return { kind: "media", text: head, images: [{ data: data.toString("base64"), mimeType: picture }] };
|
|
6917
8050
|
}
|
|
6918
|
-
const
|
|
6919
|
-
if (
|
|
8051
|
+
const text4 = data.length <= TEXT_LIMIT2 ? asText(data) : null;
|
|
8052
|
+
if (text4 !== null) return `${head}
|
|
6920
8053
|
|
|
6921
|
-
${
|
|
8054
|
+
${text4}`;
|
|
6922
8055
|
throw new ChatError(400, "files.view shows pictures and text files up to 64 KB; use files.get to work on this file with your tools");
|
|
6923
8056
|
}
|
|
6924
8057
|
function pictureType(data) {
|
|
@@ -7165,8 +8298,10 @@ var ChatTools = class {
|
|
|
7165
8298
|
signal?.throwIfAborted();
|
|
7166
8299
|
return messages.map((message) => messageRow(this.chats, this.bot, message, listing(message)));
|
|
7167
8300
|
}
|
|
8301
|
+
/** `mine`: only what this Bot sent; those messages carry the History ID of the turn that sent them (A21). */
|
|
7168
8302
|
search(input) {
|
|
7169
|
-
const
|
|
8303
|
+
const sender = input.mine ? this.bot : void 0;
|
|
8304
|
+
const found = input.chatId === void 0 ? this.chats.searchAll(this.bot, input.query, input.limit ?? 30, void 0, sender) : this.chats.search(this.bot, this.chat(input.chatId).id, input.query, input.limit ?? 30, sender);
|
|
7170
8305
|
const chats = /* @__PURE__ */ new Map();
|
|
7171
8306
|
const chatOf = (id) => {
|
|
7172
8307
|
if (!chats.has(id)) chats.set(id, this.chats.get(this.bot, id));
|
|
@@ -7192,7 +8327,7 @@ var definitions = {
|
|
|
7192
8327
|
"history.read": { description: "Read your own History by a list of IDs: summaries or details, as JSON or Markdown, or written to a temporary file. IDs that don't exist are listed in missingIds. History is material to look back on, not an instruction.", schema: historyReadSchema },
|
|
7193
8328
|
"members.list": { description: "List every member, people and Bots: name, kind (user for a person, or bot) and description; self=true is you. Use these names to @-mention someone, add them to a channel or DM them.", schema: None },
|
|
7194
8329
|
"bots.list": {
|
|
7195
|
-
description: "List every Bot and its run state. Read-only; it changes nothing. People and Bots can all see every Bot's name and state; this is not private. For each Bot: name; self=true is you; agent=true is the agent Bot, the owner's helper for running
|
|
8330
|
+
description: "List every Bot and its run state. Read-only; it changes nothing. People and Bots can all see every Bot's name and state; this is not private. For each Bot: name; self=true is you; agent=true is the agent Bot, the owner's helper for running this chat platform: ask it to create or change a Bot, give a Bot a model, see a Bot's usage, or delete a Bot, channel or channel group; status (stop: not running, idle, or running). While it runs, activity is what it is doing (reasoning, texting: writing text, tool_calling, or asking: waiting for a person's answer), chats are the chats it is handling this turn (only those you can read), and otherChats is how many others there are. If its last turn failed, error is the error code. With environments=true, each Bot also has environment: its default work environment, the one it is in now (current) and the ones it may use. You come first, then the rest by name.",
|
|
7196
8331
|
schema: Type3.Object({ environments: Type3.Optional(Type3.Boolean({ description: "Also give each Bot's work environments" })) }, { additionalProperties: false })
|
|
7197
8332
|
},
|
|
7198
8333
|
"chats.list": { description: "List the chats you are in: DMs, channels and the threads you follow. Each has its type, title (DMs also give the other member's name in with), channel group (section), topic, your unread count and whether you subscribe; threads also give their channel and status.", schema: None },
|
|
@@ -7255,8 +8390,13 @@ var definitions = {
|
|
|
7255
8390
|
}, { additionalProperties: false })
|
|
7256
8391
|
},
|
|
7257
8392
|
"messages.search": {
|
|
7258
|
-
description: "Search message text. With chatId, only that chat; without it, every chat you can see, and each result says which chat it is in. Results list their files like messages.read.",
|
|
7259
|
-
schema: Type3.Object({
|
|
8393
|
+
description: "Search message text. With chatId, only that chat; without it, every chat you can see, and each result says which chat it is in. Results list their files like messages.read. mine=true: only messages you sent; each carries the History ID of the turn that sent it.",
|
|
8394
|
+
schema: Type3.Object({
|
|
8395
|
+
chatId: Type3.Optional(Type3.String()),
|
|
8396
|
+
query: Type3.String({ minLength: 1, maxLength: 500 }),
|
|
8397
|
+
limit: Type3.Optional(Type3.Integer({ minimum: 1, maximum: 100 })),
|
|
8398
|
+
mine: Type3.Optional(Type3.Boolean({ description: "Only messages you sent" }))
|
|
8399
|
+
}, { additionalProperties: false })
|
|
7260
8400
|
},
|
|
7261
8401
|
"files.get": {
|
|
7262
8402
|
description: "Put a file from a message into your environment, to work on it with your tools; the result has its path there. It goes to temp/files/ under its own name unless you give a path.",
|
|
@@ -7267,8 +8407,20 @@ var definitions = {
|
|
|
7267
8407
|
description: "Move your file and command tools (bash, read, write, edit) to another work environment from environments.list, like cd one level up: you stay there, across turns, until you switch back. The result has your directory there and what you can do there. Commands already running finish where they started.",
|
|
7268
8408
|
schema: Type3.Object({ id: Type3.String({ minLength: 1, maxLength: 80, description: "The environment's id from environments.list" }) }, { additionalProperties: false })
|
|
7269
8409
|
},
|
|
8410
|
+
"environments.request": {
|
|
8411
|
+
description: `Ask your owner to add a machine you need to work on, reached over SSH, on a card in a chat with them, and wait for the answer, as messages.ask does. The card shows the machine's host key fingerprints for them to check; they let you in there, with its login password or by adding a key made for it to the machine. You never see the password or the key, so never ask for that password in the chat. It returns {status:"added", id, name} or {status:"granted", id, name} when you may already use it, then environments.switch with that id moves your tools there; or {status:"declined"}.`,
|
|
8412
|
+
schema: Type3.Object({
|
|
8413
|
+
chatId: Type3.String(),
|
|
8414
|
+
host: Type3.String({ minLength: 1, maxLength: 253, description: "Host name or IP address" }),
|
|
8415
|
+
user: Type3.String({ minLength: 1, maxLength: 64, description: "The user to log in as" }),
|
|
8416
|
+
port: Type3.Optional(Type3.Integer({ minimum: 1, maximum: 65535, description: "Default 22" })),
|
|
8417
|
+
directory: Type3.Optional(Type3.String({ maxLength: 1e3, description: "Where your tools start there: an absolute path or one under ~/; default the user's home" })),
|
|
8418
|
+
name: Type3.Optional(Type3.String({ maxLength: 40, description: "What to call the machine; default user@host" })),
|
|
8419
|
+
purpose: Type3.String({ minLength: 1, maxLength: 300, description: "What you need it for, in your owner's language; shown on the card" })
|
|
8420
|
+
}, { additionalProperties: false })
|
|
8421
|
+
},
|
|
7270
8422
|
"secrets.request": {
|
|
7271
|
-
description: 'Ask your owner for a secret you need (a token,
|
|
8423
|
+
description: 'Ask your owner for a secret you need (a token, an API key or a password; to work on a machine over SSH, use environments.request) on a card in a chat with them, and wait for the answer, as messages.ask does. They enter the value on the card, which never shows it in the chat, and let you use it until they revoke it or only until this run ends, or decline. It returns {status:"granted", placeholder, until} or {status:"declined"}; if you may already use it, it returns at once. Then write the placeholder in bash.',
|
|
7272
8424
|
schema: Type3.Object({
|
|
7273
8425
|
chatId: Type3.String(),
|
|
7274
8426
|
name: Type3.String({ pattern: "^[A-Z][A-Z0-9_]{0,63}$", description: "Capital letters, digits and underscores, such as GITHUB_TOKEN" }),
|
|
@@ -7430,7 +8582,7 @@ var Model2 = Type4.Object({
|
|
|
7430
8582
|
}, { additionalProperties: false });
|
|
7431
8583
|
var Thinking = Type4.Union(
|
|
7432
8584
|
["auto", "off", "minimal", "low", "medium", "high", "xhigh", "max"].map((level) => Type4.Literal(level)),
|
|
7433
|
-
{ description: "How much the model thinks; auto lets
|
|
8585
|
+
{ description: "How much the model thinks; auto lets this chat platform choose. models.list gives the levels each model has" }
|
|
7434
8586
|
);
|
|
7435
8587
|
var Settings = {
|
|
7436
8588
|
description: Type4.Optional(Type4.String({ maxLength: 1e3, description: "One line that other members see in members.list" })),
|
|
@@ -7439,7 +8591,7 @@ var Settings = {
|
|
|
7439
8591
|
thinking: Type4.Optional(Thinking)
|
|
7440
8592
|
};
|
|
7441
8593
|
var Environment = Type4.Union([Type4.Literal("sandbox"), Type4.Literal("host")], {
|
|
7442
|
-
description: "Where its file and command tools run by default: sandbox, a Linux container that
|
|
8594
|
+
description: "Where its file and command tools run by default: sandbox, a Linux container that this chat platform makes and only it uses (the default when Docker is available), or host, the owner's computer, where you work too"
|
|
7443
8595
|
});
|
|
7444
8596
|
var Confirm = {
|
|
7445
8597
|
askIn: Type4.String({ description: "ID of a chat the owner is in, where the confirmation card goes; usually the chat you were asked in" }),
|
|
@@ -7482,11 +8634,104 @@ var agentDefinitions = {
|
|
|
7482
8634
|
}
|
|
7483
8635
|
};
|
|
7484
8636
|
|
|
8637
|
+
// src/server/service/bot/tool-errors.ts
|
|
8638
|
+
import { Value } from "typebox/value";
|
|
8639
|
+
var MAX_PROBLEMS = 5;
|
|
8640
|
+
function argumentProblems(schema, args) {
|
|
8641
|
+
const lines = [];
|
|
8642
|
+
for (const error of Value.Errors(schema, args)) {
|
|
8643
|
+
if (error.keyword === "boolean" && error.schemaPath.endsWith("additionalProperties")) continue;
|
|
8644
|
+
const extra = error.keyword === "additionalProperties" ? error.params.additionalProperties : void 0;
|
|
8645
|
+
const at2 = error.instancePath ? error.instancePath.slice(1).replaceAll("/", ".") : "";
|
|
8646
|
+
if (extra?.length) for (const name3 of extra) lines.push(`${at2 ? `${at2}.` : ""}${name3}: not a parameter of this tool`);
|
|
8647
|
+
else lines.push(`${at2 || "args"}: ${error.message}`);
|
|
8648
|
+
if (lines.length >= MAX_PROBLEMS) break;
|
|
8649
|
+
}
|
|
8650
|
+
return [...new Set(lines)].slice(0, MAX_PROBLEMS);
|
|
8651
|
+
}
|
|
8652
|
+
function invalidArguments(tool, schema, args) {
|
|
8653
|
+
const problems = args === void 0 ? ["args: missing; call takes the tool's arguments in args"] : argumentProblems(schema, args);
|
|
8654
|
+
return [`Invalid arguments for ${tool}:`, ...problems.map((line) => `- ${line}`), `Its parameters: ${JSON.stringify(schema)}`].join("\n");
|
|
8655
|
+
}
|
|
8656
|
+
function unknownTool(tool, names) {
|
|
8657
|
+
return `${tool ? `Unknown chat tool "${tool}"` : "call and describe need tool"}. The tools: ${names.join(", ")}.`;
|
|
8658
|
+
}
|
|
8659
|
+
|
|
8660
|
+
// src/server/service/bot/task-tools.ts
|
|
8661
|
+
import { Type as Type5 } from "typebox";
|
|
8662
|
+
var Clock = { hour: Type5.Integer({ minimum: 0, maximum: 23 }), minute: Type5.Integer({ minimum: 0, maximum: 59 }) };
|
|
8663
|
+
var Schedule = Type5.Union([
|
|
8664
|
+
Type5.Object({ kind: Type5.Literal("once"), at: Type5.String({ minLength: 1, maxLength: 40, description: "ISO 8601; local time when it has no offset" }) }, { additionalProperties: false }),
|
|
8665
|
+
Type5.Object({ kind: Type5.Literal("every"), minutes: Type5.Integer({ minimum: TASK_MINUTES_MIN, maximum: 60 * 24 * 30, description: "Counted from when the task is made, or from when the interval changes" }) }, { additionalProperties: false }),
|
|
8666
|
+
Type5.Object({ kind: Type5.Literal("daily"), ...Clock }, { additionalProperties: false }),
|
|
8667
|
+
Type5.Object({ kind: Type5.Literal("weekly"), weekday: Type5.Integer({ minimum: 0, maximum: 6, description: "0 is Sunday" }), ...Clock }, { additionalProperties: false })
|
|
8668
|
+
], { description: `When it runs, in this machine's local time: once at a time, every N minutes (${TASK_MINUTES_MIN} at the least), daily at hour:minute, or weekly on a weekday at hour:minute` });
|
|
8669
|
+
var Id = Type5.String({ minLength: 1, maxLength: 80, description: "The task's id, from tasks.list or a <task> in your batch" });
|
|
8670
|
+
var Fields = {
|
|
8671
|
+
name: Type5.String({ minLength: 1, maxLength: 80, description: "A short name, such as Morning news" }),
|
|
8672
|
+
instruction: Type5.String({ minLength: 1, maxLength: 8e3, description: "What to do each time, in full: you get it back when the task is due, so write it for your future self" })
|
|
8673
|
+
};
|
|
8674
|
+
var ByID = Type5.Object({ id: Id }, { additionalProperties: false });
|
|
8675
|
+
var taskDefinitions = {
|
|
8676
|
+
"tasks.list": {
|
|
8677
|
+
description: "List your scheduled tasks: each one's id, name, schedule, state (active, paused or done), next and last run. Times are this machine's local time, which timeZone names.",
|
|
8678
|
+
schema: Type5.Object({}, { additionalProperties: false })
|
|
8679
|
+
},
|
|
8680
|
+
"tasks.get": { description: "One of your scheduled tasks in full, with its instruction.", schema: ByID },
|
|
8681
|
+
"tasks.create": {
|
|
8682
|
+
description: "Schedule a task for yourself. When it is due you get its instruction in a batch, as a <task>, and do it then; it never interrupts a turn, it waits for it to end. Runs missed while this chat platform was not running are skipped.",
|
|
8683
|
+
schema: Type5.Object({ ...Fields, schedule: Schedule }, { additionalProperties: false })
|
|
8684
|
+
},
|
|
8685
|
+
"tasks.update": {
|
|
8686
|
+
description: "Change one of your tasks; give only what changes. A new schedule counts from now; an interval left as it was keeps its rhythm.",
|
|
8687
|
+
schema: Type5.Object({ id: Id, name: Type5.Optional(Fields.name), instruction: Type5.Optional(Fields.instruction), schedule: Type5.Optional(Schedule) }, { additionalProperties: false })
|
|
8688
|
+
},
|
|
8689
|
+
"tasks.pause": { description: "Stop a task from running until you resume it; it stays in the list.", schema: ByID },
|
|
8690
|
+
"tasks.resume": { description: "Start a paused task again, from its next time after now.", schema: ByID },
|
|
8691
|
+
"tasks.delete": { description: "Delete a task; it is gone for good. To stop it for a while, pause it instead.", schema: ByID }
|
|
8692
|
+
};
|
|
8693
|
+
function taskRow(task, full = false) {
|
|
8694
|
+
return {
|
|
8695
|
+
id: task.id,
|
|
8696
|
+
name: task.name,
|
|
8697
|
+
schedule: describeSchedule(task.schedule),
|
|
8698
|
+
state: task.state,
|
|
8699
|
+
...task.nextRunAt !== null ? { next: localIso(task.nextRunAt) } : {},
|
|
8700
|
+
...task.lastRunAt !== null ? { last: localIso(task.lastRunAt) } : {},
|
|
8701
|
+
...full ? { instruction: task.instruction } : {}
|
|
8702
|
+
};
|
|
8703
|
+
}
|
|
8704
|
+
function callTask(tasks, botId, name3, args) {
|
|
8705
|
+
const id = args.id;
|
|
8706
|
+
switch (name3) {
|
|
8707
|
+
case "tasks.list":
|
|
8708
|
+
return { timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, now: localIso(Date.now()), tasks: tasks.list(botId).map((task) => taskRow(task)) };
|
|
8709
|
+
case "tasks.get": {
|
|
8710
|
+
const task = tasks.get(botId, id);
|
|
8711
|
+
if (!task) throw new ChatError(404, "Task not found; see tasks.list");
|
|
8712
|
+
return taskRow(task, true);
|
|
8713
|
+
}
|
|
8714
|
+
case "tasks.create":
|
|
8715
|
+
return taskRow(tasks.create(botId, args), true);
|
|
8716
|
+
case "tasks.update": {
|
|
8717
|
+
const { id: _id, ...rest } = args;
|
|
8718
|
+
return taskRow(tasks.update(botId, id, rest), true);
|
|
8719
|
+
}
|
|
8720
|
+
case "tasks.pause":
|
|
8721
|
+
return taskRow(tasks.setState(botId, id, "paused"));
|
|
8722
|
+
case "tasks.resume":
|
|
8723
|
+
return taskRow(tasks.setState(botId, id, "active"));
|
|
8724
|
+
case "tasks.delete":
|
|
8725
|
+
tasks.remove(botId, id);
|
|
8726
|
+
return { id, deleted: true };
|
|
8727
|
+
}
|
|
8728
|
+
}
|
|
8729
|
+
|
|
7485
8730
|
// src/server/service/bot/tools.ts
|
|
7486
|
-
var Gateway =
|
|
7487
|
-
action:
|
|
7488
|
-
tool:
|
|
7489
|
-
args:
|
|
8731
|
+
var Gateway = Type6.Object({
|
|
8732
|
+
action: Type6.Union([Type6.Literal("list"), Type6.Literal("describe"), Type6.Literal("call")]),
|
|
8733
|
+
tool: Type6.Optional(Type6.String()),
|
|
8734
|
+
args: Type6.Optional(Type6.Record(Type6.String(), Type6.Unknown()))
|
|
7490
8735
|
}, { additionalProperties: false });
|
|
7491
8736
|
var Tools = class {
|
|
7492
8737
|
constructor(scope) {
|
|
@@ -7499,7 +8744,7 @@ var Tools = class {
|
|
|
7499
8744
|
/** The agent Bot's own tools (A48); other Bots have none. */
|
|
7500
8745
|
agent;
|
|
7501
8746
|
get catalog() {
|
|
7502
|
-
return this.agent ? { ...definitions, ...agentDefinitions } : definitions;
|
|
8747
|
+
return this.agent ? { ...definitions, ...taskDefinitions, ...agentDefinitions } : { ...definitions, ...taskDefinitions };
|
|
7503
8748
|
}
|
|
7504
8749
|
/** Posts a card and waits for the person's answer (messages.ask, and the agent's confirmations). */
|
|
7505
8750
|
async ask(chatId, question, toolCallId, signal) {
|
|
@@ -7531,22 +8776,27 @@ var Tools = class {
|
|
|
7531
8776
|
description: "Gateway to the chat and to your own History. Use list to see the tools, describe to check a tool's parameters, then call to run it. Only what messages.send or messages.ask sends successfully is visible in the chat.",
|
|
7532
8777
|
parameters: Gateway,
|
|
7533
8778
|
execute: async (raw, { toolCallId, signal }) => {
|
|
7534
|
-
if (!
|
|
8779
|
+
if (!Value2.Check(Gateway, raw)) throw new ChatError(400, [
|
|
8780
|
+
"Invalid chat tool request:",
|
|
8781
|
+
...argumentProblems(Gateway, raw).map((line) => `- ${line}`),
|
|
8782
|
+
'It takes {action: "list" | "describe" | "call", tool, args}: list takes neither, describe takes tool, call takes both.'
|
|
8783
|
+
].join("\n"));
|
|
7535
8784
|
signal?.throwIfAborted();
|
|
7536
8785
|
if (raw.action === "list") {
|
|
7537
8786
|
if (raw.tool !== void 0 || raw.args !== void 0) throw new ChatError(400, "list does not accept tool or args");
|
|
7538
8787
|
return Object.entries(this.catalog).map(([name4, tool]) => ({ name: name4, description: tool.description }));
|
|
7539
8788
|
}
|
|
7540
|
-
if (!raw.tool || !Object.hasOwn(this.catalog, raw.tool)) throw new ChatError(400,
|
|
8789
|
+
if (!raw.tool || !Object.hasOwn(this.catalog, raw.tool)) throw new ChatError(400, unknownTool(raw.tool, Object.keys(this.catalog)));
|
|
7541
8790
|
const name3 = raw.tool, definition = this.catalog[name3];
|
|
7542
8791
|
if (raw.action === "describe") {
|
|
7543
8792
|
if (raw.args !== void 0) throw new ChatError(400, "describe does not accept args");
|
|
7544
8793
|
return { name: name3, description: definition.description, parameters: definition.schema };
|
|
7545
8794
|
}
|
|
7546
8795
|
const args = raw.args;
|
|
7547
|
-
if (!args || !
|
|
8796
|
+
if (!args || !Value2.Check(definition.schema, args)) throw new ChatError(400, invalidArguments(name3, definition.schema, args));
|
|
7548
8797
|
if (this.agent && Object.hasOwn(agentDefinitions, name3))
|
|
7549
8798
|
return this.agent.call(name3, args, (chatId, question) => this.ask(chatId, question, toolCallId, signal));
|
|
8799
|
+
if (Object.hasOwn(taskDefinitions, name3)) return callTask(this.scope.tasks, this.scope.botId, name3, args);
|
|
7550
8800
|
switch (name3) {
|
|
7551
8801
|
case "history.read":
|
|
7552
8802
|
return readHistory(this.scope.memory, this.scope.workspace, args, signal);
|
|
@@ -7584,6 +8834,16 @@ var Tools = class {
|
|
|
7584
8834
|
until: result.selected[0]?.value === "once" ? "this run ends" : "your owner revokes it"
|
|
7585
8835
|
};
|
|
7586
8836
|
}
|
|
8837
|
+
case "environments.request": {
|
|
8838
|
+
const parsed = args;
|
|
8839
|
+
if (!this.tools.chat(parsed.chatId).memberIds.some((id) => this.scope.chats.member(id)?.kind === "user")) throw new ChatError(400, "No person in this chat to answer; ask in a chat with your owner");
|
|
8840
|
+
const input = { ...parsed, purpose: parsed.purpose.trim() }, prepared = await this.scope.places.request(input);
|
|
8841
|
+
const added = (record3) => ({ id: record3.id, name: record3.name, note: "environments.switch with this id moves your tools there" });
|
|
8842
|
+
if ("granted" in prepared) return { status: "granted", ...added(prepared.granted), note: "You may already use it; environments.switch with this id moves your tools there" };
|
|
8843
|
+
const result = await this.ask(parsed.chatId, prepared.question, toolCallId, signal), found = this.scope.places.found(input);
|
|
8844
|
+
if (result.status !== "answered" || !found) return { messageId: result.messageId, status: "declined", note: "Your owner declined. Don't ask for its password in the chat; go on without it, or ask what they would like instead." };
|
|
8845
|
+
return { messageId: result.messageId, status: prepared.question.machine?.existing ? "granted" : "added", ...added(found) };
|
|
8846
|
+
}
|
|
7587
8847
|
case "chats.create":
|
|
7588
8848
|
return this.tools.create(args);
|
|
7589
8849
|
case "chats.update":
|
|
@@ -7685,7 +8945,7 @@ var Tools = class {
|
|
|
7685
8945
|
};
|
|
7686
8946
|
|
|
7687
8947
|
// src/server/service/bot/inbox.ts
|
|
7688
|
-
import { join as
|
|
8948
|
+
import { join as join12, dirname as dirname8 } from "node:path";
|
|
7689
8949
|
|
|
7690
8950
|
// src/server/shared/agent.ts
|
|
7691
8951
|
var WELCOME_LANGUAGES = { en: "English", zh: "Chinese" };
|
|
@@ -7708,15 +8968,21 @@ function remember(map, id, definition) {
|
|
|
7708
8968
|
}
|
|
7709
8969
|
var environmentLine = (place) => place ? `<environment${attribute("name", place.name)}${attribute("directory", place.directory)}/>` : "";
|
|
7710
8970
|
var iso2 = (time2) => new Date(time2).toISOString().replace(".000Z", "Z");
|
|
7711
|
-
var welcome = (owner, language) => `${owner} has just set up
|
|
7712
|
-
function encodeNoticeBatch(botId, notices, environment) {
|
|
7713
|
-
if (!notices.length) throw new Error("Empty notice batch");
|
|
8971
|
+
var welcome = (owner, language) => `${owner} has just set up this chat platform and picked your model. You are their agent Bot. In this DM, welcome them in ${WELCOME_LANGUAGES[language]}, the language of their interface: say in a few sentences what it is and how you can help, such as building a team of Bots for their work and running the platform for them. Then ask what they want to get done: a software project, writing, research or something else.`;
|
|
8972
|
+
function encodeNoticeBatch(botId, notices, environment, prompt) {
|
|
8973
|
+
if (!notices.length && !prompt) throw new Error("Empty notice batch");
|
|
7714
8974
|
const chats = /* @__PURE__ */ new Map(), members = /* @__PURE__ */ new Map();
|
|
7715
8975
|
const list = noticeTail(notices, chats, members);
|
|
7716
|
-
return `<notice_batch${attribute("bot", botId)}>${[...chats.values()].join("")}${[...members.values()].join("")}${list}${environmentLine(environment)}</notice_batch>`;
|
|
8976
|
+
return `<notice_batch${attribute("bot", botId)}>${[...chats.values()].join("")}${[...members.values()].join("")}${list}${instructionsLine(prompt)}${environmentLine(environment)}</notice_batch>`;
|
|
8977
|
+
}
|
|
8978
|
+
function instructionsLine(change) {
|
|
8979
|
+
if (!change) return "";
|
|
8980
|
+
const how = change.whole ? "The new text of the changed parts follows." : "Lines starting with - were removed and lines starting with + were added; the others are context.";
|
|
8981
|
+
return `<instructions_changed${attribute("parts", change.parts.join(", "))}>${escape(change.diff)}<note>Your instructions changed. ${how} Follow the new version from now on. Your system prompt is not rewritten yet, so that the conversation stays cached; it will be after the next compaction.</note></instructions_changed>`;
|
|
7717
8982
|
}
|
|
7718
8983
|
function noticeTail(notices, chats, members) {
|
|
7719
8984
|
for (const notice of notices) {
|
|
8985
|
+
if (notice.kind === "task") continue;
|
|
7720
8986
|
if (!chats.has(notice.chat.id)) chats.set(notice.chat.id, `<chat${attribute("id", notice.chat.id)}${attribute("type", notice.chat.type)}${notice.chat.title ? attribute("title", notice.chat.title) : ""}/>`);
|
|
7721
8987
|
remember(members, notice.actor.id, `<member${attribute("id", notice.actor.id)}${attribute("name", notice.actor.name)}${attribute("kind", notice.actor.kind)}/>`);
|
|
7722
8988
|
}
|
|
@@ -7728,9 +8994,10 @@ function noticeTail(notices, chats, members) {
|
|
|
7728
8994
|
const changes = changed.map((notice) => `<change${attribute("chat", notice.chat.id)}${attribute("by", notice.actor.id)}${attribute("what", notice.what)}${attribute("at", iso2(notice.createdAt))}${notice.value === null ? "/>" : `>${escape(notice.value)}</change>`}`);
|
|
7729
8995
|
const byAgent = changed.some((notice) => notice.actor.kind === "bot"), byOwner = changed.some((notice) => notice.actor.kind === "user");
|
|
7730
8996
|
const onboard = notices.flatMap((notice) => notice.kind !== "onboard" ? [] : [`<onboard${attribute("chat", notice.chat.id)}${attribute("by", notice.actor.id)}${attribute("at", iso2(notice.createdAt))}><note>${escape(welcome(notice.actor.name, notice.language))}</note></onboard>`]).slice(-1);
|
|
7731
|
-
|
|
8997
|
+
const tasks = notices.flatMap((notice) => notice.kind !== "task" ? [] : [`<task${attribute("id", notice.task.id)}${attribute("name", notice.task.name)}${attribute("schedule", notice.task.schedule)}${attribute("due", iso2(notice.due))}>${escape(notice.task.instruction)}</task>`]);
|
|
8998
|
+
return (tasks.length ? `<tasks>${tasks.join("")}<note>These are your scheduled tasks, not messages from anyone. Do what each says, then send what is worth sending, to your owner unless the task says where; if nothing is, do nothing.</note></tasks>` : "") + (updates.length ? `<updates>${updates.join("")}</updates>` : "") + (marks.length ? `<notices>${marks.join("")}<note>These are reactions to your messages. Act on them if you need to; no reply is required.</note></notices>` : "") + (changes.length ? `<changes>${changes.join("")}` + (byAgent ? "<note>The agent Bot changed your settings, and the changes already apply. A change to your name, description or persona is a change to your instructions too. If something is unclear, ask it in that DM.</note>" : "") + (byOwner ? "<note>Your owner changed where you work, and it already applies: environment is where your file and command tools run now, and access is what environments.switch can take you to. If you were in the middle of something, carry on where you are now.</note>" : "") + "</changes>" : "") + onboard.join("");
|
|
7732
8999
|
}
|
|
7733
|
-
function encodeChatBatch(botId, messages, digest, notices) {
|
|
9000
|
+
function encodeChatBatch(botId, messages, digest, notices, prompt) {
|
|
7734
9001
|
if (!messages.length) throw new Error("Empty chat batch");
|
|
7735
9002
|
const chats = /* @__PURE__ */ new Map(), members = /* @__PURE__ */ new Map(), ids = /* @__PURE__ */ new Set();
|
|
7736
9003
|
const encoded = messages.map((message) => {
|
|
@@ -7743,12 +9010,12 @@ function encodeChatBatch(botId, messages, digest, notices) {
|
|
|
7743
9010
|
const attributes = attribute("id", message.id) + attribute("chat", message.chat.id) + attribute("from", message.sender.id) + attribute("at", at2);
|
|
7744
9011
|
const reply = message.replyTo;
|
|
7745
9012
|
if (reply) remember(members, reply.sender.id, `<member${attribute("id", reply.sender.id)}${attribute("name", reply.sender.name)}${attribute("kind", reply.sender.kind)}/>`);
|
|
7746
|
-
const
|
|
9013
|
+
const quote2 = reply ? `<reply_to${attribute("message", reply.id)}${attribute("from", reply.sender.id)}>${escape(reply.text)}</reply_to>` : "";
|
|
7747
9014
|
const files = message.attachments.map((file) => `<file${attribute("id", file.id)}${attribute("name", file.name)}${attribute("type", file.type)}${attribute("size", file.size)}/>`).join("");
|
|
7748
|
-
return `<message${attributes}>${
|
|
9015
|
+
return `<message${attributes}>${quote2}${message.content ? `<text>${escape(message.content)}</text>` : ""}${files}</message>`;
|
|
7749
9016
|
});
|
|
7750
9017
|
const ref = (key, item) => attribute(key, item.chatId) + (chats.has(item.chatId) ? "" : attribute("title", item.title) + attribute("type", item.type));
|
|
7751
|
-
let tail = notices?.length ? noticeTail(notices, chats, members) : "";
|
|
9018
|
+
let tail = (notices?.length ? noticeTail(notices, chats, members) : "") + instructionsLine(prompt);
|
|
7752
9019
|
if (digest?.unread.length) tail += `<unread${attribute("total", String(digest.unread.reduce((sum, item) => sum + item.count, 0)))}>${digest.unread.map((item) => `<chat${ref("id", item)}${attribute("count", String(item.count))}/>`).join("")}</unread>`;
|
|
7753
9020
|
tail += environmentLine(digest?.environment);
|
|
7754
9021
|
return `<chat_batch${attribute("bot", botId)}>${[...chats.values()].join("")}${[...members.values()].join("")}${encoded.join("")}${tail}</chat_batch>`;
|
|
@@ -7757,11 +9024,12 @@ function encodeChatBatch(botId, messages, digest, notices) {
|
|
|
7757
9024
|
// src/server/service/bot/inbox.ts
|
|
7758
9025
|
var BotInbox = class {
|
|
7759
9026
|
/** `where` is the work environment the Bot is in now, named at the end of each batch (A50). */
|
|
7760
|
-
constructor(botId, repository, chats, files, where2) {
|
|
9027
|
+
constructor(botId, repository, chats, files, tasks, where2) {
|
|
7761
9028
|
this.botId = botId;
|
|
7762
9029
|
this.repository = repository;
|
|
7763
9030
|
this.chats = chats;
|
|
7764
9031
|
this.files = files;
|
|
9032
|
+
this.tasks = tasks;
|
|
7765
9033
|
this.where = where2;
|
|
7766
9034
|
}
|
|
7767
9035
|
/** Messages and other deliveries wait in the inbox (A41); either kind is work for the next run. A digest delivery
|
|
@@ -7772,50 +9040,61 @@ var BotInbox = class {
|
|
|
7772
9040
|
claim(selection) {
|
|
7773
9041
|
return this.repository.claim(this.botId, selection, this.hasNotices());
|
|
7774
9042
|
}
|
|
9043
|
+
/** Anything waiting that is not a message (A39, A41, A58): a task that is due also opens a run without messages. */
|
|
7775
9044
|
hasNotices() {
|
|
7776
9045
|
return this.chats.notices(this.botId).some((notice) => notice.urgency !== "digest");
|
|
7777
9046
|
}
|
|
7778
9047
|
reference(workspace) {
|
|
7779
9048
|
if (this.repository.hasUncertainModel(this.botId)) throw new RuntimeFault("model_switch_uncertain", "The model switch didn't finish. Pick the model again.");
|
|
7780
|
-
return this.repository.ensureSession(this.botId,
|
|
9049
|
+
return this.repository.ensureSession(this.botId, join12(dirname8(workspace), "agent", "pi", "sessions", `${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}.jsonl`));
|
|
7781
9050
|
}
|
|
7782
|
-
/** XML for the claimed messages plus the tail (other deliveries not told yet,
|
|
7783
|
-
* the batch is submitted. */
|
|
7784
|
-
async prepare(batch) {
|
|
9051
|
+
/** XML for the claimed messages plus the tail (other deliveries not told yet, how the instructions changed,
|
|
9052
|
+
* unread); `notices` are told once the batch is submitted. */
|
|
9053
|
+
async prepare(batch, prompt) {
|
|
7785
9054
|
if (!batch.inputs.length) {
|
|
7786
|
-
const pending2 = this.notices();
|
|
9055
|
+
const pending2 = this.notices(prompt);
|
|
7787
9056
|
return pending2 && { xml: pending2.xml, notices: pending2.ids };
|
|
7788
9057
|
}
|
|
7789
9058
|
const inputs = await this.files.envelope(this.botId, batch.inputs.map((input) => input.messageId));
|
|
7790
9059
|
for (const input of batch.inputs) if (!this.chats.get(this.botId, input.chatId)) throw new RuntimeFault("chat_access_changed", "Chat access has changed.");
|
|
7791
9060
|
const digest = { ...this.chats.channels.digest(this.botId), ...this.where ? { environment: this.where() } : {} };
|
|
7792
9061
|
const pending = this.envelope(this.chats.notices(this.botId));
|
|
7793
|
-
return { xml: encodeChatBatch(this.botId, inputs, digest, pending.length ? pending : void 0), notices: pending.map((notice) => notice.id) };
|
|
9062
|
+
return { xml: encodeChatBatch(this.botId, inputs, digest, pending.length ? pending : void 0, prompt), notices: pending.map((notice) => notice.id) };
|
|
7794
9063
|
}
|
|
7795
9064
|
/** Inbox rows as the envelope shape; the steered and the batch tails share it. */
|
|
7796
9065
|
envelope(notices) {
|
|
7797
|
-
return notices.
|
|
9066
|
+
return notices.flatMap((notice) => {
|
|
7798
9067
|
const chat = { id: notice.chatId, type: notice.chatType, title: notice.chatTitle };
|
|
7799
|
-
if (notice.kind === "note") return { kind: "note", id: notice.id, chat, actor: notice.actor, version: Number(notice.detail), createdAt: notice.createdAt };
|
|
7800
|
-
if (notice.kind === "onboard") return { kind: "onboard", id: notice.id, chat, actor: notice.actor, language: welcomeLanguage(notice.detail), createdAt: notice.createdAt };
|
|
9068
|
+
if (notice.kind === "note") return [{ kind: "note", id: notice.id, chat, actor: notice.actor, version: Number(notice.detail), createdAt: notice.createdAt }];
|
|
9069
|
+
if (notice.kind === "onboard") return [{ kind: "onboard", id: notice.id, chat, actor: notice.actor, language: welcomeLanguage(notice.detail), createdAt: notice.createdAt }];
|
|
9070
|
+
if (notice.kind === "task") {
|
|
9071
|
+
const fired = JSON.parse(notice.detail), task = this.tasks.get(this.botId, fired.task);
|
|
9072
|
+
return task ? [{ kind: "task", id: notice.id, chat, task: {
|
|
9073
|
+
id: task.id,
|
|
9074
|
+
name: task.name,
|
|
9075
|
+
instruction: task.instruction,
|
|
9076
|
+
schedule: describeSchedule(task.schedule)
|
|
9077
|
+
}, due: fired.due, createdAt: notice.createdAt }] : [];
|
|
9078
|
+
}
|
|
7801
9079
|
if (notice.kind === "change") {
|
|
7802
9080
|
const change = JSON.parse(notice.detail);
|
|
7803
|
-
return { kind: "change", id: notice.id, chat, actor: notice.actor, what: change.what, value: change.value, createdAt: notice.createdAt };
|
|
9081
|
+
return [{ kind: "change", id: notice.id, chat, actor: notice.actor, what: change.what, value: change.value, createdAt: notice.createdAt }];
|
|
7804
9082
|
}
|
|
7805
9083
|
const marked = notice.messageId ? this.chats.message(this.botId, notice.messageId) : null;
|
|
7806
|
-
const
|
|
7807
|
-
return { kind: "reaction", id: notice.id, chat, message: { id: notice.messageId ?? "", text:
|
|
9084
|
+
const text4 = marked ? previewQuotes(marked.content, marked.quotes).slice(0, 120) : "";
|
|
9085
|
+
return [{ kind: "reaction", id: notice.id, chat, message: { id: notice.messageId ?? "", text: text4 }, actor: notice.actor, emoji: notice.detail, createdAt: notice.createdAt }];
|
|
7808
9086
|
});
|
|
7809
9087
|
}
|
|
7810
9088
|
/** The batch went to the model: each delivery in its tail is told once. */
|
|
7811
9089
|
acknowledge(notices) {
|
|
7812
9090
|
if (notices.length) this.tell(notices);
|
|
7813
9091
|
}
|
|
7814
|
-
/** Notices the Bot has not been told (A39), as the XML that goes into a steered message or a
|
|
7815
|
-
|
|
7816
|
-
|
|
9092
|
+
/** Notices the Bot has not been told (A39), as the XML that goes into a steered message or a run of notices alone.
|
|
9093
|
+
* `steer`: what may go into a running turn; a task waits for the turn to end and opens one of its own (A58). */
|
|
9094
|
+
notices(prompt, steer = false) {
|
|
9095
|
+
const pending = this.chats.notices(this.botId).filter((notice) => !steer || notice.kind !== "task");
|
|
7817
9096
|
if (!pending.length) return null;
|
|
7818
|
-
return { xml: encodeNoticeBatch(this.botId, this.envelope(pending), this.where?.()), ids: pending.map((notice) => notice.id) };
|
|
9097
|
+
return { xml: encodeNoticeBatch(this.botId, this.envelope(pending), this.where?.(), prompt), ids: pending.map((notice) => notice.id) };
|
|
7819
9098
|
}
|
|
7820
9099
|
/** These notices reached the model. */
|
|
7821
9100
|
tell(ids) {
|
|
@@ -7910,7 +9189,7 @@ var AgentRuntime = class {
|
|
|
7910
9189
|
}
|
|
7911
9190
|
this.notices = this.notices.catch(() => {
|
|
7912
9191
|
}).then(async () => {
|
|
7913
|
-
const pending = this.inbox.notices();
|
|
9192
|
+
const pending = this.inbox.notices(null, true);
|
|
7914
9193
|
if (!pending || this.stopping) return;
|
|
7915
9194
|
if (this.prompting && this.session?.steer(pending.xml)) this.inbox.tell(pending.ids);
|
|
7916
9195
|
else this.wake();
|
|
@@ -7945,7 +9224,8 @@ var AgentRuntime = class {
|
|
|
7945
9224
|
this.update({ error: null });
|
|
7946
9225
|
let submitted = false;
|
|
7947
9226
|
try {
|
|
7948
|
-
const
|
|
9227
|
+
const change = session.promptChange();
|
|
9228
|
+
const prepared = await this.inbox.prepare(batch, change);
|
|
7949
9229
|
if (!prepared) {
|
|
7950
9230
|
repository.releaseUnsubmitted(batch.run.id);
|
|
7951
9231
|
continue;
|
|
@@ -7959,6 +9239,7 @@ var AgentRuntime = class {
|
|
|
7959
9239
|
submitted = true;
|
|
7960
9240
|
this.prompting = true;
|
|
7961
9241
|
this.inbox.acknowledge(notices);
|
|
9242
|
+
if (change) session.promptTold(change);
|
|
7962
9243
|
this.promptTask = session.prompt(xml2);
|
|
7963
9244
|
const result = await this.promptTask;
|
|
7964
9245
|
repository.finish(batch.run.id, result.outcome, result.outcome === "failed" ? "model_request_failed" : null);
|
|
@@ -7967,8 +9248,8 @@ var AgentRuntime = class {
|
|
|
7967
9248
|
throw new RuntimeFault("model_request_failed", `Model request failed: ${detail}`, 502, { detail });
|
|
7968
9249
|
}
|
|
7969
9250
|
} catch (error) {
|
|
7970
|
-
const
|
|
7971
|
-
if (
|
|
9251
|
+
const run2 = repository.run(batch.run.id);
|
|
9252
|
+
if (run2?.state === "claimed" || run2?.state === "submitted") repository.finish(batch.run.id, submitted ? "unknown" : "failed", publicFault(error).code);
|
|
7972
9253
|
throw error;
|
|
7973
9254
|
} finally {
|
|
7974
9255
|
this.prompting = false;
|
|
@@ -8057,17 +9338,22 @@ The chat gateway lists tools only you have: models.list, bots.create, bots.updat
|
|
|
8057
9338
|
Other Bots may ask you to do these things for them. Do the ordinary ones; deleting always waits for the owner's confirmation.
|
|
8058
9339
|
You can't add model connections, keys or logins. The owner does that in Settings, so point them there when a model is missing.
|
|
8059
9340
|
`;
|
|
8060
|
-
function
|
|
9341
|
+
function botPromptParts(setup, skills = []) {
|
|
8061
9342
|
const bot = { ...setup.identity(), workspacePath: setup.workspace.cwd };
|
|
8062
|
-
return
|
|
9343
|
+
return [
|
|
9344
|
+
{ name: "Persona", text: `# Persona
|
|
8063
9345
|
Your name is ${bot.name}.
|
|
8064
9346
|
|
|
8065
9347
|
${bot.persona}
|
|
8066
9348
|
|
|
8067
|
-
|
|
9349
|
+
` },
|
|
9350
|
+
{ name: "Environment", text: `## Environment
|
|
8068
9351
|
You are in a group chat with people and other Bots. You take part in direct messages (DMs), channels and threads, and you talk and share information with the other members through the chat.
|
|
8069
9352
|
Here your name is ${bot.name}, and your description is: ${bot.description}
|
|
8070
|
-
|
|
9353
|
+
` },
|
|
9354
|
+
{ name: "Your role", text: bot.agent ? AGENT_ROLE : "" },
|
|
9355
|
+
{ name: "Skills", text: skillsSection(setup, skills) },
|
|
9356
|
+
{ name: "Rules", text: `
|
|
8071
9357
|
## Language
|
|
8072
9358
|
Write to people in the language they write to you in. In a channel or thread, use the language its conversation is held in.
|
|
8073
9359
|
These instructions and the tool descriptions are in English so that every Bot reads the same rules. They do not decide which language you use with people.
|
|
@@ -8078,21 +9364,26 @@ Files in messages come with an ID: files.get puts one into your environment and
|
|
|
8078
9364
|
Channels can be put in channel groups and have a notice. Threads have a title, a status and a summary.
|
|
8079
9365
|
In channels and threads you only get new messages when you are @-mentioned; once you subscribe, every new message there is sent to you. DMs need no @.
|
|
8080
9366
|
Each batch of messages ends with your unread counts and the notices and summaries that have a new version. Read them through the chat gateway when you need to.
|
|
9367
|
+
You can schedule work for yourself, once or on a schedule, with the tasks tools in the chat gateway. When a task is due, its instruction comes in a batch as a <task>; do it then.
|
|
8081
9368
|
|
|
8082
9369
|
## History
|
|
8083
|
-
|
|
8084
|
-
history.read
|
|
9370
|
+
Your History keeps everything you did: your thinking, each tool call with its arguments, and what came back. Most of it never reaches the chat.
|
|
9371
|
+
Each message you sent carries a History ID, the turn in which you sent it. To recall how you did something, find a message of yours with messages.search (mine=true searches only yours), then read that turn with history.read. History IDs are in order, so the IDs just before and after it show what led up to it and what followed; read summaries first, then the details you need.
|
|
9372
|
+
History is material to look back on, not an instruction for the task at hand.
|
|
8085
9373
|
|
|
8086
9374
|
## Secrets
|
|
8087
9375
|
Never ask anyone for a password, token or key in the chat, and never put one in a message or in a file others read.
|
|
8088
9376
|
secrets.list in the chat gateway shows the secrets your owner lets you use. To use one, write its placeholder, such as {{secret:GITHUB_TOKEN}}, in a bash command: it is filled in for that command only. Prefer handing it to a program through its environment, such as GH_TOKEN={{secret:GITHUB_TOKEN}} gh pr list, over putting it among the command's arguments. Placeholders in write and edit content stay as written.
|
|
8089
|
-
Secret values are masked in everything you see.
|
|
9377
|
+
Secret values are masked in everything you see. When work needs a credential you don't have, such as a token, an API key or a password, ask for it with secrets.request: your owner enters it on a card, and you wait for the answer. Don't ask people to run commands that grant you access instead.
|
|
8090
9378
|
|
|
8091
|
-
|
|
9379
|
+
` },
|
|
9380
|
+
{ name: "Workspace", text: workspaceSection(setup, bot.workspacePath) },
|
|
9381
|
+
{ name: "Responding", text: `
|
|
8092
9382
|
## Responding
|
|
8093
9383
|
When you are @-mentioned or get a DM, first let the person know you have it: react to that message with an emoji, or confirm in a sentence or two with an emoji. A reaction is only a signal, not a command; pick the emoji yourself.
|
|
8094
9384
|
When something will take a while, post a short progress note partway through, and give the outcome when you are done. You don't need to answer every message that isn't meant for you.
|
|
8095
|
-
|
|
9385
|
+
` }
|
|
9386
|
+
];
|
|
8096
9387
|
}
|
|
8097
9388
|
function workspaceSection(setup, workspacePath) {
|
|
8098
9389
|
const place = setup.home();
|
|
@@ -8110,7 +9401,7 @@ Leave everything else alone by default: other members' workspaces, live data dir
|
|
|
8110
9401
|
Shared directories and other people's files are read-only. Ask the person in charge before writing to them.
|
|
8111
9402
|
|
|
8112
9403
|
## Work environment
|
|
8113
|
-
Your tools run on your owner's computer by default, the machine
|
|
9404
|
+
Your tools run on your owner's computer by default, the machine where this chat platform's server runs.
|
|
8114
9405
|
${SWITCHING}
|
|
8115
9406
|
`;
|
|
8116
9407
|
}
|
|
@@ -8119,11 +9410,11 @@ function skillsSection(setup, skills) {
|
|
|
8119
9410
|
const home = setup.home(), base = home.kind === "computer" ? home.cwd : setup.workspace.cwd;
|
|
8120
9411
|
return `
|
|
8121
9412
|
## Skills
|
|
8122
|
-
Skills are guides that come with
|
|
9413
|
+
Skills are guides that come with this chat platform for kinds of work you help with. When a task matches one, read its file before you start, and follow it. They are suggestions for the owner: propose a plan from them and act once the owner agrees. They are kept in your workspace and written again each time you start, so changes to them don't last.
|
|
8123
9414
|
${skills.map((skill) => `- ${skill.name}: ${skill.description} File: ${base}/${skillPath(skill.name)}`).join("\n")}
|
|
8124
9415
|
`;
|
|
8125
9416
|
}
|
|
8126
|
-
var SWITCHING = "Each batch of messages ends with the environment you are in now. environments.list shows the ones you may use, and environments.switch moves your tools to another, like cd, until you switch back.";
|
|
9417
|
+
var SWITCHING = "Each batch of messages ends with the environment you are in now. environments.list shows the ones you may use, and environments.switch moves your tools to another, like cd, until you switch back. To work on another machine over SSH, ask your owner to add it with environments.request; they type its password on that card, not in the chat.";
|
|
8127
9418
|
|
|
8128
9419
|
// src/server/shared/live.ts
|
|
8129
9420
|
var LIVE_STEPS = 300;
|
|
@@ -8133,8 +9424,8 @@ var LIVE_INPUT = 300;
|
|
|
8133
9424
|
var LIVE_MESSAGE = 2e3;
|
|
8134
9425
|
|
|
8135
9426
|
// src/server/service/bot/live-input.ts
|
|
8136
|
-
function clip(
|
|
8137
|
-
return
|
|
9427
|
+
function clip(text4, length) {
|
|
9428
|
+
return text4.length > length ? `${text4.slice(0, length - 1)}\u2026` : text4;
|
|
8138
9429
|
}
|
|
8139
9430
|
var field = (value2, length = LIVE_INPUT) => typeof value2 === "string" ? clip(value2, length) : void 0;
|
|
8140
9431
|
var record2 = (value2) => value2 && typeof value2 === "object" && !Array.isArray(value2) ? value2 : {};
|
|
@@ -8163,7 +9454,7 @@ function liveInput(tool, args, chat) {
|
|
|
8163
9454
|
}
|
|
8164
9455
|
function gateway(values, chat) {
|
|
8165
9456
|
const action = values.action === "call" && typeof values.tool === "string" ? values.tool : typeof values.action === "string" ? values.action : "";
|
|
8166
|
-
const inner = record2(values.args), where2 =
|
|
9457
|
+
const inner = record2(values.args), where2 = target2(inner, chat);
|
|
8167
9458
|
if (action === "messages.send") return { action, chat: where2, text: field(inner.message, LIVE_MESSAGE) };
|
|
8168
9459
|
if (action === "messages.ask") return {
|
|
8169
9460
|
action,
|
|
@@ -8173,7 +9464,7 @@ function gateway(values, chat) {
|
|
|
8173
9464
|
};
|
|
8174
9465
|
return where2 ? { action, chat: where2 } : { action };
|
|
8175
9466
|
}
|
|
8176
|
-
function
|
|
9467
|
+
function target2(inner, chat) {
|
|
8177
9468
|
if (typeof inner.to === "string") return { id: null, type: "dm", title: clip(inner.to, 80) };
|
|
8178
9469
|
return typeof inner.chatId === "string" ? chat(inner.chatId) ?? void 0 : void 0;
|
|
8179
9470
|
}
|
|
@@ -8188,10 +9479,10 @@ function sentChat(output, chat) {
|
|
|
8188
9479
|
|
|
8189
9480
|
// src/server/service/bot/live.ts
|
|
8190
9481
|
var FLUSH_MS = 100;
|
|
8191
|
-
function liveOutput(
|
|
8192
|
-
if (!
|
|
8193
|
-
if (
|
|
8194
|
-
return { head:
|
|
9482
|
+
function liveOutput(text4) {
|
|
9483
|
+
if (!text4) return null;
|
|
9484
|
+
if (text4.length <= 2 * LIVE_OUTPUT_END) return { head: text4, tail: "", omitted: 0 };
|
|
9485
|
+
return { head: text4.slice(0, LIVE_OUTPUT_END), tail: text4.slice(-LIVE_OUTPUT_END), omitted: text4.length - 2 * LIVE_OUTPUT_END };
|
|
8195
9486
|
}
|
|
8196
9487
|
var LiveTurns = class {
|
|
8197
9488
|
constructor(chat) {
|
|
@@ -8248,8 +9539,8 @@ var LiveTurns = class {
|
|
|
8248
9539
|
}
|
|
8249
9540
|
const step = this.steps.get(id);
|
|
8250
9541
|
if (!step) return;
|
|
8251
|
-
const
|
|
8252
|
-
step.text =
|
|
9542
|
+
const text4 = (step.text ?? "") + event.delta, over = Math.max(0, text4.length - LIVE_TEXT);
|
|
9543
|
+
step.text = text4.slice(over);
|
|
8253
9544
|
step.omitted = (step.omitted ?? 0) + over;
|
|
8254
9545
|
this.queue({ op: "text", id, append: event.delta });
|
|
8255
9546
|
return;
|
|
@@ -8500,12 +9791,15 @@ var BotService = class {
|
|
|
8500
9791
|
this.deps = deps;
|
|
8501
9792
|
this.agent = new AgentOps(this, deps.store, deps.chats);
|
|
8502
9793
|
this.computers = new Computers({ store: deps.store, changed: () => this.listChanged() });
|
|
8503
|
-
this.
|
|
9794
|
+
this.machines = new SshMachines(deps.store, deps.chats, () => this.listChanged());
|
|
9795
|
+
this.environments = new BotEnvironments(deps.store, this.computers, this.machines);
|
|
8504
9796
|
}
|
|
8505
9797
|
bots = /* @__PURE__ */ new Map();
|
|
8506
9798
|
lives = /* @__PURE__ */ new Map();
|
|
8507
9799
|
computers;
|
|
8508
9800
|
environments;
|
|
9801
|
+
/** The owner's SSH machines (A57). */
|
|
9802
|
+
machines;
|
|
8509
9803
|
closed = false;
|
|
8510
9804
|
started = false;
|
|
8511
9805
|
/** What only the agent Bot does (A48). */
|
|
@@ -8526,14 +9820,14 @@ var BotService = class {
|
|
|
8526
9820
|
const place = this.environments.here(id, record3.workspacePath);
|
|
8527
9821
|
return { name: place.name, directory: place.directory };
|
|
8528
9822
|
};
|
|
8529
|
-
const runtime = new AgentRuntime(new BotInbox(id, store.runtime, chats, files, here), (setup, model, reference, activity) => (this.deps.factory ?? PiSession.create)({
|
|
9823
|
+
const runtime = new AgentRuntime(new BotInbox(id, store.runtime, chats, files, store.tasks, here), (setup, model, reference, activity) => (this.deps.factory ?? PiSession.create)({
|
|
8530
9824
|
store,
|
|
8531
9825
|
bot: record3,
|
|
8532
9826
|
selection: model.selection,
|
|
8533
9827
|
reference,
|
|
8534
9828
|
credentials,
|
|
8535
9829
|
tools: setup.tools,
|
|
8536
|
-
systemPrompt: () =>
|
|
9830
|
+
systemPrompt: () => botPromptParts(setup, skills),
|
|
8537
9831
|
onHistory: setup.onHistory,
|
|
8538
9832
|
activity,
|
|
8539
9833
|
live: (event) => live.event(event),
|
|
@@ -8544,7 +9838,7 @@ var BotService = class {
|
|
|
8544
9838
|
const place = this.environments.home(id, record3.workspacePath);
|
|
8545
9839
|
return { kind: place.kind === "sandbox" ? "computer" : "host", cwd: place.directory };
|
|
8546
9840
|
}, skills);
|
|
8547
|
-
const memory = new Memory(
|
|
9841
|
+
const memory = new Memory(join13(dirname9(record3.workspacePath), "history.db"));
|
|
8548
9842
|
bot = new Bot(id, {
|
|
8549
9843
|
identity: new Identity(id, store),
|
|
8550
9844
|
model: new Model(id, store, resolveThinking),
|
|
@@ -8561,9 +9855,12 @@ var BotService = class {
|
|
|
8561
9855
|
places: {
|
|
8562
9856
|
list: () => this.environments.list(id, record3.workspacePath),
|
|
8563
9857
|
switch: (environmentId) => this.environments.switch(id, environmentId, record3.workspacePath),
|
|
8564
|
-
summary: (botId) => this.environments.summary(botId)
|
|
9858
|
+
summary: (botId) => this.environments.summary(botId),
|
|
9859
|
+
request: (input) => this.machines.prepareRequest(id, input),
|
|
9860
|
+
found: (input) => this.machines.find(id, input)
|
|
8565
9861
|
},
|
|
8566
9862
|
secrets: { list: () => this.deps.secrets.listFor(id), prepare: (name3, purpose) => this.deps.secrets.prepareRequest(id, name3, purpose) },
|
|
9863
|
+
tasks: this.deps.tasks,
|
|
8567
9864
|
...id === AGENT_BOT_ID ? { agent: this.agent } : {}
|
|
8568
9865
|
}),
|
|
8569
9866
|
agentRuntime: runtime,
|
|
@@ -8633,10 +9930,33 @@ var BotService = class {
|
|
|
8633
9930
|
}
|
|
8634
9931
|
/** The owner lets a Bot use their computer, or takes it back; taking it back while the Bot is there sends it home. */
|
|
8635
9932
|
grantHost(id, granted) {
|
|
8636
|
-
|
|
8637
|
-
|
|
9933
|
+
return this.grant(id, "host", granted);
|
|
9934
|
+
}
|
|
9935
|
+
/** The owner lets a Bot use an SSH machine (A57), or takes it back, the same way. */
|
|
9936
|
+
grantMachine(id, machineId, granted) {
|
|
9937
|
+
this.machines.get(machineId);
|
|
9938
|
+
return this.grant(id, machineId, granted);
|
|
9939
|
+
}
|
|
9940
|
+
/** The owner deletes an SSH machine: Bots working there are back in their default, and told. */
|
|
9941
|
+
async removeMachine(machineId, force) {
|
|
9942
|
+
const name3 = this.machines.get(machineId).name, granted = this.machines.get(machineId).botIds;
|
|
9943
|
+
const moved = await this.machines.remove(machineId, force);
|
|
9944
|
+
for (const id of /* @__PURE__ */ new Set([...granted, ...moved])) {
|
|
9945
|
+
if (!this.deps.store.getBot(id)) continue;
|
|
9946
|
+
const changes = [{ what: "access", value: `${name3}: removed` }];
|
|
9947
|
+
if (moved.includes(id)) {
|
|
9948
|
+
const to = this.environments.here(id, this.record(id).workspacePath);
|
|
9949
|
+
changes.push({ what: "environment", value: `${to.name}, in ${to.directory}` });
|
|
9950
|
+
}
|
|
9951
|
+
this.tellOwner(id, changes, moved.includes(id));
|
|
9952
|
+
}
|
|
9953
|
+
}
|
|
9954
|
+
grant(id, environmentId, granted) {
|
|
9955
|
+
const { changed, moved, name: name3 } = this.environments.grant(id, environmentId, granted, this.deps.store.userMemberId(), this.record(id).workspacePath);
|
|
9956
|
+
const changes = changed ? [{ what: "access", value: `${name3}: ${granted ? "you may switch to it" : "taken back"}` }] : [];
|
|
8638
9957
|
if (moved) changes.push({ what: "environment", value: where(moved) });
|
|
8639
9958
|
if (changes.length) this.tellOwner(id, changes, !!moved);
|
|
9959
|
+
if (changed && environmentId !== "host") this.listChanged();
|
|
8640
9960
|
return this.describe(id);
|
|
8641
9961
|
}
|
|
8642
9962
|
/** Tells a Bot what its owner changed about where it works (A41, A50). A move interrupts a running turn, as an
|
|
@@ -8764,7 +10084,7 @@ var BotService = class {
|
|
|
8764
10084
|
this.lives.delete(id);
|
|
8765
10085
|
await this.environments.release(id);
|
|
8766
10086
|
if (!this.deps.store.deleteBot(id)) throw new RuntimeFault("bot_missing", "Bot not found", 404);
|
|
8767
|
-
if (!keepFiles) await
|
|
10087
|
+
if (!keepFiles) await rm4(dirname9(workspacePath), { recursive: true, force: true });
|
|
8768
10088
|
}
|
|
8769
10089
|
async close() {
|
|
8770
10090
|
if (this.closed) return;
|
|
@@ -8800,8 +10120,8 @@ var ConnectionService = class {
|
|
|
8800
10120
|
}
|
|
8801
10121
|
info() {
|
|
8802
10122
|
const listening = this.listening, material = this.material;
|
|
8803
|
-
const addresses = listening && material ? lanAddresses(listening.host).map((
|
|
8804
|
-
return { https: !!material, allowHttp: this.allowHttp(), addresses, fingerprint: material?.fingerprint ?? null };
|
|
10123
|
+
const addresses = listening && material ? lanAddresses(listening.host).map((address2) => `https://${address2.includes(":") ? `[${address2}]` : address2}:${listening.port}`) : [];
|
|
10124
|
+
return { https: !!material, allowHttp: this.allowHttp(), addresses, fingerprint: material?.fingerprint ?? null, fingerprintSha1: material?.fingerprintSha1 ?? null };
|
|
8805
10125
|
}
|
|
8806
10126
|
/** The root certificate (DER) for devices to install, when serving HTTPS. */
|
|
8807
10127
|
rootCertificate() {
|
|
@@ -8922,6 +10242,123 @@ var SecretService = class {
|
|
|
8922
10242
|
}
|
|
8923
10243
|
};
|
|
8924
10244
|
|
|
10245
|
+
// src/server/service/tasks.ts
|
|
10246
|
+
var MAX_WAIT_MS = 6e4;
|
|
10247
|
+
var TaskService = class {
|
|
10248
|
+
constructor(store, chats, wake) {
|
|
10249
|
+
this.store = store;
|
|
10250
|
+
this.chats = chats;
|
|
10251
|
+
this.wake = wake;
|
|
10252
|
+
}
|
|
10253
|
+
timer;
|
|
10254
|
+
stopped = false;
|
|
10255
|
+
/** Where a due task is told: the DM between the Bot and its owner, made when first needed. */
|
|
10256
|
+
dm(botId) {
|
|
10257
|
+
const bot = this.store.getBot(botId);
|
|
10258
|
+
if (!bot) throw new ChatError(404, "Bot not found");
|
|
10259
|
+
return this.chats.openDirect(botId, this.store.userMemberId()).id;
|
|
10260
|
+
}
|
|
10261
|
+
/** Checked against what a Bot and its owner can both mean: an existing schedule the Bot can compute. `previous`:
|
|
10262
|
+
* the task's schedule before; an interval left as it was keeps counting from when it was set, a new one from now. */
|
|
10263
|
+
schedule(value2, previous) {
|
|
10264
|
+
if (!value2 || typeof value2 !== "object" || Array.isArray(value2)) throw new ValidationError("A schedule is needed", "task_schedule_invalid");
|
|
10265
|
+
const input = value2;
|
|
10266
|
+
const clock = (hour, minute) => {
|
|
10267
|
+
if (!Number.isInteger(hour) || !Number.isInteger(minute) || hour < 0 || hour > 23 || minute < 0 || minute > 59)
|
|
10268
|
+
throw new ValidationError("Hour is 0-23 and minute 0-59", "task_schedule_invalid");
|
|
10269
|
+
return { hour, minute };
|
|
10270
|
+
};
|
|
10271
|
+
switch (input.kind) {
|
|
10272
|
+
case "once": {
|
|
10273
|
+
const at2 = typeof input.at === "string" ? Date.parse(input.at) : input.at;
|
|
10274
|
+
if (!Number.isSafeInteger(at2)) throw new ValidationError("A one-off task needs a time", "task_schedule_invalid");
|
|
10275
|
+
return { kind: "once", at: at2 };
|
|
10276
|
+
}
|
|
10277
|
+
case "every": {
|
|
10278
|
+
if (!Number.isInteger(input.minutes) || input.minutes < TASK_MINUTES_MIN || input.minutes > 60 * 24 * 30)
|
|
10279
|
+
throw new ValidationError(`Every ${TASK_MINUTES_MIN} minutes at the least, a month at most`, "task_schedule_invalid");
|
|
10280
|
+
const minutes = input.minutes;
|
|
10281
|
+
return { kind: "every", minutes, since: previous?.kind === "every" && previous.minutes === minutes ? previous.since : Date.now() };
|
|
10282
|
+
}
|
|
10283
|
+
case "daily":
|
|
10284
|
+
return { kind: "daily", ...clock(input.hour, input.minute) };
|
|
10285
|
+
case "weekly": {
|
|
10286
|
+
if (!Number.isInteger(input.weekday) || input.weekday < 0 || input.weekday > 6)
|
|
10287
|
+
throw new ValidationError("Weekday is 0 (Sunday) to 6", "task_schedule_invalid");
|
|
10288
|
+
return { kind: "weekly", weekday: input.weekday, ...clock(input.hour, input.minute) };
|
|
10289
|
+
}
|
|
10290
|
+
default:
|
|
10291
|
+
throw new ValidationError("Schedule kind is once, every, daily or weekly", "task_schedule_invalid");
|
|
10292
|
+
}
|
|
10293
|
+
}
|
|
10294
|
+
text(value2, label, max) {
|
|
10295
|
+
if (typeof value2 !== "string" || !value2.trim() || value2.length > max || value2.includes("\0")) throw new ValidationError(`${label} must be 1-${max} characters`, "task_invalid");
|
|
10296
|
+
return value2.trim();
|
|
10297
|
+
}
|
|
10298
|
+
list(botId) {
|
|
10299
|
+
return this.store.tasks.list(botId);
|
|
10300
|
+
}
|
|
10301
|
+
get(botId, id) {
|
|
10302
|
+
return this.store.tasks.get(botId, id);
|
|
10303
|
+
}
|
|
10304
|
+
create(botId, input) {
|
|
10305
|
+
if (!this.store.getBot(botId)) throw new ChatError(404, "Bot not found");
|
|
10306
|
+
const task = this.store.tasks.create({
|
|
10307
|
+
botId,
|
|
10308
|
+
name: this.text(input.name, "A name", 80),
|
|
10309
|
+
instruction: this.text(input.instruction, "An instruction", 8e3),
|
|
10310
|
+
schedule: this.schedule(input.schedule)
|
|
10311
|
+
});
|
|
10312
|
+
this.tick();
|
|
10313
|
+
return task;
|
|
10314
|
+
}
|
|
10315
|
+
update(botId, id, input) {
|
|
10316
|
+
const before = this.store.tasks.get(botId, id);
|
|
10317
|
+
if (!before) throw new ChatError(404, "Task not found");
|
|
10318
|
+
const task = this.store.tasks.update(botId, id, {
|
|
10319
|
+
...input.name === void 0 ? {} : { name: this.text(input.name, "A name", 80) },
|
|
10320
|
+
...input.instruction === void 0 ? {} : { instruction: this.text(input.instruction, "An instruction", 8e3) },
|
|
10321
|
+
...input.schedule === void 0 ? {} : { schedule: this.schedule(input.schedule, before.schedule) }
|
|
10322
|
+
});
|
|
10323
|
+
this.tick();
|
|
10324
|
+
return task;
|
|
10325
|
+
}
|
|
10326
|
+
setState(botId, id, state) {
|
|
10327
|
+
const task = this.store.tasks.setState(botId, id, state);
|
|
10328
|
+
if (!task) throw new ChatError(404, "Task not found");
|
|
10329
|
+
this.tick();
|
|
10330
|
+
return task;
|
|
10331
|
+
}
|
|
10332
|
+
remove(botId, id) {
|
|
10333
|
+
if (!this.store.tasks.remove(botId, id)) throw new ChatError(404, "Task not found");
|
|
10334
|
+
this.tick();
|
|
10335
|
+
}
|
|
10336
|
+
/** Starts the timer. Runs missed while the server was not running are skipped, not made up (A58): each task moves on to
|
|
10337
|
+
* its next time after now, and a one-off already past is done. */
|
|
10338
|
+
start() {
|
|
10339
|
+
this.stopped = false;
|
|
10340
|
+
this.store.tasks.skipOverdue();
|
|
10341
|
+
this.tick();
|
|
10342
|
+
}
|
|
10343
|
+
stop() {
|
|
10344
|
+
this.stopped = true;
|
|
10345
|
+
if (this.timer) clearTimeout(this.timer);
|
|
10346
|
+
this.timer = void 0;
|
|
10347
|
+
}
|
|
10348
|
+
/** One pass: fire what is due, then sleep until the next one (or a minute, so a clock change is noticed). */
|
|
10349
|
+
tick() {
|
|
10350
|
+
if (this.stopped) return;
|
|
10351
|
+
if (this.timer) clearTimeout(this.timer);
|
|
10352
|
+
this.timer = void 0;
|
|
10353
|
+
const dms = new Map(this.store.tasks.dueBots().map((botId) => [botId, this.dm(botId)]));
|
|
10354
|
+
for (const fired of this.store.tasks.fire(dms)) this.wake(fired.botId);
|
|
10355
|
+
const soonest = this.store.tasks.soonest();
|
|
10356
|
+
const wait = soonest === null ? MAX_WAIT_MS : Math.min(MAX_WAIT_MS, Math.max(1e3, soonest - Date.now()));
|
|
10357
|
+
this.timer = setTimeout(() => this.tick(), wait);
|
|
10358
|
+
this.timer.unref?.();
|
|
10359
|
+
}
|
|
10360
|
+
};
|
|
10361
|
+
|
|
8925
10362
|
// src/server/application.ts
|
|
8926
10363
|
var Application = class _Application {
|
|
8927
10364
|
constructor(store, dataDir, release, factory) {
|
|
@@ -8945,12 +10382,14 @@ var Application = class _Application {
|
|
|
8945
10382
|
this.files = new ChatFiles(this.chats);
|
|
8946
10383
|
store.runtime.recover();
|
|
8947
10384
|
this.secrets = new SecretService(store, this.chats);
|
|
10385
|
+
this.tasks = new TaskService(store, this.chats, (botId) => this.bots.deliver(botId, urgencyOf("task", "schedule")));
|
|
8948
10386
|
this.bots = new BotService({
|
|
8949
10387
|
store,
|
|
8950
10388
|
chats: this.chats,
|
|
8951
10389
|
files: this.files,
|
|
8952
10390
|
credentials: this.credentials,
|
|
8953
10391
|
secrets: this.secrets,
|
|
10392
|
+
tasks: this.tasks,
|
|
8954
10393
|
changed: (id) => this.events.publish({ kind: "bot", id, status: this.bots.status(id) }),
|
|
8955
10394
|
listChanged: () => this.events.publish({ kind: "bots" }),
|
|
8956
10395
|
factory
|
|
@@ -8971,9 +10410,11 @@ var Application = class _Application {
|
|
|
8971
10410
|
models;
|
|
8972
10411
|
connection;
|
|
8973
10412
|
secrets;
|
|
10413
|
+
/** Scheduled tasks and their timer (A58). */
|
|
10414
|
+
tasks;
|
|
8974
10415
|
closing;
|
|
8975
10416
|
static async open(dataDir, options = {}) {
|
|
8976
|
-
await
|
|
10417
|
+
await mkdir7(resolve3(dataDir), { recursive: true, mode: 448 });
|
|
8977
10418
|
const root = await realpath(resolve3(dataDir));
|
|
8978
10419
|
let app;
|
|
8979
10420
|
let release;
|
|
@@ -8982,7 +10423,7 @@ var Application = class _Application {
|
|
|
8982
10423
|
retries: 0,
|
|
8983
10424
|
stale: 3e4,
|
|
8984
10425
|
update: 1e4,
|
|
8985
|
-
lockfilePath:
|
|
10426
|
+
lockfilePath: join14(root, ".runtime.lock"),
|
|
8986
10427
|
onCompromised: () => {
|
|
8987
10428
|
console.error("[runtime] data lock compromised");
|
|
8988
10429
|
options.onCompromised?.();
|
|
@@ -9005,10 +10446,12 @@ var Application = class _Application {
|
|
|
9005
10446
|
}
|
|
9006
10447
|
start() {
|
|
9007
10448
|
this.bots.start();
|
|
10449
|
+
this.tasks.start();
|
|
9008
10450
|
this.models.refreshCatalog();
|
|
9009
10451
|
}
|
|
9010
10452
|
close() {
|
|
9011
10453
|
if (!this.closing) this.closing = (async () => {
|
|
10454
|
+
this.tasks.stop();
|
|
9012
10455
|
await this.auth.close();
|
|
9013
10456
|
await this.bots.close();
|
|
9014
10457
|
await this.credentials.close();
|
|
@@ -9021,17 +10464,17 @@ var Application = class _Application {
|
|
|
9021
10464
|
};
|
|
9022
10465
|
|
|
9023
10466
|
// src/server/daemon.ts
|
|
9024
|
-
import { spawn as
|
|
9025
|
-
import { closeSync, mkdirSync as
|
|
9026
|
-
import { join as
|
|
9027
|
-
var pidPath = (dir) =>
|
|
9028
|
-
var logPath = (dir) =>
|
|
10467
|
+
import { spawn as spawn3 } from "node:child_process";
|
|
10468
|
+
import { closeSync, mkdirSync as mkdirSync6, openSync, readFileSync as readFileSync5, rmSync as rmSync3, writeFileSync as writeFileSync4 } from "node:fs";
|
|
10469
|
+
import { join as join15, resolve as resolve4 } from "node:path";
|
|
10470
|
+
var pidPath = (dir) => join15(resolve4(dir), "jarvy.pid");
|
|
10471
|
+
var logPath = (dir) => join15(resolve4(dir), "jarvy.log");
|
|
9029
10472
|
function writePidFile(dir, url) {
|
|
9030
10473
|
const record3 = { pid: process.pid, startToken: startToken(process.pid), url, startedAt: Date.now() };
|
|
9031
|
-
|
|
10474
|
+
writeFileSync4(pidPath(dir), JSON.stringify(record3), { mode: 384 });
|
|
9032
10475
|
}
|
|
9033
10476
|
function removePidFile(dir) {
|
|
9034
|
-
if (readRecord(dir)?.pid === process.pid)
|
|
10477
|
+
if (readRecord(dir)?.pid === process.pid) rmSync3(pidPath(dir), { force: true });
|
|
9035
10478
|
}
|
|
9036
10479
|
function readRecord(dir) {
|
|
9037
10480
|
try {
|
|
@@ -9060,15 +10503,15 @@ function runningServer(dir) {
|
|
|
9060
10503
|
if (!record3) return null;
|
|
9061
10504
|
const token = alive(record3.pid) ? startToken(record3.pid) : null;
|
|
9062
10505
|
if (token === null || token && record3.startToken && token !== record3.startToken) {
|
|
9063
|
-
|
|
10506
|
+
rmSync3(pidPath(dir), { force: true });
|
|
9064
10507
|
return null;
|
|
9065
10508
|
}
|
|
9066
10509
|
return record3;
|
|
9067
10510
|
}
|
|
9068
10511
|
function startBackground(script, runArgs, dir) {
|
|
9069
|
-
|
|
10512
|
+
mkdirSync6(resolve4(dir), { recursive: true, mode: 448 });
|
|
9070
10513
|
const log = openSync(logPath(dir), "a", 384);
|
|
9071
|
-
const child =
|
|
10514
|
+
const child = spawn3(process.execPath, [...process.execArgv, script, "run", ...runArgs], { detached: true, stdio: ["ignore", log, log, "ipc"] });
|
|
9072
10515
|
closeSync(log);
|
|
9073
10516
|
return new Promise((resolvePromise, reject) => {
|
|
9074
10517
|
child.once("error", reject);
|
|
@@ -9101,7 +10544,7 @@ async function stopServer(dir, waitMs = 3e4) {
|
|
|
9101
10544
|
process.kill(record3.pid, "SIGTERM");
|
|
9102
10545
|
for (const deadline = Date.now() + waitMs; Date.now() < deadline; ) {
|
|
9103
10546
|
if (!alive(record3.pid)) {
|
|
9104
|
-
|
|
10547
|
+
rmSync3(pidPath(dir), { force: true });
|
|
9105
10548
|
return "stopped";
|
|
9106
10549
|
}
|
|
9107
10550
|
await new Promise((done) => setTimeout(done, 200));
|
|
@@ -9169,7 +10612,7 @@ async function initialize(store) {
|
|
|
9169
10612
|
}
|
|
9170
10613
|
function reachNotes(host, port, allowHttp) {
|
|
9171
10614
|
if (isLoopbackHost(host)) return { info: [], warning: null };
|
|
9172
|
-
const info = lanAddresses(host).map((
|
|
10615
|
+
const info = lanAddresses(host).map((address2) => `On your network: https://${address2.includes(":") ? `[${address2}]` : address2}:${port}`);
|
|
9173
10616
|
info.push("To trust Jarvy on other devices, install its certificate from Settings \u2192 Network.");
|
|
9174
10617
|
return { info, warning: allowHttp ? "WARNING: HTTP access is allowed in Settings \u2192 Network, so passwords and conversations reach other devices unencrypted." : null };
|
|
9175
10618
|
}
|
|
@@ -9249,8 +10692,8 @@ async function main() {
|
|
|
9249
10692
|
try {
|
|
9250
10693
|
if (!store.isInitialized()) await initialize(store);
|
|
9251
10694
|
const { host, port, url } = listenOptions(args);
|
|
9252
|
-
const webRoot =
|
|
9253
|
-
if (!existsSync5(
|
|
10695
|
+
const webRoot = join16(dirname10(fileURLToPath2(import.meta.url)), "../web");
|
|
10696
|
+
if (!existsSync5(join16(webRoot, "index.html"))) throw new Error("Web build missing. Run npm run build first");
|
|
9254
10697
|
const server = createJarvyServer(app, webRoot, app.connection.prepare(host, port));
|
|
9255
10698
|
closeHttp = () => server.close();
|
|
9256
10699
|
await server.listen(port, host);
|