@goodfish/jarvy 0.7.0 → 0.8.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 +29 -0
- package/dist/server/cli.js +1543 -277
- package/dist/web/assets/index-Cl_aFSP0.js +594 -0
- package/dist/web/assets/index-CxbCVrx3.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
|
|
@@ -387,6 +387,10 @@ function botRoutes(bots) {
|
|
|
387
387
|
});
|
|
388
388
|
routes.put("/:id/environment", jsonBody, async (c) => {
|
|
389
389
|
const input = await readJson(c), id = c.req.param("id");
|
|
390
|
+
if (input.machineId !== void 0) {
|
|
391
|
+
if (typeof input.machineId !== "string" || typeof input.granted !== "boolean") throw new HTTPException7(400, { message: "machineId and granted (true or false) are needed" });
|
|
392
|
+
return c.json(bots.grantMachine(id, input.machineId, input.granted));
|
|
393
|
+
}
|
|
390
394
|
if (input.hostGranted === void 0) return c.json(bots.setHome(id, environmentKind(input.home)));
|
|
391
395
|
if (typeof input.hostGranted !== "boolean") throw new HTTPException7(400, { message: "hostGranted must be true or false" });
|
|
392
396
|
return c.json(bots.grantHost(id, input.hostGranted));
|
|
@@ -843,15 +847,89 @@ function secretRoutes(secrets) {
|
|
|
843
847
|
return routes;
|
|
844
848
|
}
|
|
845
849
|
|
|
850
|
+
// src/server/api/routes/environments.ts
|
|
851
|
+
import { Hono as Hono12 } from "hono";
|
|
852
|
+
import { HTTPException as HTTPException12 } from "hono/http-exception";
|
|
853
|
+
function environmentRoutes(bots) {
|
|
854
|
+
const routes = new Hono12(), machines = bots.machines;
|
|
855
|
+
routes.get("/", (c) => c.json({ machines: machines.list() }));
|
|
856
|
+
routes.post("/scan", jsonBody, async (c) => {
|
|
857
|
+
const input = await readJson(c);
|
|
858
|
+
return c.json(await machines.scan({ host: input.host, port: input.port, user: input.user }));
|
|
859
|
+
});
|
|
860
|
+
routes.post("/", jsonBody, async (c) => {
|
|
861
|
+
const input = await readJson(c);
|
|
862
|
+
return c.json(await machines.add({
|
|
863
|
+
name: input.name,
|
|
864
|
+
host: input.host,
|
|
865
|
+
port: input.port,
|
|
866
|
+
user: input.user,
|
|
867
|
+
directory: input.directory,
|
|
868
|
+
password: input.password,
|
|
869
|
+
hostKeys: hostKeys(input.hostKeys)
|
|
870
|
+
}), 201);
|
|
871
|
+
});
|
|
872
|
+
routes.put("/:id", jsonBody, async (c) => {
|
|
873
|
+
const input = await readJson(c);
|
|
874
|
+
return c.json(machines.update(c.req.param("id"), { name: input.name, directory: input.directory }));
|
|
875
|
+
});
|
|
876
|
+
routes.post("/:id/check", async (c) => c.json(await machines.check(c.req.param("id"))));
|
|
877
|
+
routes.post("/:id/trust", jsonBody, async (c) => {
|
|
878
|
+
const input = await readJson(c);
|
|
879
|
+
return c.json(await machines.trust(c.req.param("id"), input.fingerprints));
|
|
880
|
+
});
|
|
881
|
+
routes.delete("/:id", async (c) => {
|
|
882
|
+
await bots.removeMachine(c.req.param("id"), c.req.query("force") === "1");
|
|
883
|
+
return c.json({ ok: true });
|
|
884
|
+
});
|
|
885
|
+
routes.put("/:id/grants/:botId", jsonBody, async (c) => {
|
|
886
|
+
const input = await readJson(c);
|
|
887
|
+
if (typeof input.granted !== "boolean") throw new HTTPException12(400, { message: "granted must be true or false" });
|
|
888
|
+
bots.grantMachine(c.req.param("botId"), c.req.param("id"), input.granted);
|
|
889
|
+
return c.json(machines.get(c.req.param("id")));
|
|
890
|
+
});
|
|
891
|
+
routes.post("/requests/:chatId/:messageId", jsonBody, async (c) => {
|
|
892
|
+
const input = await readJson(c);
|
|
893
|
+
return c.json(messageView(await machines.fulfil(c.get("actorId"), c.req.param("chatId"), c.req.param("messageId"), { password: input.password })));
|
|
894
|
+
});
|
|
895
|
+
return routes;
|
|
896
|
+
}
|
|
897
|
+
function hostKeys(value2) {
|
|
898
|
+
const keys = value2;
|
|
899
|
+
const strings = (list) => Array.isArray(list) && list.length > 0 && list.length <= 20 && list.every((item) => typeof item === "string" && item.length <= 2e3);
|
|
900
|
+
if (!keys || !strings(keys.keys) || !strings(keys.fingerprints)) throw new HTTPException12(400, { message: "hostKeys must be what the scan returned" });
|
|
901
|
+
return { keys: keys.keys, fingerprints: keys.fingerprints };
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
// src/server/api/routes/tasks.ts
|
|
905
|
+
import { Hono as Hono13 } from "hono";
|
|
906
|
+
function taskRoutes(services) {
|
|
907
|
+
const routes = new Hono13();
|
|
908
|
+
const bot = (id) => {
|
|
909
|
+
services.bots.describe(id);
|
|
910
|
+
return id;
|
|
911
|
+
};
|
|
912
|
+
routes.get("/bots/:id/tasks", (c) => c.json({ tasks: services.tasks.list(bot(c.req.param("id"))) }));
|
|
913
|
+
routes.post("/bots/:id/tasks", jsonBody, async (c) => c.json(services.tasks.create(bot(c.req.param("id")), await readJson(c)), 201));
|
|
914
|
+
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))));
|
|
915
|
+
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")));
|
|
916
|
+
routes.delete("/bots/:id/tasks/:taskId", (c) => {
|
|
917
|
+
services.tasks.remove(bot(c.req.param("id")), c.req.param("taskId"));
|
|
918
|
+
return c.json({ ok: true });
|
|
919
|
+
});
|
|
920
|
+
return routes;
|
|
921
|
+
}
|
|
922
|
+
|
|
846
923
|
// src/server/api/app.ts
|
|
847
924
|
function createHttpApp(services, webRoot) {
|
|
848
|
-
const app = new
|
|
925
|
+
const app = new Hono14();
|
|
849
926
|
app.use("*", headers);
|
|
850
927
|
app.onError(handleError);
|
|
851
|
-
const api = new
|
|
928
|
+
const api = new Hono14();
|
|
852
929
|
api.use("*", apiSecurity);
|
|
853
930
|
api.route("/", rootCertificateRoute(services.connection));
|
|
854
931
|
installAuthentication(api, services.account);
|
|
932
|
+
api.route("/", taskRoutes(services));
|
|
855
933
|
api.route("/bots", botRoutes(services.bots));
|
|
856
934
|
api.route("/", channelRoutes(services));
|
|
857
935
|
api.route("/chats", chatRoutes(services));
|
|
@@ -861,8 +939,9 @@ function createHttpApp(services, webRoot) {
|
|
|
861
939
|
api.route("/", liveRoutes(services));
|
|
862
940
|
api.route("/", connectionRoutes(services.connection));
|
|
863
941
|
api.route("/secrets", secretRoutes(services.secrets));
|
|
942
|
+
api.route("/environments", environmentRoutes(services.bots));
|
|
864
943
|
api.all("*", () => {
|
|
865
|
-
throw new
|
|
944
|
+
throw new HTTPException13(404, { message: "API route not found" });
|
|
866
945
|
});
|
|
867
946
|
app.route("/api", api);
|
|
868
947
|
app.get("*", serveStatic({ root: webRoot, onFound: (path, c) => {
|
|
@@ -872,16 +951,16 @@ function createHttpApp(services, webRoot) {
|
|
|
872
951
|
c.header("Cache-Control", "no-store");
|
|
873
952
|
} }));
|
|
874
953
|
app.all("*", (c) => {
|
|
875
|
-
if (c.req.method !== "GET" && c.req.method !== "HEAD") throw new
|
|
876
|
-
throw new
|
|
954
|
+
if (c.req.method !== "GET" && c.req.method !== "HEAD") throw new HTTPException13(405, { message: "Method not allowed" });
|
|
955
|
+
throw new HTTPException13(404, { message: "Web assets missing; run npm run build" });
|
|
877
956
|
});
|
|
878
957
|
return app;
|
|
879
958
|
}
|
|
880
959
|
|
|
881
960
|
// src/server/api/server.ts
|
|
882
961
|
var own = { at: 0, addresses: /* @__PURE__ */ new Set() };
|
|
883
|
-
function fromThisMachine(
|
|
884
|
-
const plain2 =
|
|
962
|
+
function fromThisMachine(address2 = "") {
|
|
963
|
+
const plain2 = address2.replace(/^::ffff:/, "");
|
|
885
964
|
if (/^127\./.test(plain2) || plain2 === "::1") return true;
|
|
886
965
|
if (Date.now() - own.at > 6e4) own = { at: Date.now(), addresses: new Set(Object.values(networkInterfaces()).flatMap((list) => (list ?? []).map((item) => item.address))) };
|
|
887
966
|
return own.addresses.has(plain2);
|
|
@@ -945,7 +1024,7 @@ var BOT_COLORS = ["gray", "blue", "green", "amber", "rose", "violet"];
|
|
|
945
1024
|
var AGENT_BOT_ID = "00000000-0000-4000-8000-000000000001";
|
|
946
1025
|
|
|
947
1026
|
// src/server/infra/storage/store.ts
|
|
948
|
-
import { createHash, randomBytes as randomBytes3, randomInt as randomInt3, randomUUID as
|
|
1027
|
+
import { createHash, randomBytes as randomBytes3, randomInt as randomInt3, randomUUID as randomUUID10, scryptSync, timingSafeEqual } from "node:crypto";
|
|
949
1028
|
import { chmodSync as chmodSync2, mkdirSync as mkdirSync2 } from "node:fs";
|
|
950
1029
|
import { homedir as homedir2 } from "node:os";
|
|
951
1030
|
import { dirname as dirname2, join as join3, resolve } from "node:path";
|
|
@@ -1301,8 +1380,8 @@ var Cipher = class _Cipher {
|
|
|
1301
1380
|
location() {
|
|
1302
1381
|
return this.master.location;
|
|
1303
1382
|
}
|
|
1304
|
-
static sealed(
|
|
1305
|
-
return
|
|
1383
|
+
static sealed(text4) {
|
|
1384
|
+
return text4.startsWith(PREFIX);
|
|
1306
1385
|
}
|
|
1307
1386
|
seal(plain2, context2) {
|
|
1308
1387
|
const iv = randomBytes(12), cipher = createCipheriv("aes-256-gcm", this.master.key, iv);
|
|
@@ -1362,6 +1441,78 @@ function migrateSecrets(db, cipher) {
|
|
|
1362
1441
|
}
|
|
1363
1442
|
}
|
|
1364
1443
|
|
|
1444
|
+
// src/server/infra/storage/migrations/ssh-schema.ts
|
|
1445
|
+
function migrateSsh(db) {
|
|
1446
|
+
const version = db.prepare("PRAGMA user_version").get().user_version;
|
|
1447
|
+
if (version >= 22) return;
|
|
1448
|
+
db.exec("PRAGMA foreign_keys=OFF; BEGIN IMMEDIATE");
|
|
1449
|
+
try {
|
|
1450
|
+
db.exec(`
|
|
1451
|
+
CREATE TABLE environments_next(id TEXT PRIMARY KEY, owner_id TEXT NOT NULL REFERENCES members(id),
|
|
1452
|
+
kind TEXT NOT NULL CHECK(kind IN ('host','computer','ssh')), name TEXT NOT NULL DEFAULT '',
|
|
1453
|
+
connection TEXT NOT NULL DEFAULT '{}', directory TEXT, bot_id TEXT REFERENCES bots(id),
|
|
1454
|
+
key TEXT, host_keys TEXT, created_at INTEGER NOT NULL, deleted_at INTEGER);
|
|
1455
|
+
INSERT INTO environments_next(id,owner_id,kind,name,connection,directory,bot_id,created_at,deleted_at)
|
|
1456
|
+
SELECT id,owner_id,kind,name,connection,directory,bot_id,created_at,deleted_at FROM environments;
|
|
1457
|
+
DROP TABLE environments;
|
|
1458
|
+
ALTER TABLE environments_next RENAME TO environments;
|
|
1459
|
+
CREATE UNIQUE INDEX environments_host ON environments(owner_id) WHERE kind='host' AND deleted_at IS NULL;
|
|
1460
|
+
CREATE UNIQUE INDEX environments_computer ON environments(bot_id) WHERE kind='computer' AND deleted_at IS NULL;
|
|
1461
|
+
PRAGMA user_version=22;
|
|
1462
|
+
`);
|
|
1463
|
+
if (db.prepare("PRAGMA foreign_key_check").all().length) throw new Error("Migration has invalid references");
|
|
1464
|
+
db.exec("COMMIT");
|
|
1465
|
+
} catch (error) {
|
|
1466
|
+
db.exec("ROLLBACK");
|
|
1467
|
+
throw error;
|
|
1468
|
+
} finally {
|
|
1469
|
+
db.exec("PRAGMA foreign_keys=ON");
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
// src/server/infra/storage/migrations/task-schema.ts
|
|
1474
|
+
function migrateTasks(db) {
|
|
1475
|
+
const version = db.prepare("PRAGMA user_version").get().user_version;
|
|
1476
|
+
if (version >= 23) return;
|
|
1477
|
+
db.exec("PRAGMA foreign_keys=OFF; BEGIN IMMEDIATE");
|
|
1478
|
+
try {
|
|
1479
|
+
const sequence = db.prepare("SELECT seq FROM sqlite_sequence WHERE name='inbox'").get()?.seq ?? 0;
|
|
1480
|
+
db.exec(`
|
|
1481
|
+
CREATE TABLE tasks(id TEXT PRIMARY KEY, bot_id TEXT NOT NULL REFERENCES bots(id) ON DELETE CASCADE,
|
|
1482
|
+
name TEXT NOT NULL, instruction TEXT NOT NULL,
|
|
1483
|
+
schedule_kind TEXT NOT NULL CHECK(schedule_kind IN ('once','every','daily','weekly')),
|
|
1484
|
+
schedule_at INTEGER, schedule_minutes INTEGER, schedule_hour INTEGER, schedule_minute INTEGER, schedule_weekday INTEGER,
|
|
1485
|
+
state TEXT NOT NULL CHECK(state IN ('active','paused','done')),
|
|
1486
|
+
next_run_at INTEGER, last_run_at INTEGER, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, deleted_at INTEGER);
|
|
1487
|
+
CREATE INDEX tasks_due ON tasks(state, next_run_at) WHERE deleted_at IS NULL;
|
|
1488
|
+
CREATE INDEX tasks_bot ON tasks(bot_id) WHERE deleted_at IS NULL;
|
|
1489
|
+
CREATE TABLE inbox_next(id INTEGER PRIMARY KEY AUTOINCREMENT, member_id TEXT NOT NULL REFERENCES members(id),
|
|
1490
|
+
kind TEXT NOT NULL CHECK(kind IN ('message','reaction','note','onboard','change','task')), reason TEXT NOT NULL,
|
|
1491
|
+
urgency TEXT NOT NULL CHECK(urgency IN ('interrupt','queue','digest')),
|
|
1492
|
+
chat_id TEXT NOT NULL REFERENCES chats(id) ON DELETE CASCADE, message_id TEXT REFERENCES messages(id) ON DELETE CASCADE,
|
|
1493
|
+
actor_id TEXT REFERENCES members(id), detail TEXT,
|
|
1494
|
+
state TEXT NOT NULL DEFAULT 'pending' CHECK(state IN ('pending','claimed','told','done','failed','interrupted','unknown','cancelled')),
|
|
1495
|
+
run_id TEXT REFERENCES bot_runs(id), created_at INTEGER NOT NULL, told_at INTEGER);
|
|
1496
|
+
INSERT INTO inbox_next(id,member_id,kind,reason,urgency,chat_id,message_id,actor_id,detail,state,run_id,created_at,told_at)
|
|
1497
|
+
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;
|
|
1498
|
+
DROP TABLE inbox;
|
|
1499
|
+
ALTER TABLE inbox_next RENAME TO inbox;
|
|
1500
|
+
CREATE UNIQUE INDEX inbox_one_message ON inbox(member_id, message_id) WHERE kind='message';
|
|
1501
|
+
CREATE INDEX inbox_open ON inbox(member_id, state, id);
|
|
1502
|
+
CREATE INDEX inbox_run ON inbox(run_id) WHERE run_id IS NOT NULL;
|
|
1503
|
+
`);
|
|
1504
|
+
db.prepare("UPDATE sqlite_sequence SET seq=MAX(seq,?) WHERE name='inbox'").run(sequence);
|
|
1505
|
+
db.exec("PRAGMA user_version=23");
|
|
1506
|
+
if (db.prepare("PRAGMA foreign_key_check").all().length) throw new Error("Migration has invalid references");
|
|
1507
|
+
db.exec("COMMIT");
|
|
1508
|
+
} catch (error) {
|
|
1509
|
+
db.exec("ROLLBACK");
|
|
1510
|
+
throw error;
|
|
1511
|
+
} finally {
|
|
1512
|
+
db.exec("PRAGMA foreign_keys=ON");
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1365
1516
|
// src/server/infra/storage/migrations/schema.ts
|
|
1366
1517
|
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
1367
1518
|
|
|
@@ -1484,7 +1635,7 @@ function migrateRuntime(db) {
|
|
|
1484
1635
|
// src/server/infra/storage/migrations/schema.ts
|
|
1485
1636
|
function migrateDatabase(db, cipher) {
|
|
1486
1637
|
const version = db.prepare("PRAGMA user_version").get().user_version;
|
|
1487
|
-
if (version >
|
|
1638
|
+
if (version > 23) throw new Error(`Unsupported database version: ${version}`);
|
|
1488
1639
|
migratePrototype(db);
|
|
1489
1640
|
if (version >= 5) {
|
|
1490
1641
|
migrateRuntime(db);
|
|
@@ -1503,6 +1654,8 @@ function migrateDatabase(db, cipher) {
|
|
|
1503
1654
|
migrateAgent(db);
|
|
1504
1655
|
migrateEnvironments(db);
|
|
1505
1656
|
migrateSecrets(db, cipher);
|
|
1657
|
+
migrateSsh(db);
|
|
1658
|
+
migrateTasks(db);
|
|
1506
1659
|
return;
|
|
1507
1660
|
}
|
|
1508
1661
|
db.exec("PRAGMA foreign_keys=OFF; BEGIN IMMEDIATE");
|
|
@@ -1579,6 +1732,8 @@ function migrateDatabase(db, cipher) {
|
|
|
1579
1732
|
migrateAgent(db);
|
|
1580
1733
|
migrateEnvironments(db);
|
|
1581
1734
|
migrateSecrets(db, cipher);
|
|
1735
|
+
migrateSsh(db);
|
|
1736
|
+
migrateTasks(db);
|
|
1582
1737
|
}
|
|
1583
1738
|
|
|
1584
1739
|
// src/server/infra/storage/chat-repository.ts
|
|
@@ -1691,14 +1846,14 @@ var ChannelRepository = class {
|
|
|
1691
1846
|
}
|
|
1692
1847
|
/** Channel notice / thread summary: any member who can write there, people and Bots alike. The routed
|
|
1693
1848
|
* deliveries (A41) are written with the new version; `detail` is that version. */
|
|
1694
|
-
setNote(actorId, chatId,
|
|
1849
|
+
setNote(actorId, chatId, text4, deliveries = []) {
|
|
1695
1850
|
this.chats.assertAccess(actorId, chatId, true);
|
|
1696
1851
|
const type = this.db.prepare("SELECT type FROM chats WHERE id=?").get(chatId).type;
|
|
1697
1852
|
if (type === "dm") throw new ChatError(400, "Direct messages have no notice");
|
|
1698
1853
|
const now = Date.now();
|
|
1699
1854
|
inTransaction(this.db, () => {
|
|
1700
1855
|
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,
|
|
1856
|
+
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
1857
|
this.db.prepare("UPDATE chats SET updated_at=? WHERE id=?").run(now, chatId);
|
|
1703
1858
|
this.markNoteSeen(actorId, chatId);
|
|
1704
1859
|
const version = String(this.db.prepare("SELECT version FROM chat_notes WHERE chat_id=?").get(chatId).version);
|
|
@@ -1904,7 +2059,7 @@ var InteractionRepository = class {
|
|
|
1904
2059
|
notices(botId) {
|
|
1905
2060
|
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
2061
|
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) }));
|
|
2062
|
+
return rows2.filter((row) => this.chats.canAccess(botId, row.chatId)).map(({ actorId, ...row }) => ({ ...row, actor: actorId ? this.chats.member(actorId) : null }));
|
|
1908
2063
|
}
|
|
1909
2064
|
/** These notices reached the Bot (steered into its run, or in a batch it was given). */
|
|
1910
2065
|
tell(botId, ids) {
|
|
@@ -1954,7 +2109,7 @@ var QuoteRepository = class {
|
|
|
1954
2109
|
}
|
|
1955
2110
|
/** Inside the caller's transaction. */
|
|
1956
2111
|
insert(messageId, quotes) {
|
|
1957
|
-
quotes.forEach((
|
|
2112
|
+
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
2113
|
}
|
|
1959
2114
|
of(messageId) {
|
|
1960
2115
|
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);
|
|
@@ -2633,11 +2788,21 @@ var AgentRepository = class {
|
|
|
2633
2788
|
|
|
2634
2789
|
// src/server/infra/storage/environment-repository.ts
|
|
2635
2790
|
import { randomUUID as randomUUID7 } from "node:crypto";
|
|
2636
|
-
var
|
|
2637
|
-
var
|
|
2791
|
+
var FIELDS = (table) => ["id", "owner_id AS ownerId", "kind", "bot_id AS botId", "name", "directory", "connection"].map((column) => `${table}${column}`).join(", ");
|
|
2792
|
+
var COLUMNS = FIELDS("");
|
|
2793
|
+
var record = (row) => ({
|
|
2794
|
+
id: row.id,
|
|
2795
|
+
ownerId: row.ownerId,
|
|
2796
|
+
kind: row.kind,
|
|
2797
|
+
botId: row.botId,
|
|
2798
|
+
name: row.name,
|
|
2799
|
+
directory: row.directory,
|
|
2800
|
+
spec: JSON.parse(row.connection)
|
|
2801
|
+
});
|
|
2638
2802
|
var EnvironmentRepository = class {
|
|
2639
|
-
constructor(db, ownerId) {
|
|
2803
|
+
constructor(db, cipher, ownerId) {
|
|
2640
2804
|
this.db = db;
|
|
2805
|
+
this.cipher = cipher;
|
|
2641
2806
|
this.ownerId = ownerId;
|
|
2642
2807
|
}
|
|
2643
2808
|
get(id) {
|
|
@@ -2659,7 +2824,7 @@ var EnvironmentRepository = class {
|
|
|
2659
2824
|
}
|
|
2660
2825
|
/** Every live Bot's computer. */
|
|
2661
2826
|
computers() {
|
|
2662
|
-
return this.db.prepare(`SELECT e.
|
|
2827
|
+
return this.db.prepare(`SELECT ${FIELDS("e.")} FROM environments e JOIN bots b ON b.id=e.bot_id
|
|
2663
2828
|
WHERE e.kind='computer' AND e.deleted_at IS NULL AND b.deleted_at IS NULL`).all().map(record);
|
|
2664
2829
|
}
|
|
2665
2830
|
/** Gives the Bot a computer, reached by the container's name. */
|
|
@@ -2703,7 +2868,7 @@ var EnvironmentRepository = class {
|
|
|
2703
2868
|
}
|
|
2704
2869
|
/** The environments the owner granted the Bot, oldest grant first. */
|
|
2705
2870
|
grants(botId) {
|
|
2706
|
-
return this.db.prepare(`SELECT e.
|
|
2871
|
+
return this.db.prepare(`SELECT ${FIELDS("e.")} FROM environment_grants g JOIN environments e ON e.id=g.environment_id
|
|
2707
2872
|
WHERE g.bot_id=? AND e.deleted_at IS NULL ORDER BY g.granted_at, e.id`).all(botId).map(record);
|
|
2708
2873
|
}
|
|
2709
2874
|
/** A grant lasts until it is revoked (A50); false when nothing changed. */
|
|
@@ -2713,6 +2878,54 @@ var EnvironmentRepository = class {
|
|
|
2713
2878
|
revoke(environmentId, botId) {
|
|
2714
2879
|
return Number(this.db.prepare("DELETE FROM environment_grants WHERE environment_id=? AND bot_id=?").run(environmentId, botId).changes) > 0;
|
|
2715
2880
|
}
|
|
2881
|
+
/** The SSH machines, oldest first. */
|
|
2882
|
+
sshMachines() {
|
|
2883
|
+
return this.db.prepare(`SELECT ${COLUMNS} FROM environments WHERE kind='ssh' AND deleted_at IS NULL ORDER BY created_at, id`).all().map(record);
|
|
2884
|
+
}
|
|
2885
|
+
addSsh(input, id = randomUUID7()) {
|
|
2886
|
+
const { host, port, user, home, platform } = input;
|
|
2887
|
+
this.db.prepare("INSERT INTO environments(id,owner_id,kind,name,connection,directory,key,host_keys,created_at) VALUES(?,?,'ssh',?,?,?,?,?,?)").run(
|
|
2888
|
+
id,
|
|
2889
|
+
this.ownerId(),
|
|
2890
|
+
input.name,
|
|
2891
|
+
JSON.stringify({ host, port: String(port), user, home, platform }),
|
|
2892
|
+
input.directory,
|
|
2893
|
+
this.cipher.seal(input.privateKey, `environment:${id}`),
|
|
2894
|
+
JSON.stringify(input.hostKeys),
|
|
2895
|
+
Date.now()
|
|
2896
|
+
);
|
|
2897
|
+
return this.get(id);
|
|
2898
|
+
}
|
|
2899
|
+
updateSsh(id, input) {
|
|
2900
|
+
if (input.name !== void 0) this.db.prepare("UPDATE environments SET name=? WHERE id=? AND kind='ssh'").run(input.name, id);
|
|
2901
|
+
if (input.directory !== void 0) this.db.prepare("UPDATE environments SET directory=? WHERE id=? AND kind='ssh'").run(input.directory, id);
|
|
2902
|
+
if (input.hostKeys !== void 0) this.db.prepare("UPDATE environments SET host_keys=? WHERE id=? AND kind='ssh'").run(JSON.stringify(input.hostKeys), id);
|
|
2903
|
+
}
|
|
2904
|
+
/** The machine's private key (null when it doesn't open under this installation's key) and accepted host keys. */
|
|
2905
|
+
sshSecrets(id) {
|
|
2906
|
+
const row = this.db.prepare("SELECT key, host_keys AS hostKeys FROM environments WHERE id=? AND kind='ssh' AND deleted_at IS NULL").get(id);
|
|
2907
|
+
if (!row) return null;
|
|
2908
|
+
return { privateKey: row.key ? this.cipher.open(row.key, `environment:${id}`) : null, hostKeys: row.hostKeys ? JSON.parse(row.hostKeys) : { keys: [], fingerprints: [] } };
|
|
2909
|
+
}
|
|
2910
|
+
/** The Bots that may use an environment. */
|
|
2911
|
+
grantees(environmentId) {
|
|
2912
|
+
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);
|
|
2913
|
+
}
|
|
2914
|
+
/** Deletes an SSH machine with its key: Bots working there are back in their default. Returns those Bots. */
|
|
2915
|
+
dropSsh(id) {
|
|
2916
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
2917
|
+
try {
|
|
2918
|
+
const moved = this.db.prepare("SELECT id FROM bots WHERE current_environment_id=?").all(id).map((row) => row.id);
|
|
2919
|
+
this.db.prepare("UPDATE bots SET current_environment_id=home_environment_id WHERE current_environment_id=?").run(id);
|
|
2920
|
+
this.db.prepare("DELETE FROM environment_grants WHERE environment_id=?").run(id);
|
|
2921
|
+
this.db.prepare("UPDATE environments SET deleted_at=?, key=NULL WHERE id=? AND kind='ssh'").run(Date.now(), id);
|
|
2922
|
+
this.db.exec("COMMIT");
|
|
2923
|
+
return moved;
|
|
2924
|
+
} catch (error) {
|
|
2925
|
+
this.db.exec("ROLLBACK");
|
|
2926
|
+
throw error;
|
|
2927
|
+
}
|
|
2928
|
+
}
|
|
2716
2929
|
};
|
|
2717
2930
|
|
|
2718
2931
|
// src/server/infra/storage/settings-repository.ts
|
|
@@ -2834,6 +3047,193 @@ var SecretRepository = class {
|
|
|
2834
3047
|
}
|
|
2835
3048
|
};
|
|
2836
3049
|
|
|
3050
|
+
// src/server/infra/storage/task-repository.ts
|
|
3051
|
+
import { randomUUID as randomUUID9 } from "node:crypto";
|
|
3052
|
+
|
|
3053
|
+
// src/server/shared/task.ts
|
|
3054
|
+
var TASK_MINUTES_MIN = 5;
|
|
3055
|
+
function localAt(day, hour, minute, addDays) {
|
|
3056
|
+
const at2 = new Date(day.getFullYear(), day.getMonth(), day.getDate() + addDays, hour, minute, 0, 0);
|
|
3057
|
+
return at2.getTime();
|
|
3058
|
+
}
|
|
3059
|
+
function nextAfter(schedule, from) {
|
|
3060
|
+
if (schedule.kind === "once") return schedule.at > from ? schedule.at : null;
|
|
3061
|
+
if (schedule.kind === "every") {
|
|
3062
|
+
const step = schedule.minutes * 6e4;
|
|
3063
|
+
return schedule.since + Math.max(1, Math.floor((from - schedule.since) / step) + 1) * step;
|
|
3064
|
+
}
|
|
3065
|
+
const day = new Date(from);
|
|
3066
|
+
if (schedule.kind === "daily") {
|
|
3067
|
+
const today = localAt(day, schedule.hour, schedule.minute, 0);
|
|
3068
|
+
return today > from ? today : localAt(day, schedule.hour, schedule.minute, 1);
|
|
3069
|
+
}
|
|
3070
|
+
const ahead = (schedule.weekday - day.getDay() + 7) % 7;
|
|
3071
|
+
const candidate = localAt(day, schedule.hour, schedule.minute, ahead);
|
|
3072
|
+
return candidate > from ? candidate : localAt(day, schedule.hour, schedule.minute, ahead + 7);
|
|
3073
|
+
}
|
|
3074
|
+
function describeSchedule(schedule) {
|
|
3075
|
+
const clock = (hour, minute) => `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`;
|
|
3076
|
+
if (schedule.kind === "once") return `once at ${localIso(schedule.at)}`;
|
|
3077
|
+
if (schedule.kind === "every") return schedule.minutes % 60 === 0 && schedule.minutes >= 120 ? `every ${schedule.minutes / 60} hours` : `every ${schedule.minutes} minutes`;
|
|
3078
|
+
if (schedule.kind === "daily") return `daily at ${clock(schedule.hour, schedule.minute)}`;
|
|
3079
|
+
const days = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
|
3080
|
+
return `every ${days[schedule.weekday]} at ${clock(schedule.hour, schedule.minute)}`;
|
|
3081
|
+
}
|
|
3082
|
+
function localIso(time2) {
|
|
3083
|
+
const at2 = new Date(time2), offset = -at2.getTimezoneOffset(), sign2 = offset >= 0 ? "+" : "-", abs = Math.abs(offset);
|
|
3084
|
+
const pad = (value2) => String(value2).padStart(2, "0");
|
|
3085
|
+
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)}`;
|
|
3086
|
+
}
|
|
3087
|
+
|
|
3088
|
+
// src/server/infra/storage/task-repository.ts
|
|
3089
|
+
var COLUMNS3 = `id,bot_id,name,instruction,schedule_kind,schedule_at,schedule_minutes,schedule_hour,schedule_minute,schedule_weekday,
|
|
3090
|
+
state,next_run_at,last_run_at,created_at,updated_at`;
|
|
3091
|
+
var LIMIT = 200;
|
|
3092
|
+
function scheduleOf(row) {
|
|
3093
|
+
switch (row.schedule_kind) {
|
|
3094
|
+
case "once":
|
|
3095
|
+
return { kind: "once", at: row.schedule_at };
|
|
3096
|
+
case "every":
|
|
3097
|
+
return { kind: "every", minutes: row.schedule_minutes, since: row.schedule_at };
|
|
3098
|
+
case "daily":
|
|
3099
|
+
return { kind: "daily", hour: row.schedule_hour, minute: row.schedule_minute };
|
|
3100
|
+
case "weekly":
|
|
3101
|
+
return { kind: "weekly", weekday: row.schedule_weekday, hour: row.schedule_hour, minute: row.schedule_minute };
|
|
3102
|
+
}
|
|
3103
|
+
}
|
|
3104
|
+
function toTask(row) {
|
|
3105
|
+
return {
|
|
3106
|
+
id: row.id,
|
|
3107
|
+
botId: row.bot_id,
|
|
3108
|
+
name: row.name,
|
|
3109
|
+
instruction: row.instruction,
|
|
3110
|
+
schedule: scheduleOf(row),
|
|
3111
|
+
state: row.state,
|
|
3112
|
+
nextRunAt: row.next_run_at,
|
|
3113
|
+
lastRunAt: row.last_run_at,
|
|
3114
|
+
createdAt: row.created_at,
|
|
3115
|
+
updatedAt: row.updated_at
|
|
3116
|
+
};
|
|
3117
|
+
}
|
|
3118
|
+
function scheduleColumns(schedule) {
|
|
3119
|
+
return {
|
|
3120
|
+
kind: schedule.kind,
|
|
3121
|
+
at: schedule.kind === "once" ? schedule.at : schedule.kind === "every" ? schedule.since : null,
|
|
3122
|
+
minutes: schedule.kind === "every" ? schedule.minutes : null,
|
|
3123
|
+
hour: schedule.kind === "daily" || schedule.kind === "weekly" ? schedule.hour : null,
|
|
3124
|
+
minute: schedule.kind === "daily" || schedule.kind === "weekly" ? schedule.minute : null,
|
|
3125
|
+
weekday: schedule.kind === "weekly" ? schedule.weekday : null
|
|
3126
|
+
};
|
|
3127
|
+
}
|
|
3128
|
+
var TaskRepository = class {
|
|
3129
|
+
constructor(db) {
|
|
3130
|
+
this.db = db;
|
|
3131
|
+
}
|
|
3132
|
+
/** A Bot's tasks: active ones first, soonest first; then paused and done ones. */
|
|
3133
|
+
list(botId) {
|
|
3134
|
+
const rows2 = this.db.prepare(`SELECT ${COLUMNS3} FROM tasks WHERE bot_id=? AND deleted_at IS NULL
|
|
3135
|
+
ORDER BY state='active' DESC, COALESCE(next_run_at,0), created_at LIMIT ${LIMIT}`).all(botId);
|
|
3136
|
+
return rows2.map(toTask);
|
|
3137
|
+
}
|
|
3138
|
+
get(botId, id) {
|
|
3139
|
+
const row = this.db.prepare(`SELECT ${COLUMNS3} FROM tasks WHERE id=? AND bot_id=? AND deleted_at IS NULL`).get(id, botId);
|
|
3140
|
+
return row ? toTask(row) : null;
|
|
3141
|
+
}
|
|
3142
|
+
create(input) {
|
|
3143
|
+
const now = Date.now(), at2 = nextAfter(input.schedule, now);
|
|
3144
|
+
if (at2 === null) throw new ValidationError("A one-off task has to be in the future", "task_time_past");
|
|
3145
|
+
const id = randomUUID9(), columns = scheduleColumns(input.schedule);
|
|
3146
|
+
this.db.prepare(`INSERT INTO tasks(id,bot_id,name,instruction,schedule_kind,schedule_at,schedule_minutes,schedule_hour,schedule_minute,schedule_weekday,
|
|
3147
|
+
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);
|
|
3148
|
+
return this.get(input.botId, id);
|
|
3149
|
+
}
|
|
3150
|
+
/** Replaces what is given; a new schedule recomputes the next run from now, and turns a one-off that already ran back on. */
|
|
3151
|
+
update(botId, id, input) {
|
|
3152
|
+
const task = this.get(botId, id);
|
|
3153
|
+
if (!task) return null;
|
|
3154
|
+
const now = Date.now(), schedule = input.schedule ?? task.schedule, columns = scheduleColumns(schedule);
|
|
3155
|
+
if (input.schedule && nextAfter(schedule, now) === null) throw new ValidationError("A one-off task has to be in the future", "task_time_past");
|
|
3156
|
+
const state = input.schedule && task.state === "done" ? "active" : task.state;
|
|
3157
|
+
const next = state !== "active" ? null : input.schedule ? nextAfter(schedule, now) : task.nextRunAt;
|
|
3158
|
+
this.db.prepare(`UPDATE tasks SET name=?,instruction=?,schedule_kind=?,schedule_at=?,schedule_minutes=?,schedule_hour=?,schedule_minute=?,
|
|
3159
|
+
schedule_weekday=?,state=?,next_run_at=?,updated_at=? WHERE id=? AND bot_id=?`).run(
|
|
3160
|
+
input.name ?? task.name,
|
|
3161
|
+
input.instruction ?? task.instruction,
|
|
3162
|
+
columns.kind,
|
|
3163
|
+
columns.at,
|
|
3164
|
+
columns.minutes,
|
|
3165
|
+
columns.hour,
|
|
3166
|
+
columns.minute,
|
|
3167
|
+
columns.weekday,
|
|
3168
|
+
state,
|
|
3169
|
+
next,
|
|
3170
|
+
now,
|
|
3171
|
+
id,
|
|
3172
|
+
botId
|
|
3173
|
+
);
|
|
3174
|
+
return this.get(botId, id);
|
|
3175
|
+
}
|
|
3176
|
+
/** Pauses or resumes it; resuming a one-off already past fails, as making one in the past does. */
|
|
3177
|
+
setState(botId, id, state) {
|
|
3178
|
+
const task = this.get(botId, id);
|
|
3179
|
+
if (!task) return null;
|
|
3180
|
+
const now = Date.now(), next = state === "active" ? nextAfter(task.schedule, now) : null;
|
|
3181
|
+
if (state === "active" && next === null) throw new ValidationError("A one-off task has to be in the future", "task_time_past");
|
|
3182
|
+
this.db.prepare("UPDATE tasks SET state=?,next_run_at=?,updated_at=? WHERE id=? AND bot_id=?").run(state, next, now, id, botId);
|
|
3183
|
+
return this.get(botId, id);
|
|
3184
|
+
}
|
|
3185
|
+
/** Gone from the lists and from the scheduler; the row stays for the record. */
|
|
3186
|
+
remove(botId, id) {
|
|
3187
|
+
const now = Date.now();
|
|
3188
|
+
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;
|
|
3189
|
+
}
|
|
3190
|
+
/** The soonest time any task runs; the scheduler sleeps until then. Null when nothing is scheduled. */
|
|
3191
|
+
soonest() {
|
|
3192
|
+
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();
|
|
3193
|
+
return row?.at ?? null;
|
|
3194
|
+
}
|
|
3195
|
+
due(now) {
|
|
3196
|
+
return this.db.prepare(`SELECT ${COLUMNS3.replace(/(\w+)/g, "t.$1")} FROM tasks t JOIN bots b ON b.id=t.bot_id
|
|
3197
|
+
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);
|
|
3198
|
+
}
|
|
3199
|
+
/** The Bots with a task due now, so the caller can find each one's DM before `fire`. */
|
|
3200
|
+
dueBots(now = Date.now()) {
|
|
3201
|
+
return [...new Set(this.due(now).map((row) => row.bot_id))];
|
|
3202
|
+
}
|
|
3203
|
+
/** The tasks that are due, oldest first, each already moved on: its inbox row (in `dms`, the Bot's DM with its owner)
|
|
3204
|
+
* is written and its next run set in one transaction, so a crash can neither lose nor repeat a run. A Bot missing
|
|
3205
|
+
* from `dms` keeps its task due for the next pass. `at` is when it was due. */
|
|
3206
|
+
fire(dms, now = Date.now()) {
|
|
3207
|
+
const due = this.due(now).filter((row) => dms.has(row.bot_id));
|
|
3208
|
+
if (!due.length) return [];
|
|
3209
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
3210
|
+
try {
|
|
3211
|
+
const fired = [];
|
|
3212
|
+
for (const row of due) {
|
|
3213
|
+
const at2 = row.next_run_at, next = nextAfter(scheduleOf(row), now);
|
|
3214
|
+
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);
|
|
3215
|
+
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);
|
|
3216
|
+
fired.push({ taskId: row.id, botId: row.bot_id, chatId: dms.get(row.bot_id), at: at2 });
|
|
3217
|
+
}
|
|
3218
|
+
this.db.exec("COMMIT");
|
|
3219
|
+
return fired;
|
|
3220
|
+
} catch (error) {
|
|
3221
|
+
this.db.exec("ROLLBACK");
|
|
3222
|
+
throw error;
|
|
3223
|
+
}
|
|
3224
|
+
}
|
|
3225
|
+
/** At start: tasks whose time passed while the server was not running move on to their next time; a one-off already
|
|
3226
|
+
* past is done without running. Returns how many were skipped. */
|
|
3227
|
+
skipOverdue(now = Date.now()) {
|
|
3228
|
+
const rows2 = this.db.prepare(`SELECT ${COLUMNS3} FROM tasks WHERE deleted_at IS NULL AND state='active' AND next_run_at<=?`).all(now);
|
|
3229
|
+
for (const row of rows2) {
|
|
3230
|
+
const next = nextAfter(scheduleOf(row), now);
|
|
3231
|
+
this.db.prepare("UPDATE tasks SET next_run_at=?,state=?,updated_at=? WHERE id=?").run(next, next === null ? "done" : "active", now, row.id);
|
|
3232
|
+
}
|
|
3233
|
+
return rows2.length;
|
|
3234
|
+
}
|
|
3235
|
+
};
|
|
3236
|
+
|
|
2837
3237
|
// src/server/infra/vault/master-key.ts
|
|
2838
3238
|
import { randomBytes as randomBytes2 } from "node:crypto";
|
|
2839
3239
|
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
@@ -2937,6 +3337,8 @@ var Store = class {
|
|
|
2937
3337
|
agent;
|
|
2938
3338
|
environments;
|
|
2939
3339
|
settings;
|
|
3340
|
+
/** Scheduled tasks (A58). */
|
|
3341
|
+
tasks;
|
|
2940
3342
|
secrets;
|
|
2941
3343
|
/** Seals secrets and model credentials under the master key (A52). */
|
|
2942
3344
|
cipher;
|
|
@@ -2958,8 +3360,9 @@ var Store = class {
|
|
|
2958
3360
|
this.runtime = new RuntimeRepository(this.db);
|
|
2959
3361
|
this.credentials = new CredentialRepository(this.db, this.cipher);
|
|
2960
3362
|
this.agent = new AgentRepository(this.db, () => this.userMemberId());
|
|
2961
|
-
this.environments = new EnvironmentRepository(this.db, () => this.userMemberId());
|
|
3363
|
+
this.environments = new EnvironmentRepository(this.db, this.cipher, () => this.userMemberId());
|
|
2962
3364
|
this.settings = new SettingsRepository(this.db);
|
|
3365
|
+
this.tasks = new TaskRepository(this.db);
|
|
2963
3366
|
this.secrets = new SecretRepository(this.db, this.cipher, () => this.userMemberId());
|
|
2964
3367
|
this.agent.ensure();
|
|
2965
3368
|
}
|
|
@@ -2970,7 +3373,7 @@ var Store = class {
|
|
|
2970
3373
|
installationDeviceId() {
|
|
2971
3374
|
const row = this.db.prepare("SELECT device_id FROM installation WHERE id=1").get();
|
|
2972
3375
|
if (row) return row.device_id;
|
|
2973
|
-
const id =
|
|
3376
|
+
const id = randomUUID10();
|
|
2974
3377
|
this.db.prepare("INSERT INTO installation(id,device_id) VALUES(1,?)").run(id);
|
|
2975
3378
|
return id;
|
|
2976
3379
|
}
|
|
@@ -3042,7 +3445,7 @@ var Store = class {
|
|
|
3042
3445
|
if (!this.getBot(id)) return false;
|
|
3043
3446
|
this.db.exec("BEGIN IMMEDIATE");
|
|
3044
3447
|
try {
|
|
3045
|
-
this.db.prepare("UPDATE bots SET deleted_at
|
|
3448
|
+
this.db.prepare("UPDATE bots SET deleted_at=?, connection_id=NULL WHERE id=?").run(Date.now(), id);
|
|
3046
3449
|
this.db.prepare("DELETE FROM chats WHERE type='dm' AND id IN (SELECT chat_id FROM chat_members WHERE member_id=?)").run(id);
|
|
3047
3450
|
for (const table of ["chat_members", "chat_states", "inbox"]) this.db.prepare(`DELETE FROM ${table} WHERE member_id=?`).run(id);
|
|
3048
3451
|
this.db.prepare("DELETE FROM bot_sessions WHERE bot_id=?").run(id);
|
|
@@ -3064,7 +3467,7 @@ var Store = class {
|
|
|
3064
3467
|
/** The caller names the Bot (A45: the interface picks a free default in its own language). */
|
|
3065
3468
|
createBot(name3) {
|
|
3066
3469
|
const clean = this.botName(name3);
|
|
3067
|
-
const id =
|
|
3470
|
+
const id = randomUUID10(), createdAt = Date.now();
|
|
3068
3471
|
this.db.exec("BEGIN IMMEDIATE");
|
|
3069
3472
|
try {
|
|
3070
3473
|
this.db.prepare("INSERT INTO members VALUES(?,'bot')").run(id);
|
|
@@ -3136,7 +3539,7 @@ var Store = class {
|
|
|
3136
3539
|
}
|
|
3137
3540
|
const key = input.apiKey === void 0 ? null : requireText(input.apiKey, "API Key", 8192);
|
|
3138
3541
|
if (key && authType !== "api_key") throw new ValidationError("Invalid credential type");
|
|
3139
|
-
const id =
|
|
3542
|
+
const id = randomUUID10(), now = Date.now();
|
|
3140
3543
|
this.db.exec("BEGIN IMMEDIATE");
|
|
3141
3544
|
try {
|
|
3142
3545
|
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 +3571,21 @@ var Store = class {
|
|
|
3168
3571
|
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
3572
|
return this.getModelConnection(id);
|
|
3170
3573
|
}
|
|
3574
|
+
/** Deleted Bots keep their row (deleteBot) but use nothing: their link to the connection is cleared with it. */
|
|
3171
3575
|
deleteModelConnection(id) {
|
|
3172
3576
|
if (!this.getModelConnection(id)) return false;
|
|
3173
|
-
|
|
3174
|
-
|
|
3577
|
+
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);
|
|
3578
|
+
if (users.length) throw new ConflictError(`In use by ${users.join(", ")}`, "connection_in_use", { bots: users.join(", "), count: users.length });
|
|
3579
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
3580
|
+
try {
|
|
3581
|
+
this.db.prepare("UPDATE bots SET connection_id=NULL WHERE connection_id=? AND deleted_at IS NOT NULL").run(id);
|
|
3582
|
+
const deleted = Number(this.db.prepare("DELETE FROM provider_connections WHERE id=?").run(id).changes) > 0;
|
|
3583
|
+
this.db.exec("COMMIT");
|
|
3584
|
+
return deleted;
|
|
3585
|
+
} catch (error) {
|
|
3586
|
+
this.db.exec("ROLLBACK");
|
|
3587
|
+
throw error;
|
|
3588
|
+
}
|
|
3175
3589
|
}
|
|
3176
3590
|
saveModelCredential(id, input) {
|
|
3177
3591
|
const connection = this.getModelConnection(id);
|
|
@@ -3261,10 +3675,10 @@ var set = (...items) => tlv(49, Buffer.concat(items));
|
|
|
3261
3675
|
var integer2 = (bytes) => tlv(2, bytes[0] & 128 ? Buffer.concat([Buffer.from([0]), bytes]) : bytes);
|
|
3262
3676
|
var bits = (bytes, unused = 0) => tlv(3, Buffer.concat([Buffer.from([unused]), bytes]));
|
|
3263
3677
|
var octets = (bytes) => tlv(4, bytes);
|
|
3264
|
-
var utf8 = (
|
|
3678
|
+
var utf8 = (text4) => tlv(12, Buffer.from(text4, "utf8"));
|
|
3265
3679
|
var TRUE = tlv(1, Buffer.from([255]));
|
|
3266
|
-
function oid(
|
|
3267
|
-
const [a, b, ...rest] =
|
|
3680
|
+
function oid(text4) {
|
|
3681
|
+
const [a, b, ...rest] = text4.split(".").map(Number);
|
|
3268
3682
|
const out = [a * 40 + b];
|
|
3269
3683
|
for (const value2 of rest) {
|
|
3270
3684
|
const chunk = [value2 & 127];
|
|
@@ -3281,10 +3695,10 @@ var ECDSA_SHA256 = seq(oid("1.2.840.10045.4.3.2"));
|
|
|
3281
3695
|
var name = (common) => seq(set(seq(oid("2.5.4.10"), utf8("Jarvy"))), set(seq(oid("2.5.4.3"), utf8(common))));
|
|
3282
3696
|
var extension = (id, value2, critical = false) => seq(oid(id), ...critical ? [TRUE] : [], octets(value2));
|
|
3283
3697
|
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] =
|
|
3698
|
+
function ipBytes(address2) {
|
|
3699
|
+
if (isIPv4(address2)) return Buffer.from(address2.split(".").map(Number));
|
|
3700
|
+
if (!isIPv6(address2) || address2.includes(".")) return null;
|
|
3701
|
+
const [head, tail] = address2.split("::");
|
|
3288
3702
|
const groups = (part) => part ? part.split(":") : [];
|
|
3289
3703
|
const left = groups(head), right = groups(tail);
|
|
3290
3704
|
const all = tail === void 0 ? left : [...left, ...Array(8 - left.length - right.length).fill("0"), ...right];
|
|
@@ -3366,8 +3780,8 @@ function read2(dir, file) {
|
|
|
3366
3780
|
const path = join4(dir, file);
|
|
3367
3781
|
return existsSync2(path) ? readFileSync2(path, "utf8") : null;
|
|
3368
3782
|
}
|
|
3369
|
-
function write(dir, file,
|
|
3370
|
-
writeFileSync2(join4(dir, file),
|
|
3783
|
+
function write(dir, file, text4, secret) {
|
|
3784
|
+
writeFileSync2(join4(dir, file), text4, { mode: secret ? 384 : 420 });
|
|
3371
3785
|
}
|
|
3372
3786
|
function readKey(dir, name3, cipher, log) {
|
|
3373
3787
|
const plain2 = read2(dir, `${name3}.pem`);
|
|
@@ -3415,7 +3829,7 @@ function certificates(dataDir, host, cipher, log = console.log) {
|
|
|
3415
3829
|
log(`[tls] signed a server certificate for ${names.join(", ")}`);
|
|
3416
3830
|
}
|
|
3417
3831
|
const parsedRoot = new X509Certificate2(root.certPem);
|
|
3418
|
-
return { key: server.keyPem, cert: server.certPem, rootDer: parsedRoot.raw, fingerprint: parsedRoot.fingerprint256, names };
|
|
3832
|
+
return { key: server.keyPem, cert: server.certPem, rootDer: parsedRoot.raw, fingerprint: parsedRoot.fingerprint256, fingerprintSha1: parsedRoot.fingerprint, names };
|
|
3419
3833
|
}
|
|
3420
3834
|
|
|
3421
3835
|
// src/server/service/account/service.ts
|
|
@@ -3847,13 +4261,13 @@ var ModelService = class {
|
|
|
3847
4261
|
}
|
|
3848
4262
|
/** Suggests model fields from Pi's catalog (with pi.dev's newer entries, A49); the caller decides whether to save them. */
|
|
3849
4263
|
autoconfigure(input) {
|
|
3850
|
-
const
|
|
3851
|
-
const modelId =
|
|
4264
|
+
const text4 = (value2) => typeof value2 === "string" && value2.trim() && value2.length <= 200 ? value2.trim() : null;
|
|
4265
|
+
const modelId = text4(input.modelId), api = text4(input.api);
|
|
3852
4266
|
if (!modelId || !api) throw new RuntimeFault("autoconfig_model", "Enter the model ID.", 400);
|
|
3853
4267
|
let choice;
|
|
3854
4268
|
if (input.choice !== void 0) {
|
|
3855
4269
|
const raw = input.choice;
|
|
3856
|
-
const provider =
|
|
4270
|
+
const provider = text4(raw?.provider), chosen = text4(raw?.modelId);
|
|
3857
4271
|
if (!provider || !chosen) throw new RuntimeFault("autoconfig_source", "The setup source isn't valid.", 400);
|
|
3858
4272
|
choice = { provider, modelId: chosen };
|
|
3859
4273
|
}
|
|
@@ -3884,7 +4298,7 @@ var ModelService = class {
|
|
|
3884
4298
|
};
|
|
3885
4299
|
|
|
3886
4300
|
// src/server/infra/pi/auth.ts
|
|
3887
|
-
import { randomUUID as
|
|
4301
|
+
import { randomUUID as randomUUID11 } from "node:crypto";
|
|
3888
4302
|
import { ModelRuntime as ModelRuntime2 } from "@earendil-works/pi-coding-agent";
|
|
3889
4303
|
var CredentialLogins = class {
|
|
3890
4304
|
constructor(store, credentials, changed) {
|
|
@@ -3932,7 +4346,7 @@ var CredentialLogins = class {
|
|
|
3932
4346
|
prompt(flow, prompt) {
|
|
3933
4347
|
const signal = prompt.signal ? AbortSignal.any([flow.controller.signal, prompt.signal]) : flow.controller.signal;
|
|
3934
4348
|
signal.throwIfAborted();
|
|
3935
|
-
const id =
|
|
4349
|
+
const id = randomUUID11();
|
|
3936
4350
|
flow.state.prompt = {
|
|
3937
4351
|
id,
|
|
3938
4352
|
type: prompt.type,
|
|
@@ -4018,21 +4432,21 @@ var User = class extends Member {
|
|
|
4018
4432
|
|
|
4019
4433
|
// src/server/application.ts
|
|
4020
4434
|
import { createHash as createHash7 } from "node:crypto";
|
|
4021
|
-
import { mkdir as
|
|
4022
|
-
import { join as
|
|
4435
|
+
import { mkdir as mkdir7, realpath } from "node:fs/promises";
|
|
4436
|
+
import { join as join14, resolve as resolve3 } from "node:path";
|
|
4023
4437
|
import lockfile from "proper-lockfile";
|
|
4024
4438
|
|
|
4025
4439
|
// src/server/service/chat/mentions.ts
|
|
4026
4440
|
var WORD = /[\p{Script=Latin}\p{Nd}_]/u;
|
|
4027
4441
|
function findMentions(source2, candidates2) {
|
|
4028
|
-
const
|
|
4029
|
-
const lower =
|
|
4442
|
+
const text4 = source2.replace(/```[\s\S]*?```|`[^`\n]*`/g, (code) => " ".repeat(code.length));
|
|
4443
|
+
const lower = text4.toLowerCase(), sorted = [...candidates2].sort((a, b) => b.name.length - a.name.length), found = [];
|
|
4030
4444
|
for (let at2 = lower.indexOf("@"); at2 >= 0; at2 = lower.indexOf("@", at2 + 1)) {
|
|
4031
|
-
if (at2 > 0 && WORD.test(
|
|
4445
|
+
if (at2 > 0 && WORD.test(text4[at2 - 1])) continue;
|
|
4032
4446
|
const match = sorted.find((candidate) => {
|
|
4033
4447
|
const name3 = candidate.name.toLowerCase();
|
|
4034
4448
|
if (!name3 || !lower.startsWith(name3, at2 + 1)) return false;
|
|
4035
|
-
const next =
|
|
4449
|
+
const next = text4[at2 + 1 + name3.length];
|
|
4036
4450
|
return !(next && WORD.test(next) && WORD.test(name3.at(-1)));
|
|
4037
4451
|
});
|
|
4038
4452
|
if (match && !found.includes(match)) found.push(match);
|
|
@@ -4045,14 +4459,14 @@ function messageTargets({ dm, sender, members, mentioned, replied, subscribers }
|
|
|
4045
4459
|
if (dm) return members.filter((member) => member.id !== sender.id).map((member) => ({ member, reason: "dm" }));
|
|
4046
4460
|
const targets = mentioned.filter((member) => member.id !== sender.id).map((member) => ({ member, reason: "mention" }));
|
|
4047
4461
|
const add = (member, reason) => {
|
|
4048
|
-
if (member.id !== sender.id && !targets.some((
|
|
4462
|
+
if (member.id !== sender.id && !targets.some((target3) => target3.member.id === member.id)) targets.push({ member, reason });
|
|
4049
4463
|
};
|
|
4050
4464
|
if (replied && members.some((member) => member.id === replied.id)) add(replied, "reply");
|
|
4051
4465
|
for (const member of members) if (subscribers.includes(member.id)) add(member, "subscription");
|
|
4052
4466
|
return targets;
|
|
4053
4467
|
}
|
|
4054
4468
|
function echoTargets({ sender, members, subscribers, reached }) {
|
|
4055
|
-
return members.filter((member) => member.id !== sender.id && subscribers.includes(member.id) && !reached.some((
|
|
4469
|
+
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
4470
|
}
|
|
4057
4471
|
function reactionTargets({ actorId, author }) {
|
|
4058
4472
|
return author && author.id !== actorId ? [{ member: author, reason: "author" }] : [];
|
|
@@ -4074,7 +4488,10 @@ var POLICY = {
|
|
|
4074
4488
|
// What the agent Bot changed about a Bot rides along with that Bot's next batch, without waking it. The owner moving
|
|
4075
4489
|
// 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
4490
|
// Bot not running, and a change of access alone, hear of it with their next batch instead.
|
|
4077
|
-
change: { agent: "digest", owner: "interrupt" }
|
|
4491
|
+
change: { agent: "digest", owner: "interrupt" },
|
|
4492
|
+
// A task that is due wakes its Bot but never interrupts it, and is not steered into a running turn: it waits for
|
|
4493
|
+
// the turn to end (A58).
|
|
4494
|
+
task: { schedule: "queue" }
|
|
4078
4495
|
};
|
|
4079
4496
|
function urgencyOf(kind, reason) {
|
|
4080
4497
|
return POLICY[kind][reason];
|
|
@@ -4085,9 +4502,9 @@ function mutedUrgency(reason, urgency) {
|
|
|
4085
4502
|
|
|
4086
4503
|
// src/server/service/delivery/delivery.ts
|
|
4087
4504
|
function plan(kind, targets, muted = []) {
|
|
4088
|
-
return targets.flatMap((
|
|
4089
|
-
const urgency = urgencyOf(kind,
|
|
4090
|
-
return urgency ? [{ ...
|
|
4505
|
+
return targets.flatMap((target3) => {
|
|
4506
|
+
const urgency = urgencyOf(kind, target3.reason);
|
|
4507
|
+
return urgency ? [{ ...target3, kind, urgency: muted.includes(target3.member.id) ? mutedUrgency(target3.reason, urgency) : urgency }] : [];
|
|
4091
4508
|
});
|
|
4092
4509
|
}
|
|
4093
4510
|
var Dispatcher = class {
|
|
@@ -4270,7 +4687,8 @@ function questionSpec(input) {
|
|
|
4270
4687
|
options: parsed,
|
|
4271
4688
|
multiple: input.multiple === true,
|
|
4272
4689
|
allowCustom: input.allowCustom === true,
|
|
4273
|
-
...input.secret === void 0 ? {} : { secret: secretAsk(input.secret) }
|
|
4690
|
+
...input.secret === void 0 ? {} : { secret: secretAsk(input.secret) },
|
|
4691
|
+
...input.machine === void 0 ? {} : { machine: machineAsk(input.machine) }
|
|
4274
4692
|
};
|
|
4275
4693
|
}
|
|
4276
4694
|
function secretAsk(value2) {
|
|
@@ -4278,6 +4696,22 @@ function secretAsk(value2) {
|
|
|
4278
4696
|
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
4697
|
return { name: ask.name, purpose: ask.purpose.trim(), exists: ask.exists, needsValue: ask.needsValue };
|
|
4280
4698
|
}
|
|
4699
|
+
function machineAsk(value2) {
|
|
4700
|
+
const ask = value2, keys = ask?.hostKeys;
|
|
4701
|
+
const strings = (list) => Array.isArray(list) && list.length <= 20 && list.every((item) => text(item, 2e3));
|
|
4702
|
+
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");
|
|
4703
|
+
const existing = ask.existing;
|
|
4704
|
+
return {
|
|
4705
|
+
name: ask.name,
|
|
4706
|
+
host: ask.host,
|
|
4707
|
+
port: ask.port,
|
|
4708
|
+
user: ask.user,
|
|
4709
|
+
directory: ask.directory,
|
|
4710
|
+
purpose: ask.purpose.trim(),
|
|
4711
|
+
hostKeys: { keys: keys.keys, fingerprints: keys.fingerprints },
|
|
4712
|
+
existing: existing && typeof existing.id === "string" && typeof existing.name === "string" ? { id: existing.id, name: existing.name } : null
|
|
4713
|
+
};
|
|
4714
|
+
}
|
|
4281
4715
|
function answerOf(question, input) {
|
|
4282
4716
|
const selected = input.selected ?? [], custom = input.custom === void 0 || input.custom === null || input.custom === "" ? null : input.custom;
|
|
4283
4717
|
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 +5053,9 @@ var ChatService = class {
|
|
|
4619
5053
|
/** A DM with another member of the actor's space (a Bot DMs its owner or another Bot); one per pair. */
|
|
4620
5054
|
openDirect(actorId, targetId) {
|
|
4621
5055
|
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 ${
|
|
5056
|
+
const actor = directory.find((member) => member.id === actorId), target3 = directory.find((member) => member.id === targetId);
|
|
5057
|
+
if (!actor || !target3) throw new ChatError(404, "Member not found");
|
|
5058
|
+
const chat = this.repository.openDirect(actorId, targetId, owner, `${actor.name} \xB7 ${target3.name}`);
|
|
4625
5059
|
this.notifySaved(chat.id);
|
|
4626
5060
|
return chat;
|
|
4627
5061
|
}
|
|
@@ -4889,8 +5323,8 @@ function activityPage(messages, history, limit) {
|
|
|
4889
5323
|
}
|
|
4890
5324
|
|
|
4891
5325
|
// src/server/service/bot/service.ts
|
|
4892
|
-
import { dirname as
|
|
4893
|
-
import { rm as
|
|
5326
|
+
import { dirname as dirname9, join as join13 } from "node:path";
|
|
5327
|
+
import { rm as rm4 } from "node:fs/promises";
|
|
4894
5328
|
|
|
4895
5329
|
// src/server/infra/pi/history.ts
|
|
4896
5330
|
var value = (input) => JSON.parse(JSON.stringify(input));
|
|
@@ -4954,9 +5388,9 @@ var PiHistoryAdapter = class {
|
|
|
4954
5388
|
if (m.role === "system") {
|
|
4955
5389
|
if (m.sections) {
|
|
4956
5390
|
const sections = {};
|
|
4957
|
-
for (const [name3,
|
|
4958
|
-
sections[name3] =
|
|
4959
|
-
if (
|
|
5391
|
+
for (const [name3, text4] of Object.entries(m.sections)) {
|
|
5392
|
+
sections[name3] = text4 === null ? null : message.content.length;
|
|
5393
|
+
if (text4 !== null) message.content.push({ type: "text", text: text4 });
|
|
4960
5394
|
}
|
|
4961
5395
|
pi.sections = sections;
|
|
4962
5396
|
}
|
|
@@ -5029,6 +5463,99 @@ function reportedContext(branch) {
|
|
|
5029
5463
|
return { tokens: null, stale: false };
|
|
5030
5464
|
}
|
|
5031
5465
|
|
|
5466
|
+
// src/server/shared/prompt.ts
|
|
5467
|
+
var joinPrompt = (parts) => parts.map((part) => part.text).join("");
|
|
5468
|
+
|
|
5469
|
+
// src/server/infra/pi/prompt-change.ts
|
|
5470
|
+
var CHANGE_LINES = 40;
|
|
5471
|
+
var CONTEXT_LINES = 2;
|
|
5472
|
+
var TEXT_LIMIT = 8e3;
|
|
5473
|
+
var DIFF_LIMIT = 1500;
|
|
5474
|
+
function lineDiff(before, after2) {
|
|
5475
|
+
const rows2 = before.length, columns = after2.length;
|
|
5476
|
+
const lcs = Array.from({ length: rows2 + 1 }, () => new Uint32Array(columns + 1));
|
|
5477
|
+
for (let i2 = rows2 - 1; i2 >= 0; i2--) for (let j2 = columns - 1; j2 >= 0; j2--)
|
|
5478
|
+
lcs[i2][j2] = before[i2] === after2[j2] ? lcs[i2 + 1][j2 + 1] + 1 : Math.max(lcs[i2 + 1][j2], lcs[i2][j2 + 1]);
|
|
5479
|
+
const out = [];
|
|
5480
|
+
let i = 0, j = 0;
|
|
5481
|
+
while (i < rows2 && j < columns) {
|
|
5482
|
+
if (before[i] === after2[j]) {
|
|
5483
|
+
out.push({ kind: " ", text: after2[j] });
|
|
5484
|
+
i++;
|
|
5485
|
+
j++;
|
|
5486
|
+
} else if (lcs[i + 1][j] >= lcs[i][j + 1]) out.push({ kind: "-", text: before[i++] });
|
|
5487
|
+
else out.push({ kind: "+", text: after2[j++] });
|
|
5488
|
+
}
|
|
5489
|
+
while (i < rows2) out.push({ kind: "-", text: before[i++] });
|
|
5490
|
+
while (j < columns) out.push({ kind: "+", text: after2[j++] });
|
|
5491
|
+
return out;
|
|
5492
|
+
}
|
|
5493
|
+
function hunks(lines) {
|
|
5494
|
+
const keep = /* @__PURE__ */ new Set();
|
|
5495
|
+
lines.forEach((line, index) => {
|
|
5496
|
+
if (line.kind === " ") return;
|
|
5497
|
+
for (let at2 = Math.max(0, index - CONTEXT_LINES); at2 <= Math.min(lines.length - 1, index + CONTEXT_LINES); at2++) keep.add(at2);
|
|
5498
|
+
});
|
|
5499
|
+
const out = [];
|
|
5500
|
+
let last = -1;
|
|
5501
|
+
for (const index of [...keep].sort((a, b) => a - b)) {
|
|
5502
|
+
if (last >= 0 && index > last + 1) out.push("\u2026");
|
|
5503
|
+
const line = lines[index];
|
|
5504
|
+
out.push(`${line.kind}${line.text}`);
|
|
5505
|
+
last = index;
|
|
5506
|
+
}
|
|
5507
|
+
return out;
|
|
5508
|
+
}
|
|
5509
|
+
function promptChange(before, after2) {
|
|
5510
|
+
if (joinPrompt(before) === joinPrompt(after2)) return null;
|
|
5511
|
+
const next = after2;
|
|
5512
|
+
const old = new Map(before.map((part) => [part.name, part.text]));
|
|
5513
|
+
const changed = after2.filter((part) => (old.get(part.name) ?? "") !== part.text);
|
|
5514
|
+
const gone = before.filter((part) => !after2.some((next2) => next2.name === part.name));
|
|
5515
|
+
const parts = [...changed.map((part) => part.name), ...gone.map((part) => part.name)];
|
|
5516
|
+
const lines = [];
|
|
5517
|
+
let tooLong = false;
|
|
5518
|
+
for (const name3 of parts) {
|
|
5519
|
+
const from = (old.get(name3) ?? "").split("\n"), to = (after2.find((part) => part.name === name3)?.text ?? "").split("\n");
|
|
5520
|
+
if (from.length > DIFF_LIMIT || to.length > DIFF_LIMIT) {
|
|
5521
|
+
tooLong = true;
|
|
5522
|
+
break;
|
|
5523
|
+
}
|
|
5524
|
+
lines.push(`@@ ${name3}`, ...hunks(lineDiff(from, to)));
|
|
5525
|
+
if (lines.length > CHANGE_LINES) {
|
|
5526
|
+
tooLong = true;
|
|
5527
|
+
break;
|
|
5528
|
+
}
|
|
5529
|
+
}
|
|
5530
|
+
const cut = (text4) => text4.length > TEXT_LIMIT ? `${text4.slice(0, TEXT_LIMIT)}\u2026` : text4;
|
|
5531
|
+
if (!tooLong) return { parts, diff: cut(lines.join("\n")), whole: false, next };
|
|
5532
|
+
return { parts, diff: cut(changed.map((part) => `@@ ${part.name}
|
|
5533
|
+
${part.text.trim()}`).join("\n\n")), whole: true, next };
|
|
5534
|
+
}
|
|
5535
|
+
var PromptLog = class {
|
|
5536
|
+
adopted;
|
|
5537
|
+
told;
|
|
5538
|
+
constructor(now) {
|
|
5539
|
+
this.adopted = this.told = now();
|
|
5540
|
+
}
|
|
5541
|
+
/** The prompt every request goes out with, until a change is taken up. */
|
|
5542
|
+
get current() {
|
|
5543
|
+
return joinPrompt(this.adopted);
|
|
5544
|
+
}
|
|
5545
|
+
/** What to tell the model with the next batch: how the live prompt differs from the newest one it knows. */
|
|
5546
|
+
pending(now) {
|
|
5547
|
+
return promptChange(this.told, now());
|
|
5548
|
+
}
|
|
5549
|
+
/** The model was told: the next change is measured from here. */
|
|
5550
|
+
sent(change) {
|
|
5551
|
+
this.told = change.next;
|
|
5552
|
+
}
|
|
5553
|
+
/** A new model or a compaction: the live prompt goes out from now on, with nothing left to tell. */
|
|
5554
|
+
take(now) {
|
|
5555
|
+
this.adopted = this.told = now();
|
|
5556
|
+
}
|
|
5557
|
+
};
|
|
5558
|
+
|
|
5032
5559
|
// src/server/infra/pi/http.ts
|
|
5033
5560
|
import { EventEmitter } from "node:events";
|
|
5034
5561
|
import * as undici from "undici";
|
|
@@ -5128,7 +5655,7 @@ import { StringDecoder } from "node:string_decoder";
|
|
|
5128
5655
|
var placeholder = (name3) => `{{secret:${name3}}}`;
|
|
5129
5656
|
var MIN_MASKED = 4;
|
|
5130
5657
|
var MIN_FRAGMENT = 8;
|
|
5131
|
-
var xml = (
|
|
5658
|
+
var xml = (text4) => text4.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
|
|
5132
5659
|
function base64Fragments(value2) {
|
|
5133
5660
|
const bytes = Buffer.from(value2, "utf8"), found = [];
|
|
5134
5661
|
for (const offset of [0, 1, 2]) {
|
|
@@ -5157,8 +5684,8 @@ var Masker = class {
|
|
|
5157
5684
|
get empty() {
|
|
5158
5685
|
return this.pairs.length === 0;
|
|
5159
5686
|
}
|
|
5160
|
-
mask(
|
|
5161
|
-
let result =
|
|
5687
|
+
mask(text4) {
|
|
5688
|
+
let result = text4;
|
|
5162
5689
|
for (const { form, name: name3 } of this.pairs) if (result.includes(form)) result = result.split(form).join(placeholder(name3));
|
|
5163
5690
|
return result;
|
|
5164
5691
|
}
|
|
@@ -5240,9 +5767,9 @@ var u16le = (bytes, offset) => at(bytes, offset) + (at(bytes, offset + 1) << 8);
|
|
|
5240
5767
|
var u32be = (bytes, offset) => at(bytes, offset) * 16777216 + (at(bytes, offset + 1) << 16) + (at(bytes, offset + 2) << 8) + at(bytes, offset + 3);
|
|
5241
5768
|
var u32le = (bytes, offset) => at(bytes, offset) + (at(bytes, offset + 1) << 8) + (at(bytes, offset + 2) << 16) + at(bytes, offset + 3) * 16777216;
|
|
5242
5769
|
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 <
|
|
5770
|
+
function ascii(bytes, offset, text4) {
|
|
5771
|
+
if (bytes.length < offset + text4.length) return false;
|
|
5772
|
+
for (let index = 0; index < text4.length; index++) if (bytes[offset + index] !== text4.charCodeAt(index)) return false;
|
|
5246
5773
|
return true;
|
|
5247
5774
|
}
|
|
5248
5775
|
|
|
@@ -5364,7 +5891,7 @@ function environmentTools(lookup, secrets) {
|
|
|
5364
5891
|
const access = async (path) => {
|
|
5365
5892
|
if (!await current().exists(path)) throw Object.assign(new Error(`ENOENT: no such file or directory, access '${path}'`), { code: "ENOENT" });
|
|
5366
5893
|
};
|
|
5367
|
-
const
|
|
5894
|
+
const readFile4 = (path) => current().readFile(path), writeFile6 = (path, content2) => current().writeFile(path, content2);
|
|
5368
5895
|
const cwd = current().cwd;
|
|
5369
5896
|
const definitions2 = [
|
|
5370
5897
|
relocated(createBashToolDefinition(cwd, { operations: {
|
|
@@ -5387,9 +5914,9 @@ function environmentTools(lookup, secrets) {
|
|
|
5387
5914
|
}
|
|
5388
5915
|
} }), current),
|
|
5389
5916
|
// 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:
|
|
5391
|
-
createWriteToolDefinition(cwd, { operations: { writeFile:
|
|
5392
|
-
createEditToolDefinition(cwd, { operations: { readFile:
|
|
5917
|
+
createReadToolDefinition(cwd, { operations: { readFile: readFile4, access, detectImageMimeType: async (path) => imageKind(await current().head(path, IMAGE_SNIFF_BYTES)) } }),
|
|
5918
|
+
createWriteToolDefinition(cwd, { operations: { writeFile: writeFile6, mkdir: (path) => current().mkdir(path) } }),
|
|
5919
|
+
createEditToolDefinition(cwd, { operations: { readFile: readFile4, writeFile: writeFile6, access } })
|
|
5393
5920
|
];
|
|
5394
5921
|
return definitions2.map((definition) => inside(definition, lookup, pinned));
|
|
5395
5922
|
}
|
|
@@ -5397,7 +5924,7 @@ function inside(definition, lookup, pinned) {
|
|
|
5397
5924
|
const execute = definition.execute.bind(definition);
|
|
5398
5925
|
return { ...definition, execute: ((...args) => {
|
|
5399
5926
|
const env = lookup(), ctx = args[4];
|
|
5400
|
-
if (ctx && typeof ctx === "object") args[4] = new Proxy(ctx, { get: (
|
|
5927
|
+
if (ctx && typeof ctx === "object") args[4] = new Proxy(ctx, { get: (target3, key) => key === "cwd" ? env.cwd : Reflect.get(target3, key, target3) });
|
|
5401
5928
|
return pinned.run({ env, toolCallId: typeof args[0] === "string" ? args[0] : null }, () => execute(...args));
|
|
5402
5929
|
}) };
|
|
5403
5930
|
}
|
|
@@ -5417,12 +5944,12 @@ function relocated(definition, current) {
|
|
|
5417
5944
|
try {
|
|
5418
5945
|
const result = await execute(...args), path = result.details?.fullOutputPath;
|
|
5419
5946
|
if (typeof path !== "string") return result;
|
|
5420
|
-
const
|
|
5947
|
+
const target3 = await moveOut(path, env), swap = (text4) => text4?.replaceAll(path, target3);
|
|
5421
5948
|
return {
|
|
5422
5949
|
...result,
|
|
5423
5950
|
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:
|
|
5951
|
+
details: { ...result.details, fullOutputPath: target3 },
|
|
5952
|
+
...result.structuredContent?.full_output_path ? { structuredContent: { ...result.structuredContent, full_output_path: target3 } } : {}
|
|
5426
5953
|
};
|
|
5427
5954
|
} catch (error) {
|
|
5428
5955
|
const path = error instanceof Error ? /Full output: (\S+?)\]/.exec(error.message)?.[1] : void 0;
|
|
@@ -5432,10 +5959,10 @@ function relocated(definition, current) {
|
|
|
5432
5959
|
}) };
|
|
5433
5960
|
}
|
|
5434
5961
|
async function moveOut(path, env) {
|
|
5435
|
-
const
|
|
5436
|
-
await env.writeFile(
|
|
5962
|
+
const target3 = `${env.temp ?? `${env.cwd}/temp`}/${basename(path)}`;
|
|
5963
|
+
await env.writeFile(target3, await readLocal(path));
|
|
5437
5964
|
await rm(path, { force: true });
|
|
5438
|
-
return
|
|
5965
|
+
return target3;
|
|
5439
5966
|
}
|
|
5440
5967
|
function defined(vars) {
|
|
5441
5968
|
return Object.fromEntries(Object.entries(vars).filter((entry) => entry[1] !== void 0));
|
|
@@ -5466,6 +5993,7 @@ var PiSession = class _PiSession {
|
|
|
5466
5993
|
result = { outcome: "completed" };
|
|
5467
5994
|
signature = "";
|
|
5468
5995
|
translator = new LiveTranslator();
|
|
5996
|
+
prompts;
|
|
5469
5997
|
get context() {
|
|
5470
5998
|
const usage = this.session.getContextUsage();
|
|
5471
5999
|
if (!usage) return void 0;
|
|
@@ -5489,7 +6017,7 @@ var PiSession = class _PiSession {
|
|
|
5489
6017
|
if (!resolve2(reference.file).startsWith(expected) || dirname3(resolve2(reference.file)) !== resolve2(sessionsDir))
|
|
5490
6018
|
throw new RuntimeFault("session_path", "The session file path isn't valid.");
|
|
5491
6019
|
const exists = existsSync3(reference.file);
|
|
5492
|
-
if (!exists && (reference.persisted || store.runtime.runs(bot.id).some((
|
|
6020
|
+
if (!exists && (reference.persisted || store.runtime.runs(bot.id).some((run2) => run2.state === "unknown")))
|
|
5493
6021
|
throw new RuntimeFault("session_missing", "The session file is missing; check the run records before recovering.");
|
|
5494
6022
|
if (exists) {
|
|
5495
6023
|
let header;
|
|
@@ -5512,6 +6040,7 @@ var PiSession = class _PiSession {
|
|
|
5512
6040
|
const model = installModel(models, connection, configured2, store.modelHeaders(connection.id, configured2.id));
|
|
5513
6041
|
const sessionManager = SessionManager.open(reference.file, sessionsDir, bot.workspacePath);
|
|
5514
6042
|
const adapter = new _PiSession(options, models, credentials);
|
|
6043
|
+
adapter.prompts = new PromptLog(options.systemPrompt);
|
|
5515
6044
|
configureHttp(HTTP_IDLE_TIMEOUT_MS);
|
|
5516
6045
|
const settingsManager = SettingsManager.inMemory({
|
|
5517
6046
|
httpIdleTimeoutMs: HTTP_IDLE_TIMEOUT_MS,
|
|
@@ -5526,7 +6055,7 @@ var PiSession = class _PiSession {
|
|
|
5526
6055
|
noSkills: true,
|
|
5527
6056
|
noPromptTemplates: true,
|
|
5528
6057
|
noContextFiles: true,
|
|
5529
|
-
systemPromptOverride:
|
|
6058
|
+
systemPromptOverride: () => adapter.prompts.current,
|
|
5530
6059
|
appendSystemPromptOverride: () => [],
|
|
5531
6060
|
extensionFactories: [createCodemodeExtension(), (api) => {
|
|
5532
6061
|
for (const tool of options.tools) api.registerTool({
|
|
@@ -5544,17 +6073,17 @@ var PiSession = class _PiSession {
|
|
|
5544
6073
|
return { content: [{ type: "text", text: typeof output === "string" ? output : JSON.stringify(output) }], details: {} };
|
|
5545
6074
|
}
|
|
5546
6075
|
});
|
|
5547
|
-
api.on("before_agent_start", () => ({ systemPrompt:
|
|
6076
|
+
api.on("before_agent_start", () => ({ systemPrompt: adapter.prompts.current }));
|
|
5548
6077
|
api.on("tool_result", (event) => {
|
|
5549
6078
|
const masker = options.secrets?.masker();
|
|
5550
6079
|
if (!masker || masker.empty) return void 0;
|
|
5551
6080
|
let changed = false;
|
|
5552
6081
|
const content2 = event.content.map((part) => {
|
|
5553
6082
|
if (part.type !== "text") return part;
|
|
5554
|
-
const
|
|
5555
|
-
if (
|
|
6083
|
+
const text4 = masker.mask(part.text);
|
|
6084
|
+
if (text4 === part.text) return part;
|
|
5556
6085
|
changed = true;
|
|
5557
|
-
return { ...part, text:
|
|
6086
|
+
return { ...part, text: text4 };
|
|
5558
6087
|
});
|
|
5559
6088
|
if (!changed) return void 0;
|
|
5560
6089
|
return { content: content2, ...event.structuredContent !== void 0 ? { structuredContent: masker.maskDeep(event.structuredContent) } : {} };
|
|
@@ -5624,6 +6153,7 @@ var PiSession = class _PiSession {
|
|
|
5624
6153
|
this.options.activity("compacting");
|
|
5625
6154
|
console.log("[pi] compaction started", { botId: this.options.bot.id, reason: event.reason });
|
|
5626
6155
|
}
|
|
6156
|
+
if (event.type === "compaction_end" && event.result && !event.aborted && !event.errorMessage) this.prompts.take(this.options.systemPrompt);
|
|
5627
6157
|
if (event.type === "compaction_end") console.log(
|
|
5628
6158
|
event.errorMessage ? "[pi] compaction failed" : "[pi] compaction finished",
|
|
5629
6159
|
{
|
|
@@ -5718,12 +6248,19 @@ var PiSession = class _PiSession {
|
|
|
5718
6248
|
this.settings.applyOverrides({ compaction: { reserveTokens: compactionReserve(model.contextWindow) } });
|
|
5719
6249
|
this.session.setThinkingLevel(resolveThinking(model, selection.thinkingLevel), { persist: false });
|
|
5720
6250
|
this.signature = signature;
|
|
6251
|
+
this.prompts.take(this.options.systemPrompt);
|
|
5721
6252
|
return this.session.thinkingLevel;
|
|
5722
6253
|
} catch {
|
|
5723
6254
|
this.credentials.current = previous;
|
|
5724
6255
|
throw new RuntimeFault("model_switch_uncertain", "The model switch didn't finish. Pick the model again.", 409);
|
|
5725
6256
|
}
|
|
5726
6257
|
}
|
|
6258
|
+
promptChange() {
|
|
6259
|
+
return this.prompts.pending(this.options.systemPrompt);
|
|
6260
|
+
}
|
|
6261
|
+
promptTold(change) {
|
|
6262
|
+
this.prompts.sent(change);
|
|
6263
|
+
}
|
|
5727
6264
|
async dispose() {
|
|
5728
6265
|
this.requestAbort();
|
|
5729
6266
|
await this.abortTask;
|
|
@@ -5771,12 +6308,13 @@ function overDurable(id, kind, env, options) {
|
|
|
5771
6308
|
id,
|
|
5772
6309
|
kind,
|
|
5773
6310
|
cwd: env.cwd,
|
|
6311
|
+
...options.temp ? { temp: options.temp } : {},
|
|
5774
6312
|
async exec(command, exec) {
|
|
5775
6313
|
const result = await env.exec(command, {
|
|
5776
6314
|
cwd: exec.cwd,
|
|
5777
6315
|
...options.passEnv && exec.env ? { env: { ...exec.env, ...exec.secrets }, inheritEnv: false } : { ...exec.secrets ? { env: exec.secrets } : {}, inheritEnv: true },
|
|
5778
6316
|
...exec.timeout ? { timeout: exec.timeout } : {},
|
|
5779
|
-
onOutput: (
|
|
6317
|
+
onOutput: (text4) => exec.onData(Buffer.from(text4))
|
|
5780
6318
|
}, context(exec.signal));
|
|
5781
6319
|
if (result.ok) return { exitCode: result.value.exitCode };
|
|
5782
6320
|
if (result.error.code === "aborted") throw new Error("aborted");
|
|
@@ -5950,16 +6488,16 @@ async function removeContainer(name3) {
|
|
|
5950
6488
|
async function syncDaemon(dir) {
|
|
5951
6489
|
const arch = process.arch === "arm64" ? "arm64" : "x64";
|
|
5952
6490
|
const wanted = await readFile2(packagedDaemon({ platform: "linux", arch }));
|
|
5953
|
-
const
|
|
5954
|
-
const current = await readFile2(
|
|
6491
|
+
const target3 = join6(dir, "pi-env");
|
|
6492
|
+
const current = await readFile2(target3).catch(() => null);
|
|
5955
6493
|
if (current?.equals(wanted)) return;
|
|
5956
6494
|
await mkdir3(dir, { recursive: true, mode: 448 });
|
|
5957
|
-
const temp = `${
|
|
6495
|
+
const temp = `${target3}.${process.pid}.tmp`;
|
|
5958
6496
|
await writeFile2(temp, wanted, { mode: 493 });
|
|
5959
|
-
await rename2(temp,
|
|
6497
|
+
await rename2(temp, target3);
|
|
5960
6498
|
}
|
|
5961
6499
|
function containerEnvironment(id, container, log) {
|
|
5962
|
-
const connection = new Connection({ command: ["docker", "exec", "-i", container, `${DAEMON_DIR}/pi-env`], onLog: (
|
|
6500
|
+
const connection = new Connection({ command: ["docker", "exec", "-i", container, `${DAEMON_DIR}/pi-env`], onLog: (text4) => log(text4.trim()) });
|
|
5963
6501
|
return overDurable(id, "computer", new RemoteExecutionEnv({ connection, id, cwd: COMPUTER_CWD }), { passEnv: false, close: () => connection.close() });
|
|
5964
6502
|
}
|
|
5965
6503
|
|
|
@@ -5972,32 +6510,35 @@ function hostEnvironment(cwd) {
|
|
|
5972
6510
|
// src/server/service/bot/environments.ts
|
|
5973
6511
|
var ABOUT = {
|
|
5974
6512
|
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
|
|
6513
|
+
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
6514
|
};
|
|
5977
6515
|
var BotEnvironments = class {
|
|
5978
|
-
constructor(store, computers) {
|
|
6516
|
+
constructor(store, computers, machines) {
|
|
5979
6517
|
this.store = store;
|
|
5980
6518
|
this.computers = computers;
|
|
6519
|
+
this.machines = machines;
|
|
5981
6520
|
}
|
|
5982
6521
|
open = /* @__PURE__ */ new Map();
|
|
5983
6522
|
current(botId, workspacePath) {
|
|
5984
|
-
const
|
|
6523
|
+
const target3 = this.store.environments.placement(botId).current, signature = this.signature(target3);
|
|
5985
6524
|
const open = this.open.get(botId);
|
|
5986
|
-
if (open?.id ===
|
|
6525
|
+
if (open?.id === signature) return open.view;
|
|
5987
6526
|
if (open) this.retire(open);
|
|
5988
|
-
const opened = this.track(
|
|
6527
|
+
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
6528
|
this.open.set(botId, opened);
|
|
5990
6529
|
return opened.view;
|
|
5991
6530
|
}
|
|
5992
6531
|
placement(botId) {
|
|
5993
6532
|
const environments = this.store.environments, { home, current } = environments.placement(botId);
|
|
5994
|
-
const hostId = environments.host().id;
|
|
6533
|
+
const hostId = environments.host().id, granted = new Set(environments.grants(botId).map((grant) => grant.id));
|
|
5995
6534
|
return {
|
|
5996
|
-
home: home.kind,
|
|
6535
|
+
home: home.kind === "computer" ? "computer" : "host",
|
|
5997
6536
|
current: current.kind,
|
|
6537
|
+
...current.kind === "ssh" ? { currentName: current.name } : {},
|
|
5998
6538
|
computer: this.computers.status(botId),
|
|
5999
6539
|
docker: this.computers.available,
|
|
6000
|
-
hostGranted:
|
|
6540
|
+
hostGranted: granted.has(hostId),
|
|
6541
|
+
machines: environments.sshMachines().map((machine) => ({ id: machine.id, name: machine.name, granted: granted.has(machine.id) }))
|
|
6001
6542
|
};
|
|
6002
6543
|
}
|
|
6003
6544
|
/** The environments the Bot may be in: its sandbox, its default, and the ones its owner granted it. */
|
|
@@ -6008,11 +6549,12 @@ var BotEnvironments = class {
|
|
|
6008
6549
|
}
|
|
6009
6550
|
place(botId, record3, workspacePath) {
|
|
6010
6551
|
if (record3.kind === "host") return { id: record3.id, kind: "host", name: "your owner's computer", directory: workspacePath };
|
|
6552
|
+
if (record3.kind === "ssh") return { id: record3.id, kind: "ssh", name: record3.name, directory: record3.directory ?? record3.spec.home ?? "/" };
|
|
6011
6553
|
return { id: record3.id, kind: "sandbox", name: record3.botId === botId ? "your sandbox" : "another Bot's sandbox", directory: COMPUTER_CWD };
|
|
6012
6554
|
}
|
|
6013
6555
|
/** A Bot's environments as other Bots see them in bots.list. */
|
|
6014
6556
|
summary(botId) {
|
|
6015
|
-
const name3 = (record3) => record3.kind === "host" ? "owner's computer" : record3.botId === botId ? "own sandbox" : "another Bot's sandbox";
|
|
6557
|
+
const name3 = (record3) => record3.kind === "host" ? "owner's computer" : record3.kind === "ssh" ? record3.name : record3.botId === botId ? "own sandbox" : "another Bot's sandbox";
|
|
6016
6558
|
const { home, current } = this.store.environments.placement(botId);
|
|
6017
6559
|
return { default: name3(home), current: name3(current), usable: this.usable(botId).map(name3) };
|
|
6018
6560
|
}
|
|
@@ -6030,18 +6572,37 @@ var BotEnvironments = class {
|
|
|
6030
6572
|
return { current: current.id, environments: this.usable(botId).map((record3) => ({
|
|
6031
6573
|
...this.place(botId, record3, workspacePath),
|
|
6032
6574
|
...record3.id === home.id ? { default: true } : {},
|
|
6033
|
-
...record3.kind === "computer" ? { state: this.computers.status(record3.botId ?? botId)?.state ?? "stopped" } : {}
|
|
6575
|
+
...record3.kind === "computer" ? { state: this.computers.status(record3.botId ?? botId)?.state ?? "stopped" } : {},
|
|
6576
|
+
...record3.kind === "ssh" ? { address: this.machines.address(record3) } : {}
|
|
6034
6577
|
})) };
|
|
6035
6578
|
}
|
|
6036
|
-
/** environments.switch: the Bot moves itself, like cd, until it switches back. A sandbox is started first
|
|
6037
|
-
*
|
|
6579
|
+
/** environments.switch: the Bot moves itself, like cd, until it switches back. A sandbox is started first, an SSH
|
|
6580
|
+
* machine connected first; if that fails, the Bot stays where it was. */
|
|
6038
6581
|
async switch(botId, environmentId, workspacePath) {
|
|
6039
|
-
const
|
|
6040
|
-
if (!
|
|
6041
|
-
const
|
|
6042
|
-
if (
|
|
6043
|
-
this.store.environments.setCurrent(botId,
|
|
6044
|
-
|
|
6582
|
+
const target3 = this.usable(botId).find((record3) => record3.id === environmentId);
|
|
6583
|
+
if (!target3) throw new ChatError(404, "No work environment with this id that you may use; see environments.list");
|
|
6584
|
+
const previous = this.store.environments.placement(botId).current, already = previous.id === target3.id;
|
|
6585
|
+
if (target3.kind === "computer") await this.computers.ensure(target3.botId ?? botId);
|
|
6586
|
+
this.store.environments.setCurrent(botId, target3.id);
|
|
6587
|
+
if (target3.kind === "ssh" && !already) {
|
|
6588
|
+
const place = this.place(botId, target3, workspacePath);
|
|
6589
|
+
try {
|
|
6590
|
+
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`);
|
|
6591
|
+
} catch (error) {
|
|
6592
|
+
const opened = this.open.get(botId);
|
|
6593
|
+
if (opened) {
|
|
6594
|
+
this.open.delete(botId);
|
|
6595
|
+
this.retire(opened);
|
|
6596
|
+
}
|
|
6597
|
+
this.store.environments.setCurrent(botId, previous.id);
|
|
6598
|
+
throw new ChatError(409, `Couldn't connect to ${target3.name}, so you stay where you were: ${error instanceof Error ? error.message : String(error)}`);
|
|
6599
|
+
}
|
|
6600
|
+
}
|
|
6601
|
+
return {
|
|
6602
|
+
...this.place(botId, target3, workspacePath),
|
|
6603
|
+
about: target3.kind === "ssh" ? this.machines.about(target3, botId) : ABOUT[target3.kind],
|
|
6604
|
+
...already ? { note: "You were already here." } : {}
|
|
6605
|
+
};
|
|
6045
6606
|
}
|
|
6046
6607
|
/** The owner makes the Bot's sandbox (given it on first use) or their computer its default, and moves it there. */
|
|
6047
6608
|
setHome(botId, kind, workspacePath) {
|
|
@@ -6058,15 +6619,18 @@ var BotEnvironments = class {
|
|
|
6058
6619
|
const to = this.here(botId, workspacePath);
|
|
6059
6620
|
return from.id === to.id ? null : { from, to };
|
|
6060
6621
|
}
|
|
6061
|
-
/** The owner grants or takes back their computer (A50); taking it back while the Bot works
|
|
6062
|
-
|
|
6063
|
-
|
|
6064
|
-
|
|
6065
|
-
|
|
6066
|
-
|
|
6622
|
+
/** The owner grants or takes back their computer (A50) or an SSH machine (A57); taking it back while the Bot works
|
|
6623
|
+
* there sends it home. */
|
|
6624
|
+
grant(botId, environmentId, granted, by, workspacePath) {
|
|
6625
|
+
const environments = this.store.environments, target3 = environmentId === "host" ? environments.host() : environments.get(environmentId);
|
|
6626
|
+
if (!target3 || target3.kind === "computer") throw new ChatError(404, "No such environment");
|
|
6627
|
+
const name3 = this.place(botId, target3, workspacePath).name;
|
|
6628
|
+
if (granted) return { changed: environments.grant(target3.id, botId, by), moved: null, name: name3 };
|
|
6629
|
+
const changed = environments.revoke(target3.id, botId), { home, current } = environments.placement(botId);
|
|
6630
|
+
if (current.id !== target3.id || home.id === target3.id) return { changed, moved: null, name: name3 };
|
|
6067
6631
|
const from = this.here(botId, workspacePath);
|
|
6068
6632
|
environments.setCurrent(botId, home.id);
|
|
6069
|
-
return { changed, moved: { from, to: this.here(botId, workspacePath) } };
|
|
6633
|
+
return { changed, moved: { from, to: this.here(botId, workspacePath) }, name: name3 };
|
|
6070
6634
|
}
|
|
6071
6635
|
async release(botId) {
|
|
6072
6636
|
const open = this.open.get(botId);
|
|
@@ -6080,6 +6644,10 @@ var BotEnvironments = class {
|
|
|
6080
6644
|
await Promise.all(all.map((open) => open.env.close()));
|
|
6081
6645
|
await this.computers.close();
|
|
6082
6646
|
}
|
|
6647
|
+
/** What an open environment is for: changing a machine's directory reopens it where the owner put it. */
|
|
6648
|
+
signature(record3) {
|
|
6649
|
+
return record3.kind === "ssh" ? `${record3.id}@${record3.directory ?? ""}` : record3.id;
|
|
6650
|
+
}
|
|
6083
6651
|
retire(open) {
|
|
6084
6652
|
open.retired = true;
|
|
6085
6653
|
if (!open.busy) void open.env.close();
|
|
@@ -6087,7 +6655,7 @@ var BotEnvironments = class {
|
|
|
6087
6655
|
/** Counts the operations running on an environment, so one the Bot has left closes only when they are done. */
|
|
6088
6656
|
track(id, env) {
|
|
6089
6657
|
const opened = { id, env, view: env, busy: 0, retired: false };
|
|
6090
|
-
const
|
|
6658
|
+
const run2 = async (work) => {
|
|
6091
6659
|
opened.busy++;
|
|
6092
6660
|
try {
|
|
6093
6661
|
return await work();
|
|
@@ -6099,13 +6667,14 @@ var BotEnvironments = class {
|
|
|
6099
6667
|
id: env.id,
|
|
6100
6668
|
kind: env.kind,
|
|
6101
6669
|
cwd: env.cwd,
|
|
6102
|
-
|
|
6103
|
-
|
|
6104
|
-
|
|
6105
|
-
|
|
6106
|
-
|
|
6107
|
-
|
|
6108
|
-
|
|
6670
|
+
...env.temp ? { temp: env.temp } : {},
|
|
6671
|
+
exec: (command, options) => run2(() => env.exec(command, options)),
|
|
6672
|
+
readFile: (path) => run2(() => env.readFile(path)),
|
|
6673
|
+
head: (path, bytes) => run2(() => env.head(path, bytes)),
|
|
6674
|
+
writeFile: (path, content2) => run2(() => env.writeFile(path, content2)),
|
|
6675
|
+
mkdir: (path) => run2(() => env.mkdir(path)),
|
|
6676
|
+
exists: (path) => run2(() => env.exists(path)),
|
|
6677
|
+
fileSize: (path) => run2(() => env.fileSize(path)),
|
|
6109
6678
|
close: () => env.close()
|
|
6110
6679
|
};
|
|
6111
6680
|
return opened;
|
|
@@ -6174,7 +6743,7 @@ var Computers = class {
|
|
|
6174
6743
|
}
|
|
6175
6744
|
/** What the Bot's tools use while it works on its computer: every call first makes sure the container runs. */
|
|
6176
6745
|
environment(botId, record3) {
|
|
6177
|
-
const
|
|
6746
|
+
const run2 = async (op) => {
|
|
6178
6747
|
try {
|
|
6179
6748
|
return await op(await this.connect(botId, record3));
|
|
6180
6749
|
} catch (error) {
|
|
@@ -6188,13 +6757,13 @@ var Computers = class {
|
|
|
6188
6757
|
id: record3.id,
|
|
6189
6758
|
kind: "computer",
|
|
6190
6759
|
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) =>
|
|
6760
|
+
exec: (command, options) => run2((env) => env.exec(command, options)),
|
|
6761
|
+
readFile: (path) => run2((env) => env.readFile(path)),
|
|
6762
|
+
head: (path, bytes) => run2((env) => env.head(path, bytes)),
|
|
6763
|
+
writeFile: (path, content2) => run2((env) => env.writeFile(path, content2)),
|
|
6764
|
+
mkdir: (path) => run2((env) => env.mkdir(path)),
|
|
6765
|
+
exists: (path) => run2((env) => env.exists(path)),
|
|
6766
|
+
fileSize: (path) => run2((env) => env.fileSize(path)),
|
|
6198
6767
|
close: () => this.disconnect(botId)
|
|
6199
6768
|
};
|
|
6200
6769
|
}
|
|
@@ -6208,7 +6777,7 @@ var Computers = class {
|
|
|
6208
6777
|
await home;
|
|
6209
6778
|
let env = this.connections.get(botId);
|
|
6210
6779
|
if (!env) {
|
|
6211
|
-
env = containerEnvironment(record3.id, container, (
|
|
6780
|
+
env = containerEnvironment(record3.id, container, (text4) => this.log(`[${botId.slice(0, 8)}] ${text4}`));
|
|
6212
6781
|
this.connections.set(botId, env);
|
|
6213
6782
|
}
|
|
6214
6783
|
return env;
|
|
@@ -6336,15 +6905,409 @@ var Computers = class {
|
|
|
6336
6905
|
this.set(botId, { state: "running" });
|
|
6337
6906
|
}
|
|
6338
6907
|
}
|
|
6339
|
-
log(
|
|
6340
|
-
(this.deps.log ?? console.log)(`[computers] ${
|
|
6908
|
+
log(text4) {
|
|
6909
|
+
(this.deps.log ?? console.log)(`[computers] ${text4}`);
|
|
6341
6910
|
}
|
|
6342
6911
|
};
|
|
6343
6912
|
|
|
6913
|
+
// src/server/service/bot/machines.ts
|
|
6914
|
+
import { randomUUID as randomUUID12 } from "node:crypto";
|
|
6915
|
+
import { dirname as dirname5, join as join9, posix } from "node:path";
|
|
6916
|
+
|
|
6917
|
+
// src/server/infra/env/ssh.ts
|
|
6918
|
+
import { spawn as spawn2 } from "node:child_process";
|
|
6919
|
+
import { mkdirSync as mkdirSync4, rmSync as rmSync2, writeFileSync as writeFileSync3 } from "node:fs";
|
|
6920
|
+
import { mkdir as mkdir4, mkdtemp as mkdtemp2, readFile as readFile3, rm as rm3, writeFile as writeFile3 } from "node:fs/promises";
|
|
6921
|
+
import { join as join8 } from "node:path";
|
|
6922
|
+
import { HostKeyChangedError, HostKeyUnknownError, RemoteExecutionEnv as RemoteExecutionEnv2, detectPlatform, scanHostKey, sshArguments, sshConnection } from "@earendil-works/pi-env";
|
|
6923
|
+
var SshFault = class extends Error {
|
|
6924
|
+
constructor(code, message) {
|
|
6925
|
+
super(message);
|
|
6926
|
+
this.code = code;
|
|
6927
|
+
}
|
|
6928
|
+
};
|
|
6929
|
+
var SCAN_ALIAS = "jarvy-scan";
|
|
6930
|
+
var TIMEOUT_MS2 = 3e4;
|
|
6931
|
+
async function scanHost(address2, runtime) {
|
|
6932
|
+
await mkdir4(runtime, { recursive: true, mode: 448 });
|
|
6933
|
+
try {
|
|
6934
|
+
const scanned = await scanHostKey({ ...target(address2, SCAN_ALIAS), knownHostsFile: join8(runtime, "none") });
|
|
6935
|
+
return {
|
|
6936
|
+
keys: scanned.lines.map((line) => line.trim().split(/\s+/).slice(1, 3).join(" ")),
|
|
6937
|
+
fingerprints: scanned.fingerprints.map((line) => line.replace(` ${SCAN_ALIAS} `, " ").replace(/^\d+\s+/, ""))
|
|
6938
|
+
};
|
|
6939
|
+
} catch (error) {
|
|
6940
|
+
throw classify(error, address2);
|
|
6941
|
+
}
|
|
6942
|
+
}
|
|
6943
|
+
async function generateKey(runtime, comment) {
|
|
6944
|
+
await mkdir4(runtime, { recursive: true, mode: 448 });
|
|
6945
|
+
const directory = await mkdtemp2(join8(runtime, "keygen-"));
|
|
6946
|
+
try {
|
|
6947
|
+
await run("ssh-keygen", ["-q", "-t", "ed25519", "-N", "", "-C", comment, "-f", join8(directory, "key")]);
|
|
6948
|
+
return { privateKey: await readFile3(join8(directory, "key"), "utf8"), publicKey: (await readFile3(join8(directory, "key.pub"), "utf8")).trim() };
|
|
6949
|
+
} finally {
|
|
6950
|
+
await rm3(directory, { recursive: true, force: true });
|
|
6951
|
+
}
|
|
6952
|
+
}
|
|
6953
|
+
async function installKey(address2, runtime, hostKeys2, password, publicKey) {
|
|
6954
|
+
await withFiles(runtime, SCAN_ALIAS, hostKeys2, null, async (files, directory) => {
|
|
6955
|
+
const askpass = join8(directory, "askpass");
|
|
6956
|
+
await writeFile3(askpass, `#!/bin/sh
|
|
6957
|
+
printf '%s\\n' "$JARVY_SSH_PASSWORD"
|
|
6958
|
+
`, { mode: 448 });
|
|
6959
|
+
const { SSH_AUTH_SOCK: _agent, ...env } = process.env;
|
|
6960
|
+
const args = [
|
|
6961
|
+
"-o",
|
|
6962
|
+
"BatchMode=no",
|
|
6963
|
+
"-o",
|
|
6964
|
+
"PreferredAuthentications=keyboard-interactive,password",
|
|
6965
|
+
"-o",
|
|
6966
|
+
"PubkeyAuthentication=no",
|
|
6967
|
+
"-o",
|
|
6968
|
+
"NumberOfPasswordPrompts=1",
|
|
6969
|
+
"-o",
|
|
6970
|
+
"ConnectTimeout=15",
|
|
6971
|
+
...sshArguments({ ...target(address2, SCAN_ALIAS), ...files })
|
|
6972
|
+
];
|
|
6973
|
+
const script = [
|
|
6974
|
+
"umask 077",
|
|
6975
|
+
'mkdir -p "$HOME/.ssh"',
|
|
6976
|
+
'f="$HOME/.ssh/authorized_keys"',
|
|
6977
|
+
'touch "$f"',
|
|
6978
|
+
"IFS= read -r key",
|
|
6979
|
+
`grep -qxF "$key" "$f" || printf '%s\\n' "$key" >> "$f"`
|
|
6980
|
+
].join("\n");
|
|
6981
|
+
await runSsh(
|
|
6982
|
+
address2,
|
|
6983
|
+
args,
|
|
6984
|
+
`sh -c ${quote(script)}`,
|
|
6985
|
+
`${publicKey}
|
|
6986
|
+
`,
|
|
6987
|
+
{ ...env, SSH_ASKPASS: askpass, SSH_ASKPASS_REQUIRE: "force", DISPLAY: env.DISPLAY ?? "none", JARVY_SSH_PASSWORD: password }
|
|
6988
|
+
);
|
|
6989
|
+
});
|
|
6990
|
+
}
|
|
6991
|
+
async function probeHost(access) {
|
|
6992
|
+
return withFiles(access.runtime, access.alias, access.hostKeys, access.privateKey, async (files) => {
|
|
6993
|
+
try {
|
|
6994
|
+
const remote = await detectPlatform({ ...target(access, access.alias), ...files });
|
|
6995
|
+
if (remote.platform !== "linux" && remote.platform !== "darwin") throw new SshFault("ssh_unsupported", `Unsupported system: ${remote.platform}`);
|
|
6996
|
+
return { platform: remote.platform, home: remote.home };
|
|
6997
|
+
} catch (error) {
|
|
6998
|
+
throw classify(error, access);
|
|
6999
|
+
}
|
|
7000
|
+
});
|
|
7001
|
+
}
|
|
7002
|
+
async function removeKey(access, comment) {
|
|
7003
|
+
await withFiles(access.runtime, access.alias, access.hostKeys, access.privateKey, async (files) => {
|
|
7004
|
+
const script = [
|
|
7005
|
+
"umask 077",
|
|
7006
|
+
'f="$HOME/.ssh/authorized_keys"',
|
|
7007
|
+
'[ -f "$f" ] || exit 0',
|
|
7008
|
+
`awk ${quote(`$NF != "${comment}"`)} "$f" > "$f.jarvy" && cat "$f.jarvy" > "$f"`,
|
|
7009
|
+
"s=$?",
|
|
7010
|
+
'rm -f "$f.jarvy"',
|
|
7011
|
+
"exit $s"
|
|
7012
|
+
].join("\n");
|
|
7013
|
+
await runSsh(access, sshArguments({ ...target(access, access.alias), ...files }), `sh -c ${quote(script)}`);
|
|
7014
|
+
});
|
|
7015
|
+
}
|
|
7016
|
+
function sshEnvironment(id, access, directory, temp, onLog) {
|
|
7017
|
+
mkdirSync4(access.runtime, { recursive: true, mode: 448 });
|
|
7018
|
+
const folder = join8(access.runtime, `open-${id}-${process.pid}-${Date.now().toString(36)}`);
|
|
7019
|
+
const files = writeFilesSync(folder, access.alias, access.hostKeys, access.privateKey);
|
|
7020
|
+
const { connection } = sshConnection({ ...target(access, access.alias), ...files, ...onLog ? { onLog } : {} });
|
|
7021
|
+
return overDurable(id, "ssh", new RemoteExecutionEnv2({ connection, id: `ssh:${id}`, cwd: directory }), {
|
|
7022
|
+
passEnv: false,
|
|
7023
|
+
temp,
|
|
7024
|
+
close: () => {
|
|
7025
|
+
connection.close();
|
|
7026
|
+
rmSync2(folder, { recursive: true, force: true });
|
|
7027
|
+
}
|
|
7028
|
+
});
|
|
7029
|
+
}
|
|
7030
|
+
function clearRuntime(runtime) {
|
|
7031
|
+
rmSync2(runtime, { recursive: true, force: true });
|
|
7032
|
+
}
|
|
7033
|
+
function target(address2, alias2) {
|
|
7034
|
+
return { host: address2.host, port: address2.port, user: address2.user, hostKeyAlias: alias2, configFile: "/dev/null" };
|
|
7035
|
+
}
|
|
7036
|
+
async function withFiles(runtime, alias2, hostKeys2, privateKey, work) {
|
|
7037
|
+
await mkdir4(runtime, { recursive: true, mode: 448 });
|
|
7038
|
+
const directory = await mkdtemp2(join8(runtime, "use-"));
|
|
7039
|
+
try {
|
|
7040
|
+
return await work(writeFilesSync(directory, alias2, hostKeys2, privateKey), directory);
|
|
7041
|
+
} finally {
|
|
7042
|
+
await rm3(directory, { recursive: true, force: true });
|
|
7043
|
+
}
|
|
7044
|
+
}
|
|
7045
|
+
function writeFilesSync(directory, alias2, hostKeys2, privateKey) {
|
|
7046
|
+
mkdirSync4(directory, { recursive: true, mode: 448 });
|
|
7047
|
+
const knownHostsFile = join8(directory, "known_hosts");
|
|
7048
|
+
writeFileSync3(knownHostsFile, hostKeys2.map((key) => `${alias2} ${key}
|
|
7049
|
+
`).join(""), { mode: 384 });
|
|
7050
|
+
if (privateKey === null) return { knownHostsFile };
|
|
7051
|
+
const identityFile = join8(directory, "key");
|
|
7052
|
+
writeFileSync3(identityFile, privateKey.endsWith("\n") ? privateKey : `${privateKey}
|
|
7053
|
+
`, { mode: 384 });
|
|
7054
|
+
return { knownHostsFile, identityFile };
|
|
7055
|
+
}
|
|
7056
|
+
function runSsh(address2, args, command, stdin = "", env) {
|
|
7057
|
+
return run("ssh", [...args, command], stdin, env).catch((error) => {
|
|
7058
|
+
throw classify(error, address2);
|
|
7059
|
+
});
|
|
7060
|
+
}
|
|
7061
|
+
function run(program, args, stdin = "", env) {
|
|
7062
|
+
return new Promise((resolve5, reject) => {
|
|
7063
|
+
const child = spawn2(program, args, { stdio: ["pipe", "pipe", "pipe"], ...env ? { env } : {}, detached: true });
|
|
7064
|
+
const out = [], err = [];
|
|
7065
|
+
const timer = setTimeout(() => child.kill(), TIMEOUT_MS2);
|
|
7066
|
+
child.stdout.on("data", (chunk) => out.push(chunk));
|
|
7067
|
+
child.stderr.on("data", (chunk) => err.push(chunk));
|
|
7068
|
+
child.stdin.on("error", () => {
|
|
7069
|
+
});
|
|
7070
|
+
child.on("error", (error) => {
|
|
7071
|
+
clearTimeout(timer);
|
|
7072
|
+
reject(error);
|
|
7073
|
+
});
|
|
7074
|
+
child.on("close", (code) => {
|
|
7075
|
+
clearTimeout(timer);
|
|
7076
|
+
if (code === 0) resolve5(Buffer.concat(out).toString("utf8"));
|
|
7077
|
+
else reject(new Error(Buffer.concat(err).toString("utf8").trim() || `${program} exited with ${code ?? "a signal"}`));
|
|
7078
|
+
});
|
|
7079
|
+
child.stdin.end(stdin);
|
|
7080
|
+
});
|
|
7081
|
+
}
|
|
7082
|
+
function classify(error, address2) {
|
|
7083
|
+
if (error instanceof SshFault) return error;
|
|
7084
|
+
const text4 = error instanceof Error ? error.message : String(error), where2 = `${address2.user}@${address2.host}:${address2.port}`;
|
|
7085
|
+
if (error instanceof HostKeyChangedError || /REMOTE HOST IDENTIFICATION HAS CHANGED|Host key verification failed/.test(text4))
|
|
7086
|
+
return new SshFault("ssh_host_key_changed", `The host key of ${where2} is not the one accepted`);
|
|
7087
|
+
if (error instanceof HostKeyUnknownError) return new SshFault("ssh_host_key_changed", `The host key of ${where2} is not the one accepted`);
|
|
7088
|
+
if (/Permission denied|Too many authentication failures/.test(text4)) return new SshFault("ssh_auth_failed", `${where2} refused the login`);
|
|
7089
|
+
if (/Connection refused|timed out|No route to host|Could not resolve|Network is unreachable|Connection closed|No host key received/i.test(text4))
|
|
7090
|
+
return new SshFault("ssh_unreachable", `${where2} can't be reached: ${text4.split("\n").pop()}`);
|
|
7091
|
+
return new SshFault("ssh_failed", text4.split("\n").slice(-3).join(" ").slice(0, 300) || `ssh ${where2} failed`);
|
|
7092
|
+
}
|
|
7093
|
+
var quote = (text4) => `'${text4.replaceAll("'", "'\\''")}'`;
|
|
7094
|
+
|
|
7095
|
+
// src/server/service/bot/machines.ts
|
|
7096
|
+
var HOST = /^(?!-)[A-Za-z0-9._:-]{1,253}$/;
|
|
7097
|
+
var USER = /^(?!-)[A-Za-z0-9._][A-Za-z0-9._-]{0,63}$/;
|
|
7098
|
+
var SshMachines = class {
|
|
7099
|
+
/** `changed`: what Bots may use changed, so the owner's page reloads them. */
|
|
7100
|
+
constructor(store, chats, changed = () => {
|
|
7101
|
+
}) {
|
|
7102
|
+
this.store = store;
|
|
7103
|
+
this.chats = chats;
|
|
7104
|
+
this.changed = changed;
|
|
7105
|
+
this.runtime = join9(dirname5(store.path), "ssh");
|
|
7106
|
+
clearRuntime(this.runtime);
|
|
7107
|
+
}
|
|
7108
|
+
runtime;
|
|
7109
|
+
list() {
|
|
7110
|
+
return this.store.environments.sshMachines().map((record3) => this.view(record3));
|
|
7111
|
+
}
|
|
7112
|
+
get(id) {
|
|
7113
|
+
return this.view(this.need(id));
|
|
7114
|
+
}
|
|
7115
|
+
/** The host keys a machine presents, to show the owner before the password is typed. */
|
|
7116
|
+
async scan(input) {
|
|
7117
|
+
return this.wrap(() => scanHost(address(input), this.runtime));
|
|
7118
|
+
}
|
|
7119
|
+
/** Makes a key, installs it with the password, checks a login with it, then saves the machine; the password is
|
|
7120
|
+
* used for that one login only. Nothing is saved when any step fails. */
|
|
7121
|
+
async add(input, grantTo) {
|
|
7122
|
+
const where2 = address(input), name3 = this.freeName(text3(input.name, 40) || `${where2.user}@${where2.host}`);
|
|
7123
|
+
if (typeof input.password !== "string" || !input.password) throw new ChatError(400, "A password is needed to install the key", "ssh_password_missing");
|
|
7124
|
+
if (!input.hostKeys.keys.length) throw new ChatError(400, "The host keys are missing; scan the machine first");
|
|
7125
|
+
directoryIn("/", input.directory);
|
|
7126
|
+
const id = randomUUID12(), comment = keyComment(id);
|
|
7127
|
+
const record3 = await this.wrap(async () => {
|
|
7128
|
+
const key = await generateKey(this.runtime, comment), access = { ...where2, runtime: this.runtime, alias: alias(id), hostKeys: input.hostKeys.keys, privateKey: key.privateKey };
|
|
7129
|
+
await installKey(where2, this.runtime, input.hostKeys.keys, input.password, key.publicKey);
|
|
7130
|
+
try {
|
|
7131
|
+
const remote = await probeHost(access);
|
|
7132
|
+
return this.store.environments.addSsh({ name: name3, ...where2, ...remote, directory: directoryIn(remote.home, input.directory), hostKeys: input.hostKeys, privateKey: key.privateKey }, id);
|
|
7133
|
+
} catch (error) {
|
|
7134
|
+
await removeKey(access, comment).catch(() => {
|
|
7135
|
+
});
|
|
7136
|
+
throw error;
|
|
7137
|
+
}
|
|
7138
|
+
});
|
|
7139
|
+
if (grantTo) this.store.environments.grant(record3.id, grantTo.botId, grantTo.by);
|
|
7140
|
+
this.changed();
|
|
7141
|
+
return this.view(record3);
|
|
7142
|
+
}
|
|
7143
|
+
update(id, input) {
|
|
7144
|
+
const record3 = this.need(id);
|
|
7145
|
+
const name3 = input.name === void 0 ? void 0 : text3(input.name, 40);
|
|
7146
|
+
if (name3 !== void 0 && !name3) throw new ChatError(400, "A name is needed");
|
|
7147
|
+
if (name3 !== void 0 && name3 !== record3.name) this.freeName(name3);
|
|
7148
|
+
this.store.environments.updateSsh(id, {
|
|
7149
|
+
...name3 !== void 0 ? { name: name3 } : {},
|
|
7150
|
+
...input.directory !== void 0 ? { directory: directoryIn(record3.spec.home ?? "/", input.directory) } : {}
|
|
7151
|
+
});
|
|
7152
|
+
this.changed();
|
|
7153
|
+
return this.get(id);
|
|
7154
|
+
}
|
|
7155
|
+
/** Scans the machine again: `changed` when it no longer presents the keys the owner accepted. */
|
|
7156
|
+
async check(id) {
|
|
7157
|
+
const record3 = this.need(id), accepted = this.store.environments.sshSecrets(id).hostKeys;
|
|
7158
|
+
const hostKeys2 = await this.scan(this.addressOf(record3));
|
|
7159
|
+
return { hostKeys: hostKeys2, changed: hostKeys2.keys.some((key) => !accepted.keys.includes(key)) };
|
|
7160
|
+
}
|
|
7161
|
+
/** The owner accepts the keys the machine presents now (after a reinstall, say). */
|
|
7162
|
+
async trust(id, fingerprints) {
|
|
7163
|
+
const { hostKeys: hostKeys2 } = await this.check(id);
|
|
7164
|
+
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");
|
|
7165
|
+
this.store.environments.updateSsh(id, { hostKeys: hostKeys2 });
|
|
7166
|
+
return this.get(id);
|
|
7167
|
+
}
|
|
7168
|
+
/** Removes Jarvy's key from the machine, then forgets it. When the machine can't be reached the key stays
|
|
7169
|
+
* there; `force` forgets it anyway. Returns the Bots that were working there. */
|
|
7170
|
+
async remove(id, force) {
|
|
7171
|
+
const record3 = this.need(id);
|
|
7172
|
+
try {
|
|
7173
|
+
await this.wrap(() => removeKey(this.access(record3), keyComment(id)));
|
|
7174
|
+
} catch (error) {
|
|
7175
|
+
if (!force) throw error;
|
|
7176
|
+
}
|
|
7177
|
+
const moved = this.store.environments.dropSsh(id);
|
|
7178
|
+
this.changed();
|
|
7179
|
+
return moved;
|
|
7180
|
+
}
|
|
7181
|
+
/** The machine as a Bot's environment. Its own files go in ~/.cache/jarvy/<Bot> there. */
|
|
7182
|
+
environment(record3, botId) {
|
|
7183
|
+
const home = record3.spec.home ?? "/tmp";
|
|
7184
|
+
return sshEnvironment(
|
|
7185
|
+
record3.id,
|
|
7186
|
+
this.access(record3),
|
|
7187
|
+
record3.directory ?? home,
|
|
7188
|
+
posix.join(home, ".cache", "jarvy", botId),
|
|
7189
|
+
(text4) => console.error(`[ssh ${record3.name}] ${text4.trim()}`)
|
|
7190
|
+
);
|
|
7191
|
+
}
|
|
7192
|
+
/** What a Bot is told of a machine. */
|
|
7193
|
+
about(record3, botId) {
|
|
7194
|
+
const temp = posix.join(record3.spec.home ?? "~", ".cache", "jarvy", botId);
|
|
7195
|
+
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}.`;
|
|
7196
|
+
}
|
|
7197
|
+
address(record3) {
|
|
7198
|
+
return `${record3.spec.user}@${record3.spec.host}${record3.spec.port === "22" ? "" : `:${record3.spec.port}`}`;
|
|
7199
|
+
}
|
|
7200
|
+
/** environments.request: a machine the Bot may already use needs no card; one already added needs only a grant;
|
|
7201
|
+
* else the card shows its fingerprints and takes the password. */
|
|
7202
|
+
async prepareRequest(botId, input) {
|
|
7203
|
+
const where2 = address(input);
|
|
7204
|
+
const existing = this.store.environments.sshMachines().find((record3) => record3.spec.host === where2.host && record3.spec.user === where2.user && record3.spec.port === String(where2.port));
|
|
7205
|
+
if (existing && this.store.environments.grantees(existing.id).includes(botId)) return { granted: existing };
|
|
7206
|
+
const hostKeys2 = existing ? this.store.environments.sshSecrets(existing.id).hostKeys : await this.scan(where2);
|
|
7207
|
+
const machine = {
|
|
7208
|
+
name: existing?.name ?? (text3(input.name, 40) || `${where2.user}@${where2.host}`),
|
|
7209
|
+
...where2,
|
|
7210
|
+
directory: typeof input.directory === "string" && input.directory.trim() ? input.directory.trim() : null,
|
|
7211
|
+
purpose: input.purpose,
|
|
7212
|
+
hostKeys: hostKeys2,
|
|
7213
|
+
existing: existing ? { id: existing.id, name: existing.name } : null
|
|
7214
|
+
};
|
|
7215
|
+
return { question: { question: input.purpose, options: [{ label: "Add", value: "add" }], multiple: false, allowCustom: false, machine } };
|
|
7216
|
+
}
|
|
7217
|
+
/** The owner's answer on a request card: adds the machine with the password (or grants the one already added) to
|
|
7218
|
+
* the Bot that asked, then settles the card. The password never enters the message. */
|
|
7219
|
+
async fulfil(actorId, chatId, messageId, input) {
|
|
7220
|
+
const message = this.chats.message(actorId, messageId), ask = message?.question?.machine;
|
|
7221
|
+
if (!message || message.chatId !== chatId || !ask || message.question?.state !== "pending") throw new ChatError(409, "The question is already settled", "question_closed");
|
|
7222
|
+
if (!this.chats.questions.waitingFor(messageId)) throw new ChatError(409, "The question has expired", "question_expired");
|
|
7223
|
+
const botId = message.sender.id, by = this.store.userMemberId();
|
|
7224
|
+
const existing = ask.existing && this.store.environments.get(ask.existing.id);
|
|
7225
|
+
if (existing) {
|
|
7226
|
+
this.store.environments.grant(existing.id, botId, by);
|
|
7227
|
+
this.changed();
|
|
7228
|
+
} else await this.add({ name: ask.name, host: ask.host, port: ask.port, user: ask.user, directory: ask.directory ?? void 0, password: input.password, hostKeys: ask.hostKeys }, { botId, by });
|
|
7229
|
+
return this.chats.questions.respond(actorId, chatId, messageId, { action: "answer", selected: [0] });
|
|
7230
|
+
}
|
|
7231
|
+
/** The machine a request card added or granted, by its address. */
|
|
7232
|
+
find(botId, input) {
|
|
7233
|
+
const where2 = address(input);
|
|
7234
|
+
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;
|
|
7235
|
+
}
|
|
7236
|
+
view(record3) {
|
|
7237
|
+
const { spec } = record3, secrets = this.store.environments.sshSecrets(record3.id);
|
|
7238
|
+
return {
|
|
7239
|
+
id: record3.id,
|
|
7240
|
+
name: record3.name,
|
|
7241
|
+
host: spec.host ?? "",
|
|
7242
|
+
port: Number(spec.port ?? 22),
|
|
7243
|
+
user: spec.user ?? "",
|
|
7244
|
+
home: spec.home ?? "",
|
|
7245
|
+
directory: record3.directory ?? spec.home ?? "",
|
|
7246
|
+
platform: spec.platform ?? "",
|
|
7247
|
+
fingerprints: secrets?.hostKeys.fingerprints ?? [],
|
|
7248
|
+
botIds: this.store.environments.grantees(record3.id)
|
|
7249
|
+
};
|
|
7250
|
+
}
|
|
7251
|
+
need(id) {
|
|
7252
|
+
const record3 = this.store.environments.get(id);
|
|
7253
|
+
if (!record3 || record3.kind !== "ssh") throw new ChatError(404, "No machine with this id", "machine_missing");
|
|
7254
|
+
return record3;
|
|
7255
|
+
}
|
|
7256
|
+
addressOf(record3) {
|
|
7257
|
+
return { host: record3.spec.host, port: Number(record3.spec.port), user: record3.spec.user };
|
|
7258
|
+
}
|
|
7259
|
+
access(record3) {
|
|
7260
|
+
const secrets = this.store.environments.sshSecrets(record3.id);
|
|
7261
|
+
if (!secrets?.privateKey) 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");
|
|
7262
|
+
return { ...this.addressOf(record3), runtime: this.runtime, alias: alias(record3.id), hostKeys: secrets.hostKeys.keys, privateKey: secrets.privateKey };
|
|
7263
|
+
}
|
|
7264
|
+
freeName(name3) {
|
|
7265
|
+
if (this.store.environments.sshMachines().some((record3) => record3.name === name3)) throw new ChatError(409, "Another machine has this name", "machine_name_taken");
|
|
7266
|
+
return name3;
|
|
7267
|
+
}
|
|
7268
|
+
/** SSH failures reach people and Bots as chat errors with a code the interface can word. */
|
|
7269
|
+
async wrap(work) {
|
|
7270
|
+
try {
|
|
7271
|
+
return await work();
|
|
7272
|
+
} catch (error) {
|
|
7273
|
+
if (!(error instanceof SshFault)) throw error;
|
|
7274
|
+
switch (error.code) {
|
|
7275
|
+
case "ssh_auth_failed":
|
|
7276
|
+
throw new ChatError(403, error.message, "ssh_auth_failed");
|
|
7277
|
+
case "ssh_unreachable":
|
|
7278
|
+
throw new ChatError(409, error.message, "ssh_unreachable");
|
|
7279
|
+
case "ssh_host_key_changed":
|
|
7280
|
+
throw new ChatError(409, error.message, "ssh_host_key_changed");
|
|
7281
|
+
case "ssh_unsupported":
|
|
7282
|
+
throw new ChatError(409, error.message, "ssh_unsupported");
|
|
7283
|
+
default:
|
|
7284
|
+
throw new ChatError(409, error.message, "ssh_failed", { detail: error.message });
|
|
7285
|
+
}
|
|
7286
|
+
}
|
|
7287
|
+
}
|
|
7288
|
+
};
|
|
7289
|
+
var alias = (id) => `jarvy-${id}`;
|
|
7290
|
+
var keyComment = (id) => `jarvy-${id}`;
|
|
7291
|
+
var text3 = (value2, max) => typeof value2 === "string" ? value2.trim().slice(0, max) : "";
|
|
7292
|
+
function address(input) {
|
|
7293
|
+
const host = text3(input.host, 253), user = text3(input.user, 64), port = input.port === void 0 || input.port === "" ? 22 : Number(input.port);
|
|
7294
|
+
if (!HOST.test(host)) throw new ChatError(400, "host must be a host name or IP address", "ssh_bad_host");
|
|
7295
|
+
if (!USER.test(user)) throw new ChatError(400, "user must be a user name on that machine", "ssh_bad_user");
|
|
7296
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new ChatError(400, "port must be 1 to 65535", "ssh_bad_port");
|
|
7297
|
+
return { host, port, user };
|
|
7298
|
+
}
|
|
7299
|
+
function directoryIn(home, value2) {
|
|
7300
|
+
const raw = text3(value2, 1e3);
|
|
7301
|
+
if (!raw || raw === "~") return home;
|
|
7302
|
+
if (raw.startsWith("~/")) return posix.join(home, raw.slice(2));
|
|
7303
|
+
if (!raw.startsWith("/") || /[\x00-\x1f]/.test(raw)) throw new ChatError(400, "directory must be an absolute path or start with ~/", "ssh_bad_directory");
|
|
7304
|
+
return posix.normalize(raw);
|
|
7305
|
+
}
|
|
7306
|
+
|
|
6344
7307
|
// src/server/infra/storage/history-repository.ts
|
|
6345
7308
|
import { DatabaseSync as DatabaseSync2 } from "node:sqlite";
|
|
6346
|
-
import { chmodSync as chmodSync3, mkdirSync as
|
|
6347
|
-
import { dirname as
|
|
7309
|
+
import { chmodSync as chmodSync3, mkdirSync as mkdirSync5 } from "node:fs";
|
|
7310
|
+
import { dirname as dirname6 } from "node:path";
|
|
6348
7311
|
var json = (value2) => value2 == null ? null : JSON.stringify(value2);
|
|
6349
7312
|
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
7313
|
var BLOCKS = "FROM json_each(history.message,'$.content') b WHERE json_extract(b.value,'$.type')";
|
|
@@ -6356,7 +7319,7 @@ var SUMMARY = `SELECT id,timestamp,role,model,
|
|
|
6356
7319
|
var HistoryRepository = class {
|
|
6357
7320
|
db;
|
|
6358
7321
|
constructor(path) {
|
|
6359
|
-
|
|
7322
|
+
mkdirSync5(dirname6(path), { recursive: true, mode: 448 });
|
|
6360
7323
|
this.db = new DatabaseSync2(path);
|
|
6361
7324
|
chmodSync3(path, 384);
|
|
6362
7325
|
const version = this.db.prepare("PRAGMA user_version").get().user_version;
|
|
@@ -6668,33 +7631,33 @@ var Model = class {
|
|
|
6668
7631
|
};
|
|
6669
7632
|
|
|
6670
7633
|
// src/server/service/bot/workspace.ts
|
|
6671
|
-
import { posix } from "node:path";
|
|
7634
|
+
import { posix as posix2 } from "node:path";
|
|
6672
7635
|
|
|
6673
7636
|
// src/server/infra/files/workspace.ts
|
|
6674
|
-
import { mkdir as
|
|
6675
|
-
import { join as
|
|
7637
|
+
import { mkdir as mkdir5, writeFile as writeFile4 } from "node:fs/promises";
|
|
7638
|
+
import { join as join10 } from "node:path";
|
|
6676
7639
|
var LocalWorkspace = class {
|
|
6677
7640
|
constructor(cwd) {
|
|
6678
7641
|
this.cwd = cwd;
|
|
6679
7642
|
}
|
|
6680
7643
|
get tempDir() {
|
|
6681
|
-
return
|
|
7644
|
+
return join10(this.cwd, "temp");
|
|
6682
7645
|
}
|
|
6683
7646
|
async prepare() {
|
|
6684
|
-
await
|
|
7647
|
+
await mkdir5(this.tempDir, { recursive: true, mode: 448 });
|
|
6685
7648
|
}
|
|
6686
7649
|
async writeTempFile(name3, content2, signal) {
|
|
6687
7650
|
await this.prepare();
|
|
6688
|
-
const path =
|
|
6689
|
-
await
|
|
7651
|
+
const path = join10(this.tempDir, name3);
|
|
7652
|
+
await writeFile4(path, content2, { encoding: "utf8", flag: "wx", mode: 384, signal });
|
|
6690
7653
|
return path;
|
|
6691
7654
|
}
|
|
6692
7655
|
};
|
|
6693
7656
|
|
|
6694
7657
|
// src/server/infra/files/skills.ts
|
|
6695
7658
|
import { existsSync as existsSync4, readdirSync, readFileSync as readFileSync4 } from "node:fs";
|
|
6696
|
-
import { mkdir as
|
|
6697
|
-
import { dirname as
|
|
7659
|
+
import { mkdir as mkdir6, writeFile as writeFile5 } from "node:fs/promises";
|
|
7660
|
+
import { dirname as dirname7, join as join11 } from "node:path";
|
|
6698
7661
|
import { fileURLToPath } from "node:url";
|
|
6699
7662
|
|
|
6700
7663
|
// src/server/shared/skill.ts
|
|
@@ -6707,7 +7670,7 @@ function builtinSkills() {
|
|
|
6707
7670
|
const skills = [];
|
|
6708
7671
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
6709
7672
|
if (!entry.isDirectory()) continue;
|
|
6710
|
-
const file =
|
|
7673
|
+
const file = join11(dir, entry.name, "SKILL.md");
|
|
6711
7674
|
if (!existsSync4(file)) continue;
|
|
6712
7675
|
const skill = parseSkill(entry.name, readFileSync4(file, "utf8"));
|
|
6713
7676
|
if (skill) skills.push(skill);
|
|
@@ -6717,16 +7680,16 @@ function builtinSkills() {
|
|
|
6717
7680
|
}
|
|
6718
7681
|
async function installSkills(workspace, skills) {
|
|
6719
7682
|
for (const skill of skills) {
|
|
6720
|
-
const file =
|
|
6721
|
-
await
|
|
6722
|
-
await
|
|
7683
|
+
const file = join11(workspace, skillPath(skill.name));
|
|
7684
|
+
await mkdir6(dirname7(file), { recursive: true });
|
|
7685
|
+
await writeFile5(file, skill.content, "utf8");
|
|
6723
7686
|
}
|
|
6724
7687
|
}
|
|
6725
7688
|
function skillsDir() {
|
|
6726
|
-
let dir =
|
|
7689
|
+
let dir = dirname7(fileURLToPath(import.meta.url));
|
|
6727
7690
|
for (let depth = 0; depth < 6; depth++) {
|
|
6728
|
-
if (existsSync4(
|
|
6729
|
-
const parent =
|
|
7691
|
+
if (existsSync4(join11(dir, "package.json"))) return existsSync4(join11(dir, "skills")) ? join11(dir, "skills") : null;
|
|
7692
|
+
const parent = dirname7(dir);
|
|
6730
7693
|
if (parent === dir) break;
|
|
6731
7694
|
dir = parent;
|
|
6732
7695
|
}
|
|
@@ -6759,7 +7722,7 @@ var Workspace = class {
|
|
|
6759
7722
|
async writeTempFile(name3, content2, signal) {
|
|
6760
7723
|
const env = this.current();
|
|
6761
7724
|
if (env.kind === "host") return this.local.writeTempFile(name3, content2, signal);
|
|
6762
|
-
const path = `${env
|
|
7725
|
+
const path = `${tempOf(env)}/${name3}`;
|
|
6763
7726
|
await env.writeFile(path, content2);
|
|
6764
7727
|
return path;
|
|
6765
7728
|
}
|
|
@@ -6767,14 +7730,14 @@ var Workspace = class {
|
|
|
6767
7730
|
* id when another file has that name). Returns where it went. */
|
|
6768
7731
|
async put(name3, id, data, path) {
|
|
6769
7732
|
const env = this.current();
|
|
6770
|
-
let
|
|
6771
|
-
if (!path && await env.exists(
|
|
7733
|
+
let target3 = path ? posix2.resolve(env.cwd, path) : `${tempOf(env)}/files/${plain(name3)}`;
|
|
7734
|
+
if (!path && await env.exists(target3)) {
|
|
6772
7735
|
const dot = plain(name3).lastIndexOf("."), stem = dot > 0 ? plain(name3).slice(0, dot) : plain(name3), extension2 = dot > 0 ? plain(name3).slice(dot) : "";
|
|
6773
|
-
|
|
7736
|
+
target3 = `${tempOf(env)}/files/${stem}-${id.slice(0, 8)}${extension2}`;
|
|
6774
7737
|
}
|
|
6775
|
-
await env.mkdir(
|
|
6776
|
-
await env.writeFile(
|
|
6777
|
-
return
|
|
7738
|
+
await env.mkdir(posix2.dirname(target3));
|
|
7739
|
+
await env.writeFile(target3, data);
|
|
7740
|
+
return target3;
|
|
6778
7741
|
}
|
|
6779
7742
|
/** The files a Bot attaches to a message, read from its environment: paths there, relative to its directory. */
|
|
6780
7743
|
async readOutgoing(items, signal) {
|
|
@@ -6782,7 +7745,7 @@ var Workspace = class {
|
|
|
6782
7745
|
const env = this.current(), files = [];
|
|
6783
7746
|
for (const item of items) {
|
|
6784
7747
|
signal?.throwIfAborted();
|
|
6785
|
-
const path =
|
|
7748
|
+
const path = posix2.resolve(env.cwd, item.path), size = await env.fileSize(path);
|
|
6786
7749
|
if (size === null) throw new ChatError(400, `No file at ${path} in your environment`);
|
|
6787
7750
|
if (!size || size > MAX_FILE) throw new ChatError(400, "Invalid attachment size or type");
|
|
6788
7751
|
const data = await env.readFile(path);
|
|
@@ -6799,6 +7762,7 @@ function plain(name3) {
|
|
|
6799
7762
|
const cleaned = name3.replace(/[/\\\0]/g, "_").trim();
|
|
6800
7763
|
return cleaned && cleaned !== "." && cleaned !== ".." ? cleaned : "file";
|
|
6801
7764
|
}
|
|
7765
|
+
var tempOf = (env) => env.temp ?? `${env.cwd}/temp`;
|
|
6802
7766
|
|
|
6803
7767
|
// src/server/service/bot/state.ts
|
|
6804
7768
|
var BotState = class {
|
|
@@ -6817,20 +7781,20 @@ var BotState = class {
|
|
|
6817
7781
|
};
|
|
6818
7782
|
|
|
6819
7783
|
// src/server/service/bot/tools.ts
|
|
6820
|
-
import { Type as
|
|
6821
|
-
import { Value } from "typebox/value";
|
|
7784
|
+
import { Type as Type6 } from "typebox";
|
|
7785
|
+
import { Value as Value2 } from "typebox/value";
|
|
6822
7786
|
|
|
6823
7787
|
// src/server/service/bot/history-read.ts
|
|
6824
|
-
import { randomUUID as
|
|
7788
|
+
import { randomUUID as randomUUID13 } from "node:crypto";
|
|
6825
7789
|
import { Type as Type2 } from "typebox";
|
|
6826
7790
|
|
|
6827
7791
|
// src/server/service/bot/history-format.ts
|
|
6828
|
-
function fence(
|
|
7792
|
+
function fence(text4, language = "text") {
|
|
6829
7793
|
let length = 3;
|
|
6830
|
-
for (const match of
|
|
7794
|
+
for (const match of text4.matchAll(/`+/g)) length = Math.max(length, match[0].length + 1);
|
|
6831
7795
|
const marker = "`".repeat(length);
|
|
6832
7796
|
return `${marker}${language}
|
|
6833
|
-
${
|
|
7797
|
+
${text4}
|
|
6834
7798
|
${marker}`;
|
|
6835
7799
|
}
|
|
6836
7800
|
var jsonBlock = (value2) => fence(JSON.stringify(value2, null, 2), "json");
|
|
@@ -6901,12 +7865,12 @@ async function readHistory(memory, workspace, input, signal) {
|
|
|
6901
7865
|
const rendered = formatHistory(result, format);
|
|
6902
7866
|
signal?.throwIfAborted();
|
|
6903
7867
|
if (!input.to_file) return rendered;
|
|
6904
|
-
const name3 = `history-${
|
|
7868
|
+
const name3 = `history-${randomUUID13()}.${format === "json" ? "json" : "md"}`;
|
|
6905
7869
|
return workspace.writeTempFile(name3, rendered, signal);
|
|
6906
7870
|
}
|
|
6907
7871
|
|
|
6908
7872
|
// src/server/service/bot/file-view.ts
|
|
6909
|
-
var
|
|
7873
|
+
var TEXT_LIMIT2 = 64 * 1024;
|
|
6910
7874
|
var PICTURE_LIMIT = 4 * 1024 * 1024;
|
|
6911
7875
|
function viewFile(file, data) {
|
|
6912
7876
|
const head = `${file.name} (${file.mimeType || "application/octet-stream"}, ${byteSize(file.size)})`;
|
|
@@ -6915,10 +7879,10 @@ function viewFile(file, data) {
|
|
|
6915
7879
|
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
7880
|
return { kind: "media", text: head, images: [{ data: data.toString("base64"), mimeType: picture }] };
|
|
6917
7881
|
}
|
|
6918
|
-
const
|
|
6919
|
-
if (
|
|
7882
|
+
const text4 = data.length <= TEXT_LIMIT2 ? asText(data) : null;
|
|
7883
|
+
if (text4 !== null) return `${head}
|
|
6920
7884
|
|
|
6921
|
-
${
|
|
7885
|
+
${text4}`;
|
|
6922
7886
|
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
7887
|
}
|
|
6924
7888
|
function pictureType(data) {
|
|
@@ -7192,7 +8156,7 @@ var definitions = {
|
|
|
7192
8156
|
"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
8157
|
"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
8158
|
"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
|
|
8159
|
+
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
8160
|
schema: Type3.Object({ environments: Type3.Optional(Type3.Boolean({ description: "Also give each Bot's work environments" })) }, { additionalProperties: false })
|
|
7197
8161
|
},
|
|
7198
8162
|
"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 },
|
|
@@ -7267,8 +8231,20 @@ var definitions = {
|
|
|
7267
8231
|
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
8232
|
schema: Type3.Object({ id: Type3.String({ minLength: 1, maxLength: 80, description: "The environment's id from environments.list" }) }, { additionalProperties: false })
|
|
7269
8233
|
},
|
|
8234
|
+
"environments.request": {
|
|
8235
|
+
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 type its login password there once, and from then on it is reached with a key made for it. You never see either, 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"}.`,
|
|
8236
|
+
schema: Type3.Object({
|
|
8237
|
+
chatId: Type3.String(),
|
|
8238
|
+
host: Type3.String({ minLength: 1, maxLength: 253, description: "Host name or IP address" }),
|
|
8239
|
+
user: Type3.String({ minLength: 1, maxLength: 64, description: "The user to log in as" }),
|
|
8240
|
+
port: Type3.Optional(Type3.Integer({ minimum: 1, maximum: 65535, description: "Default 22" })),
|
|
8241
|
+
directory: Type3.Optional(Type3.String({ maxLength: 1e3, description: "Where your tools start there: an absolute path or one under ~/; default the user's home" })),
|
|
8242
|
+
name: Type3.Optional(Type3.String({ maxLength: 40, description: "What to call the machine; default user@host" })),
|
|
8243
|
+
purpose: Type3.String({ minLength: 1, maxLength: 300, description: "What you need it for, in your owner's language; shown on the card" })
|
|
8244
|
+
}, { additionalProperties: false })
|
|
8245
|
+
},
|
|
7270
8246
|
"secrets.request": {
|
|
7271
|
-
description: 'Ask your owner for a secret you need (a token,
|
|
8247
|
+
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
8248
|
schema: Type3.Object({
|
|
7273
8249
|
chatId: Type3.String(),
|
|
7274
8250
|
name: Type3.String({ pattern: "^[A-Z][A-Z0-9_]{0,63}$", description: "Capital letters, digits and underscores, such as GITHUB_TOKEN" }),
|
|
@@ -7430,7 +8406,7 @@ var Model2 = Type4.Object({
|
|
|
7430
8406
|
}, { additionalProperties: false });
|
|
7431
8407
|
var Thinking = Type4.Union(
|
|
7432
8408
|
["auto", "off", "minimal", "low", "medium", "high", "xhigh", "max"].map((level) => Type4.Literal(level)),
|
|
7433
|
-
{ description: "How much the model thinks; auto lets
|
|
8409
|
+
{ description: "How much the model thinks; auto lets this chat platform choose. models.list gives the levels each model has" }
|
|
7434
8410
|
);
|
|
7435
8411
|
var Settings = {
|
|
7436
8412
|
description: Type4.Optional(Type4.String({ maxLength: 1e3, description: "One line that other members see in members.list" })),
|
|
@@ -7439,7 +8415,7 @@ var Settings = {
|
|
|
7439
8415
|
thinking: Type4.Optional(Thinking)
|
|
7440
8416
|
};
|
|
7441
8417
|
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
|
|
8418
|
+
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
8419
|
});
|
|
7444
8420
|
var Confirm = {
|
|
7445
8421
|
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 +8458,104 @@ var agentDefinitions = {
|
|
|
7482
8458
|
}
|
|
7483
8459
|
};
|
|
7484
8460
|
|
|
8461
|
+
// src/server/service/bot/tool-errors.ts
|
|
8462
|
+
import { Value } from "typebox/value";
|
|
8463
|
+
var MAX_PROBLEMS = 5;
|
|
8464
|
+
function argumentProblems(schema, args) {
|
|
8465
|
+
const lines = [];
|
|
8466
|
+
for (const error of Value.Errors(schema, args)) {
|
|
8467
|
+
if (error.keyword === "boolean" && error.schemaPath.endsWith("additionalProperties")) continue;
|
|
8468
|
+
const extra = error.keyword === "additionalProperties" ? error.params.additionalProperties : void 0;
|
|
8469
|
+
const at2 = error.instancePath ? error.instancePath.slice(1).replaceAll("/", ".") : "";
|
|
8470
|
+
if (extra?.length) for (const name3 of extra) lines.push(`${at2 ? `${at2}.` : ""}${name3}: not a parameter of this tool`);
|
|
8471
|
+
else lines.push(`${at2 || "args"}: ${error.message}`);
|
|
8472
|
+
if (lines.length >= MAX_PROBLEMS) break;
|
|
8473
|
+
}
|
|
8474
|
+
return [...new Set(lines)].slice(0, MAX_PROBLEMS);
|
|
8475
|
+
}
|
|
8476
|
+
function invalidArguments(tool, schema, args) {
|
|
8477
|
+
const problems = args === void 0 ? ["args: missing; call takes the tool's arguments in args"] : argumentProblems(schema, args);
|
|
8478
|
+
return [`Invalid arguments for ${tool}:`, ...problems.map((line) => `- ${line}`), `Its parameters: ${JSON.stringify(schema)}`].join("\n");
|
|
8479
|
+
}
|
|
8480
|
+
function unknownTool(tool, names) {
|
|
8481
|
+
return `${tool ? `Unknown chat tool "${tool}"` : "call and describe need tool"}. The tools: ${names.join(", ")}.`;
|
|
8482
|
+
}
|
|
8483
|
+
|
|
8484
|
+
// src/server/service/bot/task-tools.ts
|
|
8485
|
+
import { Type as Type5 } from "typebox";
|
|
8486
|
+
var Clock = { hour: Type5.Integer({ minimum: 0, maximum: 23 }), minute: Type5.Integer({ minimum: 0, maximum: 59 }) };
|
|
8487
|
+
var Schedule = Type5.Union([
|
|
8488
|
+
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 }),
|
|
8489
|
+
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 }),
|
|
8490
|
+
Type5.Object({ kind: Type5.Literal("daily"), ...Clock }, { additionalProperties: false }),
|
|
8491
|
+
Type5.Object({ kind: Type5.Literal("weekly"), weekday: Type5.Integer({ minimum: 0, maximum: 6, description: "0 is Sunday" }), ...Clock }, { additionalProperties: false })
|
|
8492
|
+
], { 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` });
|
|
8493
|
+
var Id = Type5.String({ minLength: 1, maxLength: 80, description: "The task's id, from tasks.list or a <task> in your batch" });
|
|
8494
|
+
var Fields = {
|
|
8495
|
+
name: Type5.String({ minLength: 1, maxLength: 80, description: "A short name, such as Morning news" }),
|
|
8496
|
+
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" })
|
|
8497
|
+
};
|
|
8498
|
+
var ByID = Type5.Object({ id: Id }, { additionalProperties: false });
|
|
8499
|
+
var taskDefinitions = {
|
|
8500
|
+
"tasks.list": {
|
|
8501
|
+
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.",
|
|
8502
|
+
schema: Type5.Object({}, { additionalProperties: false })
|
|
8503
|
+
},
|
|
8504
|
+
"tasks.get": { description: "One of your scheduled tasks in full, with its instruction.", schema: ByID },
|
|
8505
|
+
"tasks.create": {
|
|
8506
|
+
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.",
|
|
8507
|
+
schema: Type5.Object({ ...Fields, schedule: Schedule }, { additionalProperties: false })
|
|
8508
|
+
},
|
|
8509
|
+
"tasks.update": {
|
|
8510
|
+
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.",
|
|
8511
|
+
schema: Type5.Object({ id: Id, name: Type5.Optional(Fields.name), instruction: Type5.Optional(Fields.instruction), schedule: Type5.Optional(Schedule) }, { additionalProperties: false })
|
|
8512
|
+
},
|
|
8513
|
+
"tasks.pause": { description: "Stop a task from running until you resume it; it stays in the list.", schema: ByID },
|
|
8514
|
+
"tasks.resume": { description: "Start a paused task again, from its next time after now.", schema: ByID },
|
|
8515
|
+
"tasks.delete": { description: "Delete a task; it is gone for good. To stop it for a while, pause it instead.", schema: ByID }
|
|
8516
|
+
};
|
|
8517
|
+
function taskRow(task, full = false) {
|
|
8518
|
+
return {
|
|
8519
|
+
id: task.id,
|
|
8520
|
+
name: task.name,
|
|
8521
|
+
schedule: describeSchedule(task.schedule),
|
|
8522
|
+
state: task.state,
|
|
8523
|
+
...task.nextRunAt !== null ? { next: localIso(task.nextRunAt) } : {},
|
|
8524
|
+
...task.lastRunAt !== null ? { last: localIso(task.lastRunAt) } : {},
|
|
8525
|
+
...full ? { instruction: task.instruction } : {}
|
|
8526
|
+
};
|
|
8527
|
+
}
|
|
8528
|
+
function callTask(tasks, botId, name3, args) {
|
|
8529
|
+
const id = args.id;
|
|
8530
|
+
switch (name3) {
|
|
8531
|
+
case "tasks.list":
|
|
8532
|
+
return { timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, now: localIso(Date.now()), tasks: tasks.list(botId).map((task) => taskRow(task)) };
|
|
8533
|
+
case "tasks.get": {
|
|
8534
|
+
const task = tasks.get(botId, id);
|
|
8535
|
+
if (!task) throw new ChatError(404, "Task not found; see tasks.list");
|
|
8536
|
+
return taskRow(task, true);
|
|
8537
|
+
}
|
|
8538
|
+
case "tasks.create":
|
|
8539
|
+
return taskRow(tasks.create(botId, args), true);
|
|
8540
|
+
case "tasks.update": {
|
|
8541
|
+
const { id: _id, ...rest } = args;
|
|
8542
|
+
return taskRow(tasks.update(botId, id, rest), true);
|
|
8543
|
+
}
|
|
8544
|
+
case "tasks.pause":
|
|
8545
|
+
return taskRow(tasks.setState(botId, id, "paused"));
|
|
8546
|
+
case "tasks.resume":
|
|
8547
|
+
return taskRow(tasks.setState(botId, id, "active"));
|
|
8548
|
+
case "tasks.delete":
|
|
8549
|
+
tasks.remove(botId, id);
|
|
8550
|
+
return { id, deleted: true };
|
|
8551
|
+
}
|
|
8552
|
+
}
|
|
8553
|
+
|
|
7485
8554
|
// src/server/service/bot/tools.ts
|
|
7486
|
-
var Gateway =
|
|
7487
|
-
action:
|
|
7488
|
-
tool:
|
|
7489
|
-
args:
|
|
8555
|
+
var Gateway = Type6.Object({
|
|
8556
|
+
action: Type6.Union([Type6.Literal("list"), Type6.Literal("describe"), Type6.Literal("call")]),
|
|
8557
|
+
tool: Type6.Optional(Type6.String()),
|
|
8558
|
+
args: Type6.Optional(Type6.Record(Type6.String(), Type6.Unknown()))
|
|
7490
8559
|
}, { additionalProperties: false });
|
|
7491
8560
|
var Tools = class {
|
|
7492
8561
|
constructor(scope) {
|
|
@@ -7499,7 +8568,7 @@ var Tools = class {
|
|
|
7499
8568
|
/** The agent Bot's own tools (A48); other Bots have none. */
|
|
7500
8569
|
agent;
|
|
7501
8570
|
get catalog() {
|
|
7502
|
-
return this.agent ? { ...definitions, ...agentDefinitions } : definitions;
|
|
8571
|
+
return this.agent ? { ...definitions, ...taskDefinitions, ...agentDefinitions } : { ...definitions, ...taskDefinitions };
|
|
7503
8572
|
}
|
|
7504
8573
|
/** Posts a card and waits for the person's answer (messages.ask, and the agent's confirmations). */
|
|
7505
8574
|
async ask(chatId, question, toolCallId, signal) {
|
|
@@ -7531,22 +8600,27 @@ var Tools = class {
|
|
|
7531
8600
|
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
8601
|
parameters: Gateway,
|
|
7533
8602
|
execute: async (raw, { toolCallId, signal }) => {
|
|
7534
|
-
if (!
|
|
8603
|
+
if (!Value2.Check(Gateway, raw)) throw new ChatError(400, [
|
|
8604
|
+
"Invalid chat tool request:",
|
|
8605
|
+
...argumentProblems(Gateway, raw).map((line) => `- ${line}`),
|
|
8606
|
+
'It takes {action: "list" | "describe" | "call", tool, args}: list takes neither, describe takes tool, call takes both.'
|
|
8607
|
+
].join("\n"));
|
|
7535
8608
|
signal?.throwIfAborted();
|
|
7536
8609
|
if (raw.action === "list") {
|
|
7537
8610
|
if (raw.tool !== void 0 || raw.args !== void 0) throw new ChatError(400, "list does not accept tool or args");
|
|
7538
8611
|
return Object.entries(this.catalog).map(([name4, tool]) => ({ name: name4, description: tool.description }));
|
|
7539
8612
|
}
|
|
7540
|
-
if (!raw.tool || !Object.hasOwn(this.catalog, raw.tool)) throw new ChatError(400,
|
|
8613
|
+
if (!raw.tool || !Object.hasOwn(this.catalog, raw.tool)) throw new ChatError(400, unknownTool(raw.tool, Object.keys(this.catalog)));
|
|
7541
8614
|
const name3 = raw.tool, definition = this.catalog[name3];
|
|
7542
8615
|
if (raw.action === "describe") {
|
|
7543
8616
|
if (raw.args !== void 0) throw new ChatError(400, "describe does not accept args");
|
|
7544
8617
|
return { name: name3, description: definition.description, parameters: definition.schema };
|
|
7545
8618
|
}
|
|
7546
8619
|
const args = raw.args;
|
|
7547
|
-
if (!args || !
|
|
8620
|
+
if (!args || !Value2.Check(definition.schema, args)) throw new ChatError(400, invalidArguments(name3, definition.schema, args));
|
|
7548
8621
|
if (this.agent && Object.hasOwn(agentDefinitions, name3))
|
|
7549
8622
|
return this.agent.call(name3, args, (chatId, question) => this.ask(chatId, question, toolCallId, signal));
|
|
8623
|
+
if (Object.hasOwn(taskDefinitions, name3)) return callTask(this.scope.tasks, this.scope.botId, name3, args);
|
|
7550
8624
|
switch (name3) {
|
|
7551
8625
|
case "history.read":
|
|
7552
8626
|
return readHistory(this.scope.memory, this.scope.workspace, args, signal);
|
|
@@ -7584,6 +8658,16 @@ var Tools = class {
|
|
|
7584
8658
|
until: result.selected[0]?.value === "once" ? "this run ends" : "your owner revokes it"
|
|
7585
8659
|
};
|
|
7586
8660
|
}
|
|
8661
|
+
case "environments.request": {
|
|
8662
|
+
const parsed = args;
|
|
8663
|
+
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");
|
|
8664
|
+
const input = { ...parsed, purpose: parsed.purpose.trim() }, prepared = await this.scope.places.request(input);
|
|
8665
|
+
const added = (record3) => ({ id: record3.id, name: record3.name, note: "environments.switch with this id moves your tools there" });
|
|
8666
|
+
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" };
|
|
8667
|
+
const result = await this.ask(parsed.chatId, prepared.question, toolCallId, signal), found = this.scope.places.found(input);
|
|
8668
|
+
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." };
|
|
8669
|
+
return { messageId: result.messageId, status: prepared.question.machine?.existing ? "granted" : "added", ...added(found) };
|
|
8670
|
+
}
|
|
7587
8671
|
case "chats.create":
|
|
7588
8672
|
return this.tools.create(args);
|
|
7589
8673
|
case "chats.update":
|
|
@@ -7685,7 +8769,7 @@ var Tools = class {
|
|
|
7685
8769
|
};
|
|
7686
8770
|
|
|
7687
8771
|
// src/server/service/bot/inbox.ts
|
|
7688
|
-
import { join as
|
|
8772
|
+
import { join as join12, dirname as dirname8 } from "node:path";
|
|
7689
8773
|
|
|
7690
8774
|
// src/server/shared/agent.ts
|
|
7691
8775
|
var WELCOME_LANGUAGES = { en: "English", zh: "Chinese" };
|
|
@@ -7708,15 +8792,21 @@ function remember(map, id, definition) {
|
|
|
7708
8792
|
}
|
|
7709
8793
|
var environmentLine = (place) => place ? `<environment${attribute("name", place.name)}${attribute("directory", place.directory)}/>` : "";
|
|
7710
8794
|
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");
|
|
8795
|
+
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.`;
|
|
8796
|
+
function encodeNoticeBatch(botId, notices, environment, prompt) {
|
|
8797
|
+
if (!notices.length && !prompt) throw new Error("Empty notice batch");
|
|
7714
8798
|
const chats = /* @__PURE__ */ new Map(), members = /* @__PURE__ */ new Map();
|
|
7715
8799
|
const list = noticeTail(notices, chats, members);
|
|
7716
|
-
return `<notice_batch${attribute("bot", botId)}>${[...chats.values()].join("")}${[...members.values()].join("")}${list}${environmentLine(environment)}</notice_batch>`;
|
|
8800
|
+
return `<notice_batch${attribute("bot", botId)}>${[...chats.values()].join("")}${[...members.values()].join("")}${list}${instructionsLine(prompt)}${environmentLine(environment)}</notice_batch>`;
|
|
8801
|
+
}
|
|
8802
|
+
function instructionsLine(change) {
|
|
8803
|
+
if (!change) return "";
|
|
8804
|
+
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.";
|
|
8805
|
+
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
8806
|
}
|
|
7718
8807
|
function noticeTail(notices, chats, members) {
|
|
7719
8808
|
for (const notice of notices) {
|
|
8809
|
+
if (notice.kind === "task") continue;
|
|
7720
8810
|
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
8811
|
remember(members, notice.actor.id, `<member${attribute("id", notice.actor.id)}${attribute("name", notice.actor.name)}${attribute("kind", notice.actor.kind)}/>`);
|
|
7722
8812
|
}
|
|
@@ -7728,9 +8818,10 @@ function noticeTail(notices, chats, members) {
|
|
|
7728
8818
|
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
8819
|
const byAgent = changed.some((notice) => notice.actor.kind === "bot"), byOwner = changed.some((notice) => notice.actor.kind === "user");
|
|
7730
8820
|
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
|
-
|
|
8821
|
+
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>`]);
|
|
8822
|
+
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
8823
|
}
|
|
7733
|
-
function encodeChatBatch(botId, messages, digest, notices) {
|
|
8824
|
+
function encodeChatBatch(botId, messages, digest, notices, prompt) {
|
|
7734
8825
|
if (!messages.length) throw new Error("Empty chat batch");
|
|
7735
8826
|
const chats = /* @__PURE__ */ new Map(), members = /* @__PURE__ */ new Map(), ids = /* @__PURE__ */ new Set();
|
|
7736
8827
|
const encoded = messages.map((message) => {
|
|
@@ -7743,12 +8834,12 @@ function encodeChatBatch(botId, messages, digest, notices) {
|
|
|
7743
8834
|
const attributes = attribute("id", message.id) + attribute("chat", message.chat.id) + attribute("from", message.sender.id) + attribute("at", at2);
|
|
7744
8835
|
const reply = message.replyTo;
|
|
7745
8836
|
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
|
|
8837
|
+
const quote2 = reply ? `<reply_to${attribute("message", reply.id)}${attribute("from", reply.sender.id)}>${escape(reply.text)}</reply_to>` : "";
|
|
7747
8838
|
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}>${
|
|
8839
|
+
return `<message${attributes}>${quote2}${message.content ? `<text>${escape(message.content)}</text>` : ""}${files}</message>`;
|
|
7749
8840
|
});
|
|
7750
8841
|
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) : "";
|
|
8842
|
+
let tail = (notices?.length ? noticeTail(notices, chats, members) : "") + instructionsLine(prompt);
|
|
7752
8843
|
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
8844
|
tail += environmentLine(digest?.environment);
|
|
7754
8845
|
return `<chat_batch${attribute("bot", botId)}>${[...chats.values()].join("")}${[...members.values()].join("")}${encoded.join("")}${tail}</chat_batch>`;
|
|
@@ -7757,11 +8848,12 @@ function encodeChatBatch(botId, messages, digest, notices) {
|
|
|
7757
8848
|
// src/server/service/bot/inbox.ts
|
|
7758
8849
|
var BotInbox = class {
|
|
7759
8850
|
/** `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) {
|
|
8851
|
+
constructor(botId, repository, chats, files, tasks, where2) {
|
|
7761
8852
|
this.botId = botId;
|
|
7762
8853
|
this.repository = repository;
|
|
7763
8854
|
this.chats = chats;
|
|
7764
8855
|
this.files = files;
|
|
8856
|
+
this.tasks = tasks;
|
|
7765
8857
|
this.where = where2;
|
|
7766
8858
|
}
|
|
7767
8859
|
/** Messages and other deliveries wait in the inbox (A41); either kind is work for the next run. A digest delivery
|
|
@@ -7772,50 +8864,61 @@ var BotInbox = class {
|
|
|
7772
8864
|
claim(selection) {
|
|
7773
8865
|
return this.repository.claim(this.botId, selection, this.hasNotices());
|
|
7774
8866
|
}
|
|
8867
|
+
/** Anything waiting that is not a message (A39, A41, A58): a task that is due also opens a run without messages. */
|
|
7775
8868
|
hasNotices() {
|
|
7776
8869
|
return this.chats.notices(this.botId).some((notice) => notice.urgency !== "digest");
|
|
7777
8870
|
}
|
|
7778
8871
|
reference(workspace) {
|
|
7779
8872
|
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,
|
|
8873
|
+
return this.repository.ensureSession(this.botId, join12(dirname8(workspace), "agent", "pi", "sessions", `${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}.jsonl`));
|
|
7781
8874
|
}
|
|
7782
|
-
/** XML for the claimed messages plus the tail (other deliveries not told yet,
|
|
7783
|
-
* the batch is submitted. */
|
|
7784
|
-
async prepare(batch) {
|
|
8875
|
+
/** XML for the claimed messages plus the tail (other deliveries not told yet, how the instructions changed,
|
|
8876
|
+
* unread); `notices` are told once the batch is submitted. */
|
|
8877
|
+
async prepare(batch, prompt) {
|
|
7785
8878
|
if (!batch.inputs.length) {
|
|
7786
|
-
const pending2 = this.notices();
|
|
8879
|
+
const pending2 = this.notices(prompt);
|
|
7787
8880
|
return pending2 && { xml: pending2.xml, notices: pending2.ids };
|
|
7788
8881
|
}
|
|
7789
8882
|
const inputs = await this.files.envelope(this.botId, batch.inputs.map((input) => input.messageId));
|
|
7790
8883
|
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
8884
|
const digest = { ...this.chats.channels.digest(this.botId), ...this.where ? { environment: this.where() } : {} };
|
|
7792
8885
|
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) };
|
|
8886
|
+
return { xml: encodeChatBatch(this.botId, inputs, digest, pending.length ? pending : void 0, prompt), notices: pending.map((notice) => notice.id) };
|
|
7794
8887
|
}
|
|
7795
8888
|
/** Inbox rows as the envelope shape; the steered and the batch tails share it. */
|
|
7796
8889
|
envelope(notices) {
|
|
7797
|
-
return notices.
|
|
8890
|
+
return notices.flatMap((notice) => {
|
|
7798
8891
|
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 };
|
|
8892
|
+
if (notice.kind === "note") return [{ kind: "note", id: notice.id, chat, actor: notice.actor, version: Number(notice.detail), createdAt: notice.createdAt }];
|
|
8893
|
+
if (notice.kind === "onboard") return [{ kind: "onboard", id: notice.id, chat, actor: notice.actor, language: welcomeLanguage(notice.detail), createdAt: notice.createdAt }];
|
|
8894
|
+
if (notice.kind === "task") {
|
|
8895
|
+
const fired = JSON.parse(notice.detail), task = this.tasks.get(this.botId, fired.task);
|
|
8896
|
+
return task ? [{ kind: "task", id: notice.id, chat, task: {
|
|
8897
|
+
id: task.id,
|
|
8898
|
+
name: task.name,
|
|
8899
|
+
instruction: task.instruction,
|
|
8900
|
+
schedule: describeSchedule(task.schedule)
|
|
8901
|
+
}, due: fired.due, createdAt: notice.createdAt }] : [];
|
|
8902
|
+
}
|
|
7801
8903
|
if (notice.kind === "change") {
|
|
7802
8904
|
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 };
|
|
8905
|
+
return [{ kind: "change", id: notice.id, chat, actor: notice.actor, what: change.what, value: change.value, createdAt: notice.createdAt }];
|
|
7804
8906
|
}
|
|
7805
8907
|
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:
|
|
8908
|
+
const text4 = marked ? previewQuotes(marked.content, marked.quotes).slice(0, 120) : "";
|
|
8909
|
+
return [{ kind: "reaction", id: notice.id, chat, message: { id: notice.messageId ?? "", text: text4 }, actor: notice.actor, emoji: notice.detail, createdAt: notice.createdAt }];
|
|
7808
8910
|
});
|
|
7809
8911
|
}
|
|
7810
8912
|
/** The batch went to the model: each delivery in its tail is told once. */
|
|
7811
8913
|
acknowledge(notices) {
|
|
7812
8914
|
if (notices.length) this.tell(notices);
|
|
7813
8915
|
}
|
|
7814
|
-
/** Notices the Bot has not been told (A39), as the XML that goes into a steered message or a
|
|
7815
|
-
|
|
7816
|
-
|
|
8916
|
+
/** Notices the Bot has not been told (A39), as the XML that goes into a steered message or a run of notices alone.
|
|
8917
|
+
* `steer`: what may go into a running turn; a task waits for the turn to end and opens one of its own (A58). */
|
|
8918
|
+
notices(prompt, steer = false) {
|
|
8919
|
+
const pending = this.chats.notices(this.botId).filter((notice) => !steer || notice.kind !== "task");
|
|
7817
8920
|
if (!pending.length) return null;
|
|
7818
|
-
return { xml: encodeNoticeBatch(this.botId, this.envelope(pending), this.where?.()), ids: pending.map((notice) => notice.id) };
|
|
8921
|
+
return { xml: encodeNoticeBatch(this.botId, this.envelope(pending), this.where?.(), prompt), ids: pending.map((notice) => notice.id) };
|
|
7819
8922
|
}
|
|
7820
8923
|
/** These notices reached the model. */
|
|
7821
8924
|
tell(ids) {
|
|
@@ -7910,7 +9013,7 @@ var AgentRuntime = class {
|
|
|
7910
9013
|
}
|
|
7911
9014
|
this.notices = this.notices.catch(() => {
|
|
7912
9015
|
}).then(async () => {
|
|
7913
|
-
const pending = this.inbox.notices();
|
|
9016
|
+
const pending = this.inbox.notices(null, true);
|
|
7914
9017
|
if (!pending || this.stopping) return;
|
|
7915
9018
|
if (this.prompting && this.session?.steer(pending.xml)) this.inbox.tell(pending.ids);
|
|
7916
9019
|
else this.wake();
|
|
@@ -7945,7 +9048,8 @@ var AgentRuntime = class {
|
|
|
7945
9048
|
this.update({ error: null });
|
|
7946
9049
|
let submitted = false;
|
|
7947
9050
|
try {
|
|
7948
|
-
const
|
|
9051
|
+
const change = session.promptChange();
|
|
9052
|
+
const prepared = await this.inbox.prepare(batch, change);
|
|
7949
9053
|
if (!prepared) {
|
|
7950
9054
|
repository.releaseUnsubmitted(batch.run.id);
|
|
7951
9055
|
continue;
|
|
@@ -7959,6 +9063,7 @@ var AgentRuntime = class {
|
|
|
7959
9063
|
submitted = true;
|
|
7960
9064
|
this.prompting = true;
|
|
7961
9065
|
this.inbox.acknowledge(notices);
|
|
9066
|
+
if (change) session.promptTold(change);
|
|
7962
9067
|
this.promptTask = session.prompt(xml2);
|
|
7963
9068
|
const result = await this.promptTask;
|
|
7964
9069
|
repository.finish(batch.run.id, result.outcome, result.outcome === "failed" ? "model_request_failed" : null);
|
|
@@ -7967,8 +9072,8 @@ var AgentRuntime = class {
|
|
|
7967
9072
|
throw new RuntimeFault("model_request_failed", `Model request failed: ${detail}`, 502, { detail });
|
|
7968
9073
|
}
|
|
7969
9074
|
} catch (error) {
|
|
7970
|
-
const
|
|
7971
|
-
if (
|
|
9075
|
+
const run2 = repository.run(batch.run.id);
|
|
9076
|
+
if (run2?.state === "claimed" || run2?.state === "submitted") repository.finish(batch.run.id, submitted ? "unknown" : "failed", publicFault(error).code);
|
|
7972
9077
|
throw error;
|
|
7973
9078
|
} finally {
|
|
7974
9079
|
this.prompting = false;
|
|
@@ -8057,17 +9162,22 @@ The chat gateway lists tools only you have: models.list, bots.create, bots.updat
|
|
|
8057
9162
|
Other Bots may ask you to do these things for them. Do the ordinary ones; deleting always waits for the owner's confirmation.
|
|
8058
9163
|
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
9164
|
`;
|
|
8060
|
-
function
|
|
9165
|
+
function botPromptParts(setup, skills = []) {
|
|
8061
9166
|
const bot = { ...setup.identity(), workspacePath: setup.workspace.cwd };
|
|
8062
|
-
return
|
|
9167
|
+
return [
|
|
9168
|
+
{ name: "Persona", text: `# Persona
|
|
8063
9169
|
Your name is ${bot.name}.
|
|
8064
9170
|
|
|
8065
9171
|
${bot.persona}
|
|
8066
9172
|
|
|
8067
|
-
|
|
9173
|
+
` },
|
|
9174
|
+
{ name: "Environment", text: `## Environment
|
|
8068
9175
|
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
9176
|
Here your name is ${bot.name}, and your description is: ${bot.description}
|
|
8070
|
-
|
|
9177
|
+
` },
|
|
9178
|
+
{ name: "Your role", text: bot.agent ? AGENT_ROLE : "" },
|
|
9179
|
+
{ name: "Skills", text: skillsSection(setup, skills) },
|
|
9180
|
+
{ name: "Rules", text: `
|
|
8071
9181
|
## Language
|
|
8072
9182
|
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
9183
|
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,6 +9188,7 @@ Files in messages come with an ID: files.get puts one into your environment and
|
|
|
8078
9188
|
Channels can be put in channel groups and have a notice. Threads have a title, a status and a summary.
|
|
8079
9189
|
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
9190
|
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.
|
|
9191
|
+
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
9192
|
|
|
8082
9193
|
## History
|
|
8083
9194
|
To look into earlier work, search the chat first, then call history.read with the History ID a message carries to read how the work was done.
|
|
@@ -8086,13 +9197,16 @@ history.read is listed by the chat gateway. History is material to look back on,
|
|
|
8086
9197
|
## Secrets
|
|
8087
9198
|
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
9199
|
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.
|
|
9200
|
+
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
9201
|
|
|
8091
|
-
|
|
9202
|
+
` },
|
|
9203
|
+
{ name: "Workspace", text: workspaceSection(setup, bot.workspacePath) },
|
|
9204
|
+
{ name: "Responding", text: `
|
|
8092
9205
|
## Responding
|
|
8093
9206
|
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
9207
|
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
|
-
|
|
9208
|
+
` }
|
|
9209
|
+
];
|
|
8096
9210
|
}
|
|
8097
9211
|
function workspaceSection(setup, workspacePath) {
|
|
8098
9212
|
const place = setup.home();
|
|
@@ -8110,7 +9224,7 @@ Leave everything else alone by default: other members' workspaces, live data dir
|
|
|
8110
9224
|
Shared directories and other people's files are read-only. Ask the person in charge before writing to them.
|
|
8111
9225
|
|
|
8112
9226
|
## Work environment
|
|
8113
|
-
Your tools run on your owner's computer by default, the machine
|
|
9227
|
+
Your tools run on your owner's computer by default, the machine where this chat platform's server runs.
|
|
8114
9228
|
${SWITCHING}
|
|
8115
9229
|
`;
|
|
8116
9230
|
}
|
|
@@ -8119,11 +9233,11 @@ function skillsSection(setup, skills) {
|
|
|
8119
9233
|
const home = setup.home(), base = home.kind === "computer" ? home.cwd : setup.workspace.cwd;
|
|
8120
9234
|
return `
|
|
8121
9235
|
## Skills
|
|
8122
|
-
Skills are guides that come with
|
|
9236
|
+
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
9237
|
${skills.map((skill) => `- ${skill.name}: ${skill.description} File: ${base}/${skillPath(skill.name)}`).join("\n")}
|
|
8124
9238
|
`;
|
|
8125
9239
|
}
|
|
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.";
|
|
9240
|
+
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
9241
|
|
|
8128
9242
|
// src/server/shared/live.ts
|
|
8129
9243
|
var LIVE_STEPS = 300;
|
|
@@ -8133,8 +9247,8 @@ var LIVE_INPUT = 300;
|
|
|
8133
9247
|
var LIVE_MESSAGE = 2e3;
|
|
8134
9248
|
|
|
8135
9249
|
// src/server/service/bot/live-input.ts
|
|
8136
|
-
function clip(
|
|
8137
|
-
return
|
|
9250
|
+
function clip(text4, length) {
|
|
9251
|
+
return text4.length > length ? `${text4.slice(0, length - 1)}\u2026` : text4;
|
|
8138
9252
|
}
|
|
8139
9253
|
var field = (value2, length = LIVE_INPUT) => typeof value2 === "string" ? clip(value2, length) : void 0;
|
|
8140
9254
|
var record2 = (value2) => value2 && typeof value2 === "object" && !Array.isArray(value2) ? value2 : {};
|
|
@@ -8163,7 +9277,7 @@ function liveInput(tool, args, chat) {
|
|
|
8163
9277
|
}
|
|
8164
9278
|
function gateway(values, chat) {
|
|
8165
9279
|
const action = values.action === "call" && typeof values.tool === "string" ? values.tool : typeof values.action === "string" ? values.action : "";
|
|
8166
|
-
const inner = record2(values.args), where2 =
|
|
9280
|
+
const inner = record2(values.args), where2 = target2(inner, chat);
|
|
8167
9281
|
if (action === "messages.send") return { action, chat: where2, text: field(inner.message, LIVE_MESSAGE) };
|
|
8168
9282
|
if (action === "messages.ask") return {
|
|
8169
9283
|
action,
|
|
@@ -8173,7 +9287,7 @@ function gateway(values, chat) {
|
|
|
8173
9287
|
};
|
|
8174
9288
|
return where2 ? { action, chat: where2 } : { action };
|
|
8175
9289
|
}
|
|
8176
|
-
function
|
|
9290
|
+
function target2(inner, chat) {
|
|
8177
9291
|
if (typeof inner.to === "string") return { id: null, type: "dm", title: clip(inner.to, 80) };
|
|
8178
9292
|
return typeof inner.chatId === "string" ? chat(inner.chatId) ?? void 0 : void 0;
|
|
8179
9293
|
}
|
|
@@ -8188,10 +9302,10 @@ function sentChat(output, chat) {
|
|
|
8188
9302
|
|
|
8189
9303
|
// src/server/service/bot/live.ts
|
|
8190
9304
|
var FLUSH_MS = 100;
|
|
8191
|
-
function liveOutput(
|
|
8192
|
-
if (!
|
|
8193
|
-
if (
|
|
8194
|
-
return { head:
|
|
9305
|
+
function liveOutput(text4) {
|
|
9306
|
+
if (!text4) return null;
|
|
9307
|
+
if (text4.length <= 2 * LIVE_OUTPUT_END) return { head: text4, tail: "", omitted: 0 };
|
|
9308
|
+
return { head: text4.slice(0, LIVE_OUTPUT_END), tail: text4.slice(-LIVE_OUTPUT_END), omitted: text4.length - 2 * LIVE_OUTPUT_END };
|
|
8195
9309
|
}
|
|
8196
9310
|
var LiveTurns = class {
|
|
8197
9311
|
constructor(chat) {
|
|
@@ -8248,8 +9362,8 @@ var LiveTurns = class {
|
|
|
8248
9362
|
}
|
|
8249
9363
|
const step = this.steps.get(id);
|
|
8250
9364
|
if (!step) return;
|
|
8251
|
-
const
|
|
8252
|
-
step.text =
|
|
9365
|
+
const text4 = (step.text ?? "") + event.delta, over = Math.max(0, text4.length - LIVE_TEXT);
|
|
9366
|
+
step.text = text4.slice(over);
|
|
8253
9367
|
step.omitted = (step.omitted ?? 0) + over;
|
|
8254
9368
|
this.queue({ op: "text", id, append: event.delta });
|
|
8255
9369
|
return;
|
|
@@ -8500,12 +9614,15 @@ var BotService = class {
|
|
|
8500
9614
|
this.deps = deps;
|
|
8501
9615
|
this.agent = new AgentOps(this, deps.store, deps.chats);
|
|
8502
9616
|
this.computers = new Computers({ store: deps.store, changed: () => this.listChanged() });
|
|
8503
|
-
this.
|
|
9617
|
+
this.machines = new SshMachines(deps.store, deps.chats, () => this.listChanged());
|
|
9618
|
+
this.environments = new BotEnvironments(deps.store, this.computers, this.machines);
|
|
8504
9619
|
}
|
|
8505
9620
|
bots = /* @__PURE__ */ new Map();
|
|
8506
9621
|
lives = /* @__PURE__ */ new Map();
|
|
8507
9622
|
computers;
|
|
8508
9623
|
environments;
|
|
9624
|
+
/** The owner's SSH machines (A57). */
|
|
9625
|
+
machines;
|
|
8509
9626
|
closed = false;
|
|
8510
9627
|
started = false;
|
|
8511
9628
|
/** What only the agent Bot does (A48). */
|
|
@@ -8526,14 +9643,14 @@ var BotService = class {
|
|
|
8526
9643
|
const place = this.environments.here(id, record3.workspacePath);
|
|
8527
9644
|
return { name: place.name, directory: place.directory };
|
|
8528
9645
|
};
|
|
8529
|
-
const runtime = new AgentRuntime(new BotInbox(id, store.runtime, chats, files, here), (setup, model, reference, activity) => (this.deps.factory ?? PiSession.create)({
|
|
9646
|
+
const runtime = new AgentRuntime(new BotInbox(id, store.runtime, chats, files, store.tasks, here), (setup, model, reference, activity) => (this.deps.factory ?? PiSession.create)({
|
|
8530
9647
|
store,
|
|
8531
9648
|
bot: record3,
|
|
8532
9649
|
selection: model.selection,
|
|
8533
9650
|
reference,
|
|
8534
9651
|
credentials,
|
|
8535
9652
|
tools: setup.tools,
|
|
8536
|
-
systemPrompt: () =>
|
|
9653
|
+
systemPrompt: () => botPromptParts(setup, skills),
|
|
8537
9654
|
onHistory: setup.onHistory,
|
|
8538
9655
|
activity,
|
|
8539
9656
|
live: (event) => live.event(event),
|
|
@@ -8544,7 +9661,7 @@ var BotService = class {
|
|
|
8544
9661
|
const place = this.environments.home(id, record3.workspacePath);
|
|
8545
9662
|
return { kind: place.kind === "sandbox" ? "computer" : "host", cwd: place.directory };
|
|
8546
9663
|
}, skills);
|
|
8547
|
-
const memory = new Memory(
|
|
9664
|
+
const memory = new Memory(join13(dirname9(record3.workspacePath), "history.db"));
|
|
8548
9665
|
bot = new Bot(id, {
|
|
8549
9666
|
identity: new Identity(id, store),
|
|
8550
9667
|
model: new Model(id, store, resolveThinking),
|
|
@@ -8561,9 +9678,12 @@ var BotService = class {
|
|
|
8561
9678
|
places: {
|
|
8562
9679
|
list: () => this.environments.list(id, record3.workspacePath),
|
|
8563
9680
|
switch: (environmentId) => this.environments.switch(id, environmentId, record3.workspacePath),
|
|
8564
|
-
summary: (botId) => this.environments.summary(botId)
|
|
9681
|
+
summary: (botId) => this.environments.summary(botId),
|
|
9682
|
+
request: (input) => this.machines.prepareRequest(id, input),
|
|
9683
|
+
found: (input) => this.machines.find(id, input)
|
|
8565
9684
|
},
|
|
8566
9685
|
secrets: { list: () => this.deps.secrets.listFor(id), prepare: (name3, purpose) => this.deps.secrets.prepareRequest(id, name3, purpose) },
|
|
9686
|
+
tasks: this.deps.tasks,
|
|
8567
9687
|
...id === AGENT_BOT_ID ? { agent: this.agent } : {}
|
|
8568
9688
|
}),
|
|
8569
9689
|
agentRuntime: runtime,
|
|
@@ -8633,10 +9753,33 @@ var BotService = class {
|
|
|
8633
9753
|
}
|
|
8634
9754
|
/** The owner lets a Bot use their computer, or takes it back; taking it back while the Bot is there sends it home. */
|
|
8635
9755
|
grantHost(id, granted) {
|
|
8636
|
-
|
|
8637
|
-
|
|
9756
|
+
return this.grant(id, "host", granted);
|
|
9757
|
+
}
|
|
9758
|
+
/** The owner lets a Bot use an SSH machine (A57), or takes it back, the same way. */
|
|
9759
|
+
grantMachine(id, machineId, granted) {
|
|
9760
|
+
this.machines.get(machineId);
|
|
9761
|
+
return this.grant(id, machineId, granted);
|
|
9762
|
+
}
|
|
9763
|
+
/** The owner deletes an SSH machine: Bots working there are back in their default, and told. */
|
|
9764
|
+
async removeMachine(machineId, force) {
|
|
9765
|
+
const name3 = this.machines.get(machineId).name, granted = this.machines.get(machineId).botIds;
|
|
9766
|
+
const moved = await this.machines.remove(machineId, force);
|
|
9767
|
+
for (const id of /* @__PURE__ */ new Set([...granted, ...moved])) {
|
|
9768
|
+
if (!this.deps.store.getBot(id)) continue;
|
|
9769
|
+
const changes = [{ what: "access", value: `${name3}: removed` }];
|
|
9770
|
+
if (moved.includes(id)) {
|
|
9771
|
+
const to = this.environments.here(id, this.record(id).workspacePath);
|
|
9772
|
+
changes.push({ what: "environment", value: `${to.name}, in ${to.directory}` });
|
|
9773
|
+
}
|
|
9774
|
+
this.tellOwner(id, changes, moved.includes(id));
|
|
9775
|
+
}
|
|
9776
|
+
}
|
|
9777
|
+
grant(id, environmentId, granted) {
|
|
9778
|
+
const { changed, moved, name: name3 } = this.environments.grant(id, environmentId, granted, this.deps.store.userMemberId(), this.record(id).workspacePath);
|
|
9779
|
+
const changes = changed ? [{ what: "access", value: `${name3}: ${granted ? "you may switch to it" : "taken back"}` }] : [];
|
|
8638
9780
|
if (moved) changes.push({ what: "environment", value: where(moved) });
|
|
8639
9781
|
if (changes.length) this.tellOwner(id, changes, !!moved);
|
|
9782
|
+
if (changed && environmentId !== "host") this.listChanged();
|
|
8640
9783
|
return this.describe(id);
|
|
8641
9784
|
}
|
|
8642
9785
|
/** Tells a Bot what its owner changed about where it works (A41, A50). A move interrupts a running turn, as an
|
|
@@ -8764,7 +9907,7 @@ var BotService = class {
|
|
|
8764
9907
|
this.lives.delete(id);
|
|
8765
9908
|
await this.environments.release(id);
|
|
8766
9909
|
if (!this.deps.store.deleteBot(id)) throw new RuntimeFault("bot_missing", "Bot not found", 404);
|
|
8767
|
-
if (!keepFiles) await
|
|
9910
|
+
if (!keepFiles) await rm4(dirname9(workspacePath), { recursive: true, force: true });
|
|
8768
9911
|
}
|
|
8769
9912
|
async close() {
|
|
8770
9913
|
if (this.closed) return;
|
|
@@ -8800,8 +9943,8 @@ var ConnectionService = class {
|
|
|
8800
9943
|
}
|
|
8801
9944
|
info() {
|
|
8802
9945
|
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 };
|
|
9946
|
+
const addresses = listening && material ? lanAddresses(listening.host).map((address2) => `https://${address2.includes(":") ? `[${address2}]` : address2}:${listening.port}`) : [];
|
|
9947
|
+
return { https: !!material, allowHttp: this.allowHttp(), addresses, fingerprint: material?.fingerprint ?? null, fingerprintSha1: material?.fingerprintSha1 ?? null };
|
|
8805
9948
|
}
|
|
8806
9949
|
/** The root certificate (DER) for devices to install, when serving HTTPS. */
|
|
8807
9950
|
rootCertificate() {
|
|
@@ -8922,6 +10065,123 @@ var SecretService = class {
|
|
|
8922
10065
|
}
|
|
8923
10066
|
};
|
|
8924
10067
|
|
|
10068
|
+
// src/server/service/tasks.ts
|
|
10069
|
+
var MAX_WAIT_MS = 6e4;
|
|
10070
|
+
var TaskService = class {
|
|
10071
|
+
constructor(store, chats, wake) {
|
|
10072
|
+
this.store = store;
|
|
10073
|
+
this.chats = chats;
|
|
10074
|
+
this.wake = wake;
|
|
10075
|
+
}
|
|
10076
|
+
timer;
|
|
10077
|
+
stopped = false;
|
|
10078
|
+
/** Where a due task is told: the DM between the Bot and its owner, made when first needed. */
|
|
10079
|
+
dm(botId) {
|
|
10080
|
+
const bot = this.store.getBot(botId);
|
|
10081
|
+
if (!bot) throw new ChatError(404, "Bot not found");
|
|
10082
|
+
return this.chats.openDirect(botId, this.store.userMemberId()).id;
|
|
10083
|
+
}
|
|
10084
|
+
/** Checked against what a Bot and its owner can both mean: an existing schedule the Bot can compute. `previous`:
|
|
10085
|
+
* the task's schedule before; an interval left as it was keeps counting from when it was set, a new one from now. */
|
|
10086
|
+
schedule(value2, previous) {
|
|
10087
|
+
if (!value2 || typeof value2 !== "object" || Array.isArray(value2)) throw new ValidationError("A schedule is needed", "task_schedule_invalid");
|
|
10088
|
+
const input = value2;
|
|
10089
|
+
const clock = (hour, minute) => {
|
|
10090
|
+
if (!Number.isInteger(hour) || !Number.isInteger(minute) || hour < 0 || hour > 23 || minute < 0 || minute > 59)
|
|
10091
|
+
throw new ValidationError("Hour is 0-23 and minute 0-59", "task_schedule_invalid");
|
|
10092
|
+
return { hour, minute };
|
|
10093
|
+
};
|
|
10094
|
+
switch (input.kind) {
|
|
10095
|
+
case "once": {
|
|
10096
|
+
const at2 = typeof input.at === "string" ? Date.parse(input.at) : input.at;
|
|
10097
|
+
if (!Number.isSafeInteger(at2)) throw new ValidationError("A one-off task needs a time", "task_schedule_invalid");
|
|
10098
|
+
return { kind: "once", at: at2 };
|
|
10099
|
+
}
|
|
10100
|
+
case "every": {
|
|
10101
|
+
if (!Number.isInteger(input.minutes) || input.minutes < TASK_MINUTES_MIN || input.minutes > 60 * 24 * 30)
|
|
10102
|
+
throw new ValidationError(`Every ${TASK_MINUTES_MIN} minutes at the least, a month at most`, "task_schedule_invalid");
|
|
10103
|
+
const minutes = input.minutes;
|
|
10104
|
+
return { kind: "every", minutes, since: previous?.kind === "every" && previous.minutes === minutes ? previous.since : Date.now() };
|
|
10105
|
+
}
|
|
10106
|
+
case "daily":
|
|
10107
|
+
return { kind: "daily", ...clock(input.hour, input.minute) };
|
|
10108
|
+
case "weekly": {
|
|
10109
|
+
if (!Number.isInteger(input.weekday) || input.weekday < 0 || input.weekday > 6)
|
|
10110
|
+
throw new ValidationError("Weekday is 0 (Sunday) to 6", "task_schedule_invalid");
|
|
10111
|
+
return { kind: "weekly", weekday: input.weekday, ...clock(input.hour, input.minute) };
|
|
10112
|
+
}
|
|
10113
|
+
default:
|
|
10114
|
+
throw new ValidationError("Schedule kind is once, every, daily or weekly", "task_schedule_invalid");
|
|
10115
|
+
}
|
|
10116
|
+
}
|
|
10117
|
+
text(value2, label, max) {
|
|
10118
|
+
if (typeof value2 !== "string" || !value2.trim() || value2.length > max || value2.includes("\0")) throw new ValidationError(`${label} must be 1-${max} characters`, "task_invalid");
|
|
10119
|
+
return value2.trim();
|
|
10120
|
+
}
|
|
10121
|
+
list(botId) {
|
|
10122
|
+
return this.store.tasks.list(botId);
|
|
10123
|
+
}
|
|
10124
|
+
get(botId, id) {
|
|
10125
|
+
return this.store.tasks.get(botId, id);
|
|
10126
|
+
}
|
|
10127
|
+
create(botId, input) {
|
|
10128
|
+
if (!this.store.getBot(botId)) throw new ChatError(404, "Bot not found");
|
|
10129
|
+
const task = this.store.tasks.create({
|
|
10130
|
+
botId,
|
|
10131
|
+
name: this.text(input.name, "A name", 80),
|
|
10132
|
+
instruction: this.text(input.instruction, "An instruction", 8e3),
|
|
10133
|
+
schedule: this.schedule(input.schedule)
|
|
10134
|
+
});
|
|
10135
|
+
this.tick();
|
|
10136
|
+
return task;
|
|
10137
|
+
}
|
|
10138
|
+
update(botId, id, input) {
|
|
10139
|
+
const before = this.store.tasks.get(botId, id);
|
|
10140
|
+
if (!before) throw new ChatError(404, "Task not found");
|
|
10141
|
+
const task = this.store.tasks.update(botId, id, {
|
|
10142
|
+
...input.name === void 0 ? {} : { name: this.text(input.name, "A name", 80) },
|
|
10143
|
+
...input.instruction === void 0 ? {} : { instruction: this.text(input.instruction, "An instruction", 8e3) },
|
|
10144
|
+
...input.schedule === void 0 ? {} : { schedule: this.schedule(input.schedule, before.schedule) }
|
|
10145
|
+
});
|
|
10146
|
+
this.tick();
|
|
10147
|
+
return task;
|
|
10148
|
+
}
|
|
10149
|
+
setState(botId, id, state) {
|
|
10150
|
+
const task = this.store.tasks.setState(botId, id, state);
|
|
10151
|
+
if (!task) throw new ChatError(404, "Task not found");
|
|
10152
|
+
this.tick();
|
|
10153
|
+
return task;
|
|
10154
|
+
}
|
|
10155
|
+
remove(botId, id) {
|
|
10156
|
+
if (!this.store.tasks.remove(botId, id)) throw new ChatError(404, "Task not found");
|
|
10157
|
+
this.tick();
|
|
10158
|
+
}
|
|
10159
|
+
/** Starts the timer. Runs missed while the server was not running are skipped, not made up (A58): each task moves on to
|
|
10160
|
+
* its next time after now, and a one-off already past is done. */
|
|
10161
|
+
start() {
|
|
10162
|
+
this.stopped = false;
|
|
10163
|
+
this.store.tasks.skipOverdue();
|
|
10164
|
+
this.tick();
|
|
10165
|
+
}
|
|
10166
|
+
stop() {
|
|
10167
|
+
this.stopped = true;
|
|
10168
|
+
if (this.timer) clearTimeout(this.timer);
|
|
10169
|
+
this.timer = void 0;
|
|
10170
|
+
}
|
|
10171
|
+
/** One pass: fire what is due, then sleep until the next one (or a minute, so a clock change is noticed). */
|
|
10172
|
+
tick() {
|
|
10173
|
+
if (this.stopped) return;
|
|
10174
|
+
if (this.timer) clearTimeout(this.timer);
|
|
10175
|
+
this.timer = void 0;
|
|
10176
|
+
const dms = new Map(this.store.tasks.dueBots().map((botId) => [botId, this.dm(botId)]));
|
|
10177
|
+
for (const fired of this.store.tasks.fire(dms)) this.wake(fired.botId);
|
|
10178
|
+
const soonest = this.store.tasks.soonest();
|
|
10179
|
+
const wait = soonest === null ? MAX_WAIT_MS : Math.min(MAX_WAIT_MS, Math.max(1e3, soonest - Date.now()));
|
|
10180
|
+
this.timer = setTimeout(() => this.tick(), wait);
|
|
10181
|
+
this.timer.unref?.();
|
|
10182
|
+
}
|
|
10183
|
+
};
|
|
10184
|
+
|
|
8925
10185
|
// src/server/application.ts
|
|
8926
10186
|
var Application = class _Application {
|
|
8927
10187
|
constructor(store, dataDir, release, factory) {
|
|
@@ -8945,12 +10205,14 @@ var Application = class _Application {
|
|
|
8945
10205
|
this.files = new ChatFiles(this.chats);
|
|
8946
10206
|
store.runtime.recover();
|
|
8947
10207
|
this.secrets = new SecretService(store, this.chats);
|
|
10208
|
+
this.tasks = new TaskService(store, this.chats, (botId) => this.bots.deliver(botId, urgencyOf("task", "schedule")));
|
|
8948
10209
|
this.bots = new BotService({
|
|
8949
10210
|
store,
|
|
8950
10211
|
chats: this.chats,
|
|
8951
10212
|
files: this.files,
|
|
8952
10213
|
credentials: this.credentials,
|
|
8953
10214
|
secrets: this.secrets,
|
|
10215
|
+
tasks: this.tasks,
|
|
8954
10216
|
changed: (id) => this.events.publish({ kind: "bot", id, status: this.bots.status(id) }),
|
|
8955
10217
|
listChanged: () => this.events.publish({ kind: "bots" }),
|
|
8956
10218
|
factory
|
|
@@ -8971,9 +10233,11 @@ var Application = class _Application {
|
|
|
8971
10233
|
models;
|
|
8972
10234
|
connection;
|
|
8973
10235
|
secrets;
|
|
10236
|
+
/** Scheduled tasks and their timer (A58). */
|
|
10237
|
+
tasks;
|
|
8974
10238
|
closing;
|
|
8975
10239
|
static async open(dataDir, options = {}) {
|
|
8976
|
-
await
|
|
10240
|
+
await mkdir7(resolve3(dataDir), { recursive: true, mode: 448 });
|
|
8977
10241
|
const root = await realpath(resolve3(dataDir));
|
|
8978
10242
|
let app;
|
|
8979
10243
|
let release;
|
|
@@ -8982,7 +10246,7 @@ var Application = class _Application {
|
|
|
8982
10246
|
retries: 0,
|
|
8983
10247
|
stale: 3e4,
|
|
8984
10248
|
update: 1e4,
|
|
8985
|
-
lockfilePath:
|
|
10249
|
+
lockfilePath: join14(root, ".runtime.lock"),
|
|
8986
10250
|
onCompromised: () => {
|
|
8987
10251
|
console.error("[runtime] data lock compromised");
|
|
8988
10252
|
options.onCompromised?.();
|
|
@@ -9005,10 +10269,12 @@ var Application = class _Application {
|
|
|
9005
10269
|
}
|
|
9006
10270
|
start() {
|
|
9007
10271
|
this.bots.start();
|
|
10272
|
+
this.tasks.start();
|
|
9008
10273
|
this.models.refreshCatalog();
|
|
9009
10274
|
}
|
|
9010
10275
|
close() {
|
|
9011
10276
|
if (!this.closing) this.closing = (async () => {
|
|
10277
|
+
this.tasks.stop();
|
|
9012
10278
|
await this.auth.close();
|
|
9013
10279
|
await this.bots.close();
|
|
9014
10280
|
await this.credentials.close();
|
|
@@ -9021,17 +10287,17 @@ var Application = class _Application {
|
|
|
9021
10287
|
};
|
|
9022
10288
|
|
|
9023
10289
|
// 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) =>
|
|
10290
|
+
import { spawn as spawn3 } from "node:child_process";
|
|
10291
|
+
import { closeSync, mkdirSync as mkdirSync6, openSync, readFileSync as readFileSync5, rmSync as rmSync3, writeFileSync as writeFileSync4 } from "node:fs";
|
|
10292
|
+
import { join as join15, resolve as resolve4 } from "node:path";
|
|
10293
|
+
var pidPath = (dir) => join15(resolve4(dir), "jarvy.pid");
|
|
10294
|
+
var logPath = (dir) => join15(resolve4(dir), "jarvy.log");
|
|
9029
10295
|
function writePidFile(dir, url) {
|
|
9030
10296
|
const record3 = { pid: process.pid, startToken: startToken(process.pid), url, startedAt: Date.now() };
|
|
9031
|
-
|
|
10297
|
+
writeFileSync4(pidPath(dir), JSON.stringify(record3), { mode: 384 });
|
|
9032
10298
|
}
|
|
9033
10299
|
function removePidFile(dir) {
|
|
9034
|
-
if (readRecord(dir)?.pid === process.pid)
|
|
10300
|
+
if (readRecord(dir)?.pid === process.pid) rmSync3(pidPath(dir), { force: true });
|
|
9035
10301
|
}
|
|
9036
10302
|
function readRecord(dir) {
|
|
9037
10303
|
try {
|
|
@@ -9060,15 +10326,15 @@ function runningServer(dir) {
|
|
|
9060
10326
|
if (!record3) return null;
|
|
9061
10327
|
const token = alive(record3.pid) ? startToken(record3.pid) : null;
|
|
9062
10328
|
if (token === null || token && record3.startToken && token !== record3.startToken) {
|
|
9063
|
-
|
|
10329
|
+
rmSync3(pidPath(dir), { force: true });
|
|
9064
10330
|
return null;
|
|
9065
10331
|
}
|
|
9066
10332
|
return record3;
|
|
9067
10333
|
}
|
|
9068
10334
|
function startBackground(script, runArgs, dir) {
|
|
9069
|
-
|
|
10335
|
+
mkdirSync6(resolve4(dir), { recursive: true, mode: 448 });
|
|
9070
10336
|
const log = openSync(logPath(dir), "a", 384);
|
|
9071
|
-
const child =
|
|
10337
|
+
const child = spawn3(process.execPath, [...process.execArgv, script, "run", ...runArgs], { detached: true, stdio: ["ignore", log, log, "ipc"] });
|
|
9072
10338
|
closeSync(log);
|
|
9073
10339
|
return new Promise((resolvePromise, reject) => {
|
|
9074
10340
|
child.once("error", reject);
|
|
@@ -9101,7 +10367,7 @@ async function stopServer(dir, waitMs = 3e4) {
|
|
|
9101
10367
|
process.kill(record3.pid, "SIGTERM");
|
|
9102
10368
|
for (const deadline = Date.now() + waitMs; Date.now() < deadline; ) {
|
|
9103
10369
|
if (!alive(record3.pid)) {
|
|
9104
|
-
|
|
10370
|
+
rmSync3(pidPath(dir), { force: true });
|
|
9105
10371
|
return "stopped";
|
|
9106
10372
|
}
|
|
9107
10373
|
await new Promise((done) => setTimeout(done, 200));
|
|
@@ -9169,7 +10435,7 @@ async function initialize(store) {
|
|
|
9169
10435
|
}
|
|
9170
10436
|
function reachNotes(host, port, allowHttp) {
|
|
9171
10437
|
if (isLoopbackHost(host)) return { info: [], warning: null };
|
|
9172
|
-
const info = lanAddresses(host).map((
|
|
10438
|
+
const info = lanAddresses(host).map((address2) => `On your network: https://${address2.includes(":") ? `[${address2}]` : address2}:${port}`);
|
|
9173
10439
|
info.push("To trust Jarvy on other devices, install its certificate from Settings \u2192 Network.");
|
|
9174
10440
|
return { info, warning: allowHttp ? "WARNING: HTTP access is allowed in Settings \u2192 Network, so passwords and conversations reach other devices unencrypted." : null };
|
|
9175
10441
|
}
|
|
@@ -9249,8 +10515,8 @@ async function main() {
|
|
|
9249
10515
|
try {
|
|
9250
10516
|
if (!store.isInitialized()) await initialize(store);
|
|
9251
10517
|
const { host, port, url } = listenOptions(args);
|
|
9252
|
-
const webRoot =
|
|
9253
|
-
if (!existsSync5(
|
|
10518
|
+
const webRoot = join16(dirname10(fileURLToPath2(import.meta.url)), "../web");
|
|
10519
|
+
if (!existsSync5(join16(webRoot, "index.html"))) throw new Error("Web build missing. Run npm run build first");
|
|
9254
10520
|
const server = createJarvyServer(app, webRoot, app.connection.prepare(host, port));
|
|
9255
10521
|
closeHttp = () => server.close();
|
|
9256
10522
|
await server.listen(port, host);
|