@goodfish/jarvy 0.4.0 → 0.6.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.
@@ -3,7 +3,7 @@
3
3
  // src/server/cli.ts
4
4
  import { existsSync as existsSync2 } from "node:fs";
5
5
  import { createInterface } from "node:readline/promises";
6
- import { dirname as dirname6, join as join9 } from "node:path";
6
+ import { dirname as dirname8, join as join11 } from "node:path";
7
7
  import { fileURLToPath } from "node:url";
8
8
 
9
9
  // src/server/api/server.ts
@@ -12,7 +12,7 @@ import { getRequestListener } from "@hono/node-server";
12
12
 
13
13
  // src/server/api/app.ts
14
14
  import { Hono as Hono10 } from "hono";
15
- import { HTTPException as HTTPException9 } from "hono/http-exception";
15
+ import { HTTPException as HTTPException10 } from "hono/http-exception";
16
16
  import { serveStatic } from "@hono/node-server/serve-static";
17
17
 
18
18
  // src/server/api/middleware/security.ts
@@ -184,6 +184,7 @@ function installAuthentication(api, account) {
184
184
 
185
185
  // src/server/api/routes/bots.ts
186
186
  import { Hono as Hono4 } from "hono";
187
+ import { HTTPException as HTTPException7 } from "hono/http-exception";
187
188
 
188
189
  // src/server/api/views/bot-status.ts
189
190
  function botStatusView(status) {
@@ -379,7 +380,18 @@ function botRoutes(bots) {
379
380
  routes.get("/", (c) => c.json({ bots: bots.list() }));
380
381
  routes.post("/", jsonBody, async (c) => {
381
382
  const input = await readJson(c);
382
- return c.json(bots.create(textField(input, "name")), 201);
383
+ return c.json(bots.create(textField(input, "name"), input.environment === void 0 ? void 0 : environmentKind(input.environment)), 201);
384
+ });
385
+ routes.put("/:id/environment", jsonBody, async (c) => {
386
+ const input = await readJson(c), id = c.req.param("id");
387
+ if (input.hostGranted === void 0) return c.json(bots.setHome(id, environmentKind(input.home)));
388
+ if (typeof input.hostGranted !== "boolean") throw new HTTPException7(400, { message: "hostGranted must be true or false" });
389
+ return c.json(bots.grantHost(id, input.hostGranted));
390
+ });
391
+ routes.post("/:id/computer/:action", async (c) => {
392
+ const action = c.req.param("action");
393
+ if (action !== "start" && action !== "stop" && action !== "reset") throw new HTTPException7(404, { message: "Not found" });
394
+ return c.json(await bots.computer(c.req.param("id"), action));
383
395
  });
384
396
  routes.get("/:id", (c) => c.json(bots.describe(c.req.param("id"))));
385
397
  routes.delete("/:id", async (c) => {
@@ -428,10 +440,14 @@ function botRoutes(bots) {
428
440
  routes.route("/", activityRoutes(bots));
429
441
  return routes;
430
442
  }
443
+ function environmentKind(value2) {
444
+ if (value2 !== "computer" && value2 !== "host") throw new HTTPException7(400, { message: "environment must be computer or host" });
445
+ return value2;
446
+ }
431
447
 
432
448
  // src/server/api/routes/chats.ts
433
449
  import { Hono as Hono5 } from "hono";
434
- import { HTTPException as HTTPException7 } from "hono/http-exception";
450
+ import { HTTPException as HTTPException8 } from "hono/http-exception";
435
451
 
436
452
  // src/server/api/views/download.ts
437
453
  var MEDIA_TYPES = /* @__PURE__ */ new Set(["video/webm", "video/mp4", "video/quicktime", "audio/mpeg", "audio/wav", "audio/x-wav", "audio/ogg", "audio/webm", "audio/mp4"]);
@@ -466,10 +482,10 @@ function chatRoutes({ chats, bots }) {
466
482
  const page = chats.readBefore(c.get("actorId"), c.req.param("id"), before === void 0 ? null : Number(before), Number(tail ?? c.req.query("limit") ?? 50));
467
483
  return c.json({ messages: page.messages.map(messageView), hasOlder: page.hasOlder });
468
484
  }
469
- const after = Number(c.req.query("after") ?? 0);
485
+ const after2 = Number(c.req.query("after") ?? 0);
470
486
  const limit = Number(c.req.query("limit") ?? 1e3);
471
- const messages = chats.read(c.get("actorId"), c.req.param("id"), after, limit);
472
- return c.json({ messages: messages.map(messageView), nextSequence: messages.at(-1)?.sequence ?? after, hasMore: messages.length === limit });
487
+ const messages = chats.read(c.get("actorId"), c.req.param("id"), after2, limit);
488
+ return c.json({ messages: messages.map(messageView), nextSequence: messages.at(-1)?.sequence ?? after2, hasMore: messages.length === limit });
473
489
  });
474
490
  routes.post("/:id/messages", jsonBody, async (c) => {
475
491
  const message = await chats.send(c.get("actorId"), c.req.param("id"), await readJson(c));
@@ -484,16 +500,16 @@ function chatRoutes({ chats, bots }) {
484
500
  try {
485
501
  name2 = decodeURIComponent(c.req.header("x-file-name") ?? "");
486
502
  } catch {
487
- throw new HTTPException7(400, { message: "Invalid filename" });
503
+ throw new HTTPException8(400, { message: "Invalid filename" });
488
504
  }
489
505
  const data = Buffer.from(await c.req.arrayBuffer());
490
- if (!data.length) throw new HTTPException7(400, { message: "Empty attachment" });
506
+ if (!data.length) throw new HTTPException8(400, { message: "Empty attachment" });
491
507
  const mime = (c.req.header("content-type") ?? "application/octet-stream").slice(0, 100);
492
508
  return c.json(chats.upload(c.get("actorId"), c.req.param("id"), name2, mime, data), 201);
493
509
  });
494
510
  routes.delete("/:id", async (c) => {
495
511
  const id = c.req.param("id");
496
- if (!await chats.delete(c.get("actorId"), id)) throw new HTTPException7(404, { message: "Chat not found" });
512
+ if (!await chats.delete(c.get("actorId"), id)) throw new HTTPException8(404, { message: "Chat not found" });
497
513
  await bots.chatDeleted(id);
498
514
  return c.json({ ok: true });
499
515
  });
@@ -503,7 +519,7 @@ function attachmentRoutes(chats) {
503
519
  const routes = new Hono5();
504
520
  routes.get("/:id", (c) => {
505
521
  const attachment = chats.download(c.get("actorId"), c.req.param("id"));
506
- if (!attachment) throw new HTTPException7(404, { message: "Attachment not found" });
522
+ if (!attachment) throw new HTTPException8(404, { message: "Attachment not found" });
507
523
  const size = attachment.data.length, range = byteRange(c.req.header("Range"), size);
508
524
  const headers2 = {
509
525
  "Content-Type": downloadType(attachment.file.mimeType),
@@ -520,14 +536,14 @@ function attachmentRoutes(chats) {
520
536
  });
521
537
  routes.get("/:id/run", (c) => {
522
538
  const attachment = chats.download(c.get("actorId"), c.req.param("id"));
523
- if (!attachment) throw new HTTPException7(404, { message: "Attachment not found" });
539
+ if (!attachment) throw new HTTPException8(404, { message: "Attachment not found" });
524
540
  const { name: name2, mimeType } = attachment.file;
525
- if (!/\.html?$/i.test(name2) && mimeType !== "text/html") throw new HTTPException7(415, { message: "Only HTML attachments can run" });
541
+ if (!/\.html?$/i.test(name2) && mimeType !== "text/html") throw new HTTPException8(415, { message: "Only HTML attachments can run" });
526
542
  c.set("sandboxedDocument", true);
527
543
  return c.body(new Uint8Array(attachment.data), 200, { "Content-Type": "text/html; charset=utf-8", "Content-Length": String(attachment.data.length) });
528
544
  });
529
545
  routes.delete("/:id", (c) => {
530
- if (!chats.removePendingAttachment(c.get("actorId"), c.req.param("id"))) throw new HTTPException7(404, { message: "Pending attachment not found" });
546
+ if (!chats.removePendingAttachment(c.get("actorId"), c.req.param("id"))) throw new HTTPException8(404, { message: "Pending attachment not found" });
531
547
  return c.json({ ok: true });
532
548
  });
533
549
  return routes;
@@ -607,6 +623,7 @@ data: ${JSON.stringify(data)}
607
623
  if (change.kind === "sections" && change.ownerId === actorId) send("sections", {});
608
624
  if (change.kind === "inbox" && change.memberId === actorId) send("inbox", {});
609
625
  if (change.kind === "bot" && bots.owns(change.id)) send("bot", { botId: change.id, status: botStatusView(change.status) });
626
+ if (change.kind === "bots") send("bots", {});
610
627
  }, () => disconnect());
611
628
  const heartbeat = setInterval(() => {
612
629
  if (!account.authenticated(token)) disconnect();
@@ -637,13 +654,13 @@ data: ${JSON.stringify(data)}
637
654
 
638
655
  // src/server/api/routes/channels.ts
639
656
  import { Hono as Hono8 } from "hono";
640
- import { HTTPException as HTTPException8 } from "hono/http-exception";
657
+ import { HTTPException as HTTPException9 } from "hono/http-exception";
641
658
  function channelRoutes({ chats }) {
642
659
  const routes = new Hono8();
643
660
  const channels = chats.channels;
644
661
  const chat = (actorId, id) => {
645
662
  const found = chats.get(actorId, id);
646
- if (!found) throw new HTTPException8(404, { message: "Chat not found" });
663
+ if (!found) throw new HTTPException9(404, { message: "Chat not found" });
647
664
  return chatView(found, (member) => chats.member(member));
648
665
  };
649
666
  routes.get("/members", (c) => c.json({ members: channels.everyone(c.get("actorId")) }));
@@ -664,7 +681,7 @@ function channelRoutes({ chats }) {
664
681
  routes.post("/sections", jsonBody, async (c) => c.json(channels.createSection(c.get("actorId"), await readJson(c)), 201));
665
682
  routes.patch("/sections/:id", jsonBody, async (c) => c.json(channels.updateSection(c.get("actorId"), c.req.param("id"), await readJson(c))));
666
683
  routes.delete("/sections/:id", (c) => {
667
- if (!channels.deleteSection(c.get("actorId"), c.req.param("id"))) throw new HTTPException8(404, { message: "Section not found" });
684
+ if (!channels.deleteSection(c.get("actorId"), c.req.param("id"))) throw new HTTPException9(404, { message: "Section not found" });
668
685
  return c.json({ ok: true });
669
686
  });
670
687
  routes.post("/channels", jsonBody, async (c) => {
@@ -680,7 +697,7 @@ function channelRoutes({ chats }) {
680
697
  routes.get("/chats/:id", (c) => c.json(chat(c.get("actorId"), c.req.param("id"))));
681
698
  routes.get("/messages/:id", (c) => {
682
699
  const found = chats.message(c.get("actorId"), c.req.param("id"));
683
- if (!found) throw new HTTPException8(404, { message: "Message not found" });
700
+ if (!found) throw new HTTPException9(404, { message: "Message not found" });
684
701
  return c.json(messageView(found));
685
702
  });
686
703
  routes.post("/messages/:id/thread", (c) => {
@@ -785,7 +802,7 @@ function createHttpApp(services, webRoot) {
785
802
  api.route("/", eventRoutes(services));
786
803
  api.route("/", liveRoutes(services));
787
804
  api.all("*", () => {
788
- throw new HTTPException9(404, { message: "API route not found" });
805
+ throw new HTTPException10(404, { message: "API route not found" });
789
806
  });
790
807
  app.route("/api", api);
791
808
  app.get("*", serveStatic({ root: webRoot, onFound: (path, c) => {
@@ -795,8 +812,8 @@ function createHttpApp(services, webRoot) {
795
812
  c.header("Cache-Control", "no-store");
796
813
  } }));
797
814
  app.all("*", (c) => {
798
- if (c.req.method !== "GET" && c.req.method !== "HEAD") throw new HTTPException9(405, { message: "Method not allowed" });
799
- throw new HTTPException9(404, { message: "Web assets missing; run npm run build" });
815
+ if (c.req.method !== "GET" && c.req.method !== "HEAD") throw new HTTPException10(405, { message: "Method not allowed" });
816
+ throw new HTTPException10(404, { message: "Web assets missing; run npm run build" });
800
817
  });
801
818
  return app;
802
819
  }
@@ -809,12 +826,13 @@ function createJarvyServer(services, webRoot) {
809
826
 
810
827
  // src/server/shared/models.ts
811
828
  var BOT_COLORS = ["gray", "blue", "green", "amber", "rose", "violet"];
829
+ var AGENT_BOT_ID = "00000000-0000-4000-8000-000000000001";
812
830
 
813
831
  // src/server/infra/storage/store.ts
814
- import { createHash, randomBytes, randomInt as randomInt2, randomUUID as randomUUID7, scryptSync, timingSafeEqual } from "node:crypto";
832
+ import { createHash, randomBytes, randomInt as randomInt3, randomUUID as randomUUID8, scryptSync, timingSafeEqual } from "node:crypto";
815
833
  import { chmodSync, mkdirSync } from "node:fs";
816
834
  import { homedir } from "node:os";
817
- import { dirname, join, resolve } from "node:path";
835
+ import { dirname as dirname2, join as join2, resolve } from "node:path";
818
836
  import { DatabaseSync } from "node:sqlite";
819
837
 
820
838
  // src/server/infra/storage/migrations/composition-schema.ts
@@ -936,13 +954,13 @@ function migrateChannels(db) {
936
954
  SELECT cm.chat_id, cm.member_id, COALESCE((SELECT MAX(sequence) FROM messages m WHERE m.chat_id=cm.chat_id), 0) FROM chat_members cm;
937
955
  `);
938
956
  const taken = new Set(db.prepare("SELECT username FROM account").all().map((row) => row.username.toLowerCase()));
939
- const rename2 = db.prepare("UPDATE bots SET name=? WHERE id=?");
957
+ const rename3 = db.prepare("UPDATE bots SET name=? WHERE id=?");
940
958
  for (const bot of db.prepare("SELECT id,name FROM bots ORDER BY created_at,id").all()) {
941
959
  const base = bot.name.replace(/@/g, "").trim() || "Bot";
942
960
  let name2 = base;
943
961
  for (let suffix = 2; taken.has(name2.toLowerCase()); suffix++) name2 = `${base} ${suffix}`;
944
962
  taken.add(name2.toLowerCase());
945
- if (name2 !== bot.name) rename2.run(name2, bot.id);
963
+ if (name2 !== bot.name) rename3.run(name2, bot.id);
946
964
  }
947
965
  db.exec("PRAGMA user_version=12; COMMIT;");
948
966
  } catch (error) {
@@ -1083,12 +1101,76 @@ function migrateTitles(db) {
1083
1101
  }
1084
1102
  }
1085
1103
 
1104
+ // src/server/infra/storage/migrations/agent-schema.ts
1105
+ function migrateAgent(db) {
1106
+ const version = db.prepare("PRAGMA user_version").get().user_version;
1107
+ if (version >= 19) return;
1108
+ db.exec("PRAGMA foreign_keys=OFF; BEGIN IMMEDIATE");
1109
+ try {
1110
+ const sequence = db.prepare("SELECT seq FROM sqlite_sequence WHERE name='inbox'").get()?.seq ?? 0;
1111
+ db.exec(`
1112
+ CREATE TABLE settings(key TEXT PRIMARY KEY, value TEXT NOT NULL);
1113
+ CREATE TABLE inbox_next(id INTEGER PRIMARY KEY AUTOINCREMENT, member_id TEXT NOT NULL REFERENCES members(id),
1114
+ kind TEXT NOT NULL CHECK(kind IN ('message','reaction','note','onboard','change')), reason TEXT NOT NULL,
1115
+ urgency TEXT NOT NULL CHECK(urgency IN ('interrupt','queue','digest')),
1116
+ chat_id TEXT NOT NULL REFERENCES chats(id) ON DELETE CASCADE, message_id TEXT REFERENCES messages(id) ON DELETE CASCADE,
1117
+ actor_id TEXT REFERENCES members(id), detail TEXT,
1118
+ state TEXT NOT NULL DEFAULT 'pending' CHECK(state IN ('pending','claimed','told','done','failed','interrupted','unknown','cancelled')),
1119
+ run_id TEXT REFERENCES bot_runs(id), created_at INTEGER NOT NULL, told_at INTEGER);
1120
+ INSERT INTO inbox_next(id,member_id,kind,reason,urgency,chat_id,message_id,actor_id,detail,state,run_id,created_at,told_at)
1121
+ 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;
1122
+ DROP TABLE inbox;
1123
+ ALTER TABLE inbox_next RENAME TO inbox;
1124
+ CREATE UNIQUE INDEX inbox_one_message ON inbox(member_id, message_id) WHERE kind='message';
1125
+ CREATE INDEX inbox_open ON inbox(member_id, state, id);
1126
+ CREATE INDEX inbox_run ON inbox(run_id) WHERE run_id IS NOT NULL;
1127
+ `);
1128
+ db.prepare("UPDATE sqlite_sequence SET seq=MAX(seq,?) WHERE name='inbox'").run(sequence);
1129
+ db.prepare(`INSERT INTO settings(key,value) SELECT 'agent_onboarded',? FROM bots
1130
+ WHERE id=? AND deleted_at IS NULL AND connection_id IS NOT NULL`).run(String(Date.now()), AGENT_BOT_ID);
1131
+ db.exec("PRAGMA user_version=19");
1132
+ if (db.prepare("PRAGMA foreign_key_check").all().length) throw new Error("Migration has invalid references");
1133
+ db.exec("COMMIT");
1134
+ } catch (error) {
1135
+ db.exec("ROLLBACK");
1136
+ throw error;
1137
+ } finally {
1138
+ db.exec("PRAGMA foreign_keys=ON");
1139
+ }
1140
+ }
1141
+
1142
+ // src/server/infra/storage/migrations/environment-schema.ts
1143
+ function migrateEnvironments(db) {
1144
+ const version = db.prepare("PRAGMA user_version").get().user_version;
1145
+ if (version >= 20) return;
1146
+ db.exec("BEGIN IMMEDIATE");
1147
+ try {
1148
+ db.exec(`
1149
+ CREATE TABLE environments(id TEXT PRIMARY KEY, owner_id TEXT NOT NULL REFERENCES members(id),
1150
+ kind TEXT NOT NULL CHECK(kind IN ('host','computer')), name TEXT NOT NULL DEFAULT '',
1151
+ connection TEXT NOT NULL DEFAULT '{}', directory TEXT, bot_id TEXT REFERENCES bots(id),
1152
+ created_at INTEGER NOT NULL, deleted_at INTEGER);
1153
+ CREATE UNIQUE INDEX environments_host ON environments(owner_id) WHERE kind='host' AND deleted_at IS NULL;
1154
+ CREATE UNIQUE INDEX environments_computer ON environments(bot_id) WHERE kind='computer' AND deleted_at IS NULL;
1155
+ CREATE TABLE environment_grants(environment_id TEXT NOT NULL REFERENCES environments(id),
1156
+ bot_id TEXT NOT NULL REFERENCES bots(id), granted_by TEXT NOT NULL REFERENCES members(id),
1157
+ granted_at INTEGER NOT NULL, PRIMARY KEY(environment_id, bot_id));
1158
+ ALTER TABLE bots ADD COLUMN home_environment_id TEXT REFERENCES environments(id);
1159
+ ALTER TABLE bots ADD COLUMN current_environment_id TEXT REFERENCES environments(id);
1160
+ PRAGMA user_version=20;
1161
+ `);
1162
+ db.exec("COMMIT");
1163
+ } catch (error) {
1164
+ db.exec("ROLLBACK");
1165
+ throw error;
1166
+ }
1167
+ }
1168
+
1086
1169
  // src/server/infra/storage/migrations/schema.ts
1087
1170
  import { randomUUID as randomUUID2 } from "node:crypto";
1088
1171
 
1089
1172
  // src/server/infra/storage/migrations/prototype-schema.ts
1090
1173
  import { randomUUID } from "node:crypto";
1091
- var DEFAULT_BOT_ID = "00000000-0000-4000-8000-000000000001";
1092
1174
  function migratePrototype(db) {
1093
1175
  const version = db.prepare("PRAGMA user_version").get().user_version;
1094
1176
  if (version >= 4) return;
@@ -1121,9 +1203,9 @@ function migratePrototype(db) {
1121
1203
  system_prompt TEXT NOT NULL, model_id TEXT NOT NULL, avatar_mime TEXT, avatar_data BLOB,
1122
1204
  avatar_updated_at INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL);
1123
1205
  INSERT INTO bots(id,name,description,system_prompt,model_id,created_at)
1124
- SELECT '${DEFAULT_BOT_ID}',name,'',system_prompt,'',0 FROM bot_settings WHERE id=1;
1206
+ SELECT '${AGENT_BOT_ID}',name,'',system_prompt,'',0 FROM bot_settings WHERE id=1;
1125
1207
  ALTER TABLE chats ADD COLUMN bot_id TEXT REFERENCES bots(id);
1126
- UPDATE chats SET bot_id='${DEFAULT_BOT_ID}';
1208
+ UPDATE chats SET bot_id='${AGENT_BOT_ID}';
1127
1209
  CREATE INDEX chat_by_bot ON chats(bot_id,updated_at DESC);
1128
1210
  CREATE TABLE attachments (id TEXT PRIMARY KEY, chat_id TEXT NOT NULL REFERENCES chats(id) ON DELETE CASCADE,
1129
1211
  message_id TEXT REFERENCES messages(id) ON DELETE CASCADE, name TEXT NOT NULL,
@@ -1206,7 +1288,7 @@ function migrateRuntime(db) {
1206
1288
  // src/server/infra/storage/migrations/schema.ts
1207
1289
  function migrateDatabase(db) {
1208
1290
  const version = db.prepare("PRAGMA user_version").get().user_version;
1209
- if (version > 18) throw new Error(`Unsupported database version: ${version}`);
1291
+ if (version > 20) throw new Error(`Unsupported database version: ${version}`);
1210
1292
  migratePrototype(db);
1211
1293
  if (version >= 5) {
1212
1294
  migrateRuntime(db);
@@ -1222,6 +1304,8 @@ function migrateDatabase(db) {
1222
1304
  migrateInbox(db);
1223
1305
  migrateQuotes(db);
1224
1306
  migrateTitles(db);
1307
+ migrateAgent(db);
1308
+ migrateEnvironments(db);
1225
1309
  return;
1226
1310
  }
1227
1311
  db.exec("PRAGMA foreign_keys=OFF; BEGIN IMMEDIATE");
@@ -1295,6 +1379,8 @@ function migrateDatabase(db) {
1295
1379
  migrateInbox(db);
1296
1380
  migrateQuotes(db);
1297
1381
  migrateTitles(db);
1382
+ migrateAgent(db);
1383
+ migrateEnvironments(db);
1298
1384
  }
1299
1385
 
1300
1386
  // src/server/infra/storage/chat-repository.ts
@@ -1618,7 +1704,7 @@ var InteractionRepository = class {
1618
1704
  }
1619
1705
  /** Notices the Bot has not been told, oldest first, in chats it can still read. */
1620
1706
  notices(botId) {
1621
- const rows2 = this.db.prepare(`SELECT n.id,n.kind,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
1707
+ 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
1622
1708
  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);
1623
1709
  return rows2.filter((row) => this.chats.canAccess(botId, row.chatId)).map(({ actorId, ...row }) => ({ ...row, actor: this.chats.member(actorId) }));
1624
1710
  }
@@ -1848,9 +1934,9 @@ var ChatRepository = class {
1848
1934
  AND (instr(lower(c.title),lower(?))>0 OR instr(lower(c.topic),lower(?))>0 OR instr(lower(COALESCE(n.text,'')),lower(?))>0)
1849
1935
  ORDER BY c.updated_at DESC LIMIT ?`).all(actorId, query, query, query, limit).map((row) => row.id);
1850
1936
  }
1851
- messages(actorId, chatId, after = 0, limit = 1e3) {
1937
+ messages(actorId, chatId, after2 = 0, limit = 1e3) {
1852
1938
  this.assertAccess(actorId, chatId);
1853
- const rows2 = this.db.prepare(`SELECT ${MESSAGE_COLUMNS} FROM messages WHERE chat_id=? AND sequence>? ORDER BY sequence LIMIT ?`).all(chatId, after, limit);
1939
+ const rows2 = this.db.prepare(`SELECT ${MESSAGE_COLUMNS} FROM messages WHERE chat_id=? AND sequence>? ORDER BY sequence LIMIT ?`).all(chatId, after2, limit);
1854
1940
  return rows2.map((row) => this.messageFromRow(row));
1855
1941
  }
1856
1942
  message(actorId, id) {
@@ -1874,7 +1960,7 @@ var ChatRepository = class {
1874
1960
  return rows2.map(({ historyId, toolCallId, ...row }) => ({ ...row, source: historyId === null ? null : { historyId, toolCallId } }));
1875
1961
  }
1876
1962
  messageFromRow(row) {
1877
- const attachments = this.db.prepare("SELECT id,name,mime_type AS mimeType,size FROM attachments WHERE message_id=? ORDER BY created_at,id").all(row.id);
1963
+ const attachments = this.db.prepare("SELECT id,name,mime_type AS mimeType,size FROM attachments WHERE message_id=? ORDER BY created_at,rowid").all(row.id);
1878
1964
  return {
1879
1965
  id: row.id,
1880
1966
  chatId: row.chatId,
@@ -2135,9 +2221,112 @@ var RuntimeRepository = class {
2135
2221
  };
2136
2222
 
2137
2223
  // src/server/infra/pi/catalog.ts
2138
- import { builtinProviders } from "@earendil-works/pi-ai/providers/all";
2139
- function providerCatalog() {
2140
- return builtinProviders().flatMap((provider) => {
2224
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
2225
+ import { dirname, join } from "node:path";
2226
+ import { builtinProviders, getBuiltinModelDataGeneratedAt } from "@earendil-works/pi-ai/providers/all";
2227
+ var CATALOG_URL = "https://pi.dev/api/models";
2228
+ var REFRESH_MS = 4 * 60 * 60 * 1e3;
2229
+ var TIMEOUT_MS = 15e3;
2230
+ var modelApis = new Set(builtinProviders().flatMap((provider) => provider.getModels().map((model) => model.api)));
2231
+ var ModelCatalog = class {
2232
+ path;
2233
+ kept;
2234
+ loaded = false;
2235
+ overlaid;
2236
+ loading;
2237
+ constructor(dataDir) {
2238
+ this.path = join(dataDir, "pi", "models.json");
2239
+ }
2240
+ providers() {
2241
+ this.refresh();
2242
+ return this.overlaid ?? builtinProviders();
2243
+ }
2244
+ /** Newer entries for one provider's models (a connection's catalog comes from Pi with its credentials). */
2245
+ overlay(providerId, models) {
2246
+ return merge(models, this.entries(providerId));
2247
+ }
2248
+ /** Loads the kept catalog, then checks pi.dev when the last check is 4 hours old; never waits. */
2249
+ refresh() {
2250
+ const due = process.env.PI_OFFLINE === void 0 && Date.now() - (this.kept?.checkedAt ?? 0) >= REFRESH_MS;
2251
+ if (this.loading || this.loaded && !due) return;
2252
+ this.loading = (async () => {
2253
+ if (!this.loaded) {
2254
+ this.kept = await this.read();
2255
+ this.loaded = true;
2256
+ this.apply();
2257
+ }
2258
+ if (!due) return;
2259
+ await this.fetch();
2260
+ this.apply();
2261
+ })().catch((error) => console.warn("[models] catalog refresh failed", { error: error instanceof Error ? error.message : String(error) })).finally(() => {
2262
+ this.loading = void 0;
2263
+ });
2264
+ }
2265
+ async fetch() {
2266
+ const previous = this.kept;
2267
+ const checkedAt = Date.now();
2268
+ try {
2269
+ const response = await fetch(CATALOG_URL, {
2270
+ signal: AbortSignal.timeout(TIMEOUT_MS),
2271
+ headers: { accept: "application/json", "user-agent": "Jarvy", ...previous?.etag ? { "if-none-match": previous.etag } : {} }
2272
+ });
2273
+ if (response.status === 304 && previous) {
2274
+ this.kept = { ...previous, checkedAt };
2275
+ return;
2276
+ }
2277
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
2278
+ const body = await response.json();
2279
+ if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error("not a catalog");
2280
+ const lastModified = Date.parse(response.headers.get("last-modified") ?? "");
2281
+ this.kept = { models: body, etag: response.headers.get("etag") ?? void 0, lastModified: Number.isNaN(lastModified) ? 0 : lastModified, checkedAt };
2282
+ } catch (error) {
2283
+ this.kept = { ...previous ?? { models: {}, lastModified: 0 }, checkedAt };
2284
+ throw error;
2285
+ } finally {
2286
+ await this.write().catch((error) => console.warn("[models] catalog not saved", { error: error instanceof Error ? error.message : String(error) }));
2287
+ }
2288
+ }
2289
+ apply() {
2290
+ this.overlaid = builtinProviders().map((provider) => {
2291
+ const entries = this.entries(provider.id);
2292
+ if (!entries.length) return provider;
2293
+ const models = merge(provider.getModels(), entries);
2294
+ return { ...provider, getModels: () => models };
2295
+ });
2296
+ }
2297
+ entries(providerId) {
2298
+ const kept = this.kept, generatedAt = getBuiltinModelDataGeneratedAt();
2299
+ if (!kept || generatedAt !== void 0 && kept.lastModified <= generatedAt) return [];
2300
+ const models = kept.models[providerId];
2301
+ if (!models || typeof models !== "object") return [];
2302
+ return Object.values(models).filter((model) => typeof model?.id === "string" && typeof model.api === "string" && modelApis.has(model.api) && (model.type === void 0 || model.type === "chat")).map((model) => ({ ...model, provider: providerId }));
2303
+ }
2304
+ async read() {
2305
+ try {
2306
+ const kept = JSON.parse(await readFile(this.path, "utf8"));
2307
+ return kept && typeof kept.models === "object" ? kept : void 0;
2308
+ } catch {
2309
+ return void 0;
2310
+ }
2311
+ }
2312
+ async write() {
2313
+ await mkdir(dirname(this.path), { recursive: true, mode: 448 });
2314
+ const next = `${this.path}.tmp`;
2315
+ await writeFile(next, JSON.stringify(this.kept), { mode: 384 });
2316
+ await rename(next, this.path);
2317
+ }
2318
+ };
2319
+ function merge(models, newer) {
2320
+ const merged = [...models];
2321
+ for (const model of newer) {
2322
+ const index = merged.findIndex((entry) => entry.id === model.id);
2323
+ if (index >= 0) merged[index] = model;
2324
+ else merged.push(model);
2325
+ }
2326
+ return merged;
2327
+ }
2328
+ function providerCatalog(providers) {
2329
+ return providers.flatMap((provider) => {
2141
2330
  const models = provider.getModels();
2142
2331
  if (!models.length && provider.getAllModels?.().length) return [];
2143
2332
  const common = { id: provider.id, api: models[0]?.api ?? "openai-completions", modelCount: models.length };
@@ -2147,7 +2336,6 @@ function providerCatalog() {
2147
2336
  ];
2148
2337
  }).sort((a, b) => a.name.localeCompare(b.name));
2149
2338
  }
2150
- var modelApis = new Set(builtinProviders().flatMap((provider) => provider.getModels().map((model) => model.api)));
2151
2339
 
2152
2340
  // src/server/infra/storage/credential-repository.ts
2153
2341
  function credentialValue(value2) {
@@ -2191,8 +2379,133 @@ var CredentialRepository = class {
2191
2379
  }
2192
2380
  };
2193
2381
 
2382
+ // src/server/infra/storage/agent-repository.ts
2383
+ import { randomInt as randomInt2 } from "node:crypto";
2384
+ var AgentRepository = class {
2385
+ constructor(db, ownerId) {
2386
+ this.db = db;
2387
+ this.ownerId = ownerId;
2388
+ }
2389
+ /** Brings the agent Bot back when it is missing (deleted before it became undeletable): the same id, the first free
2390
+ * name of Jarvy, Jarvy 2, …, no persona and no model, so the owner picks one and is welcomed again. */
2391
+ ensure() {
2392
+ const row = this.db.prepare("SELECT deleted_at AS deletedAt FROM bots WHERE id=?").get(AGENT_BOT_ID);
2393
+ if (row && row.deletedAt === null) return;
2394
+ const taken = this.db.prepare(`SELECT 1 FROM account WHERE lower(username)=lower(:name)
2395
+ UNION ALL SELECT 1 FROM bots WHERE lower(name)=lower(:name) AND deleted_at IS NULL LIMIT 1`);
2396
+ let name2 = "Jarvy";
2397
+ for (let n = 2; taken.get({ name: name2 }); n++) name2 = `Jarvy ${n}`;
2398
+ this.db.exec("BEGIN IMMEDIATE");
2399
+ try {
2400
+ if (row) this.db.prepare(`UPDATE bots SET deleted_at=NULL,name=?,description='',system_prompt='',connection_id=NULL,model_id='',thinking_level='auto'
2401
+ WHERE id=?`).run(name2, AGENT_BOT_ID);
2402
+ else {
2403
+ this.db.prepare("INSERT OR IGNORE INTO members VALUES(?,'bot')").run(AGENT_BOT_ID);
2404
+ this.db.prepare("INSERT INTO bots(id,name,description,system_prompt,model_id,created_at,owner_id,thinking_level,color) VALUES(?,?,'','','',?,?,'auto',?)").run(AGENT_BOT_ID, name2, Date.now(), this.ownerId(), BOT_COLORS[randomInt2(BOT_COLORS.length)]);
2405
+ }
2406
+ this.db.prepare("DELETE FROM settings WHERE key='agent_onboarded'").run();
2407
+ this.db.exec("COMMIT");
2408
+ } catch (error) {
2409
+ this.db.exec("ROLLBACK");
2410
+ throw error;
2411
+ }
2412
+ }
2413
+ /** Records that the owner is being welcomed; false when that already happened, so only one caller asks. */
2414
+ markOnboarded() {
2415
+ return Number(this.db.prepare("INSERT OR IGNORE INTO settings(key,value) VALUES('agent_onboarded',?)").run(String(Date.now())).changes) > 0;
2416
+ }
2417
+ /** One delivery about no message, for one Bot; the caller wakes it. */
2418
+ notify(input) {
2419
+ this.db.prepare("INSERT INTO inbox(member_id,kind,reason,urgency,chat_id,actor_id,detail,created_at) VALUES(?,?,?,?,?,?,?,?)").run(input.botId, input.kind, input.reason, input.urgency, input.chatId, input.actorId, input.detail, Date.now());
2420
+ }
2421
+ };
2422
+
2423
+ // src/server/infra/storage/environment-repository.ts
2424
+ import { randomUUID as randomUUID7 } from "node:crypto";
2425
+ var COLUMNS = "id, owner_id AS ownerId, kind, bot_id AS botId, connection";
2426
+ var record = (row) => ({ id: row.id, ownerId: row.ownerId, kind: row.kind, botId: row.botId, spec: JSON.parse(row.connection) });
2427
+ var EnvironmentRepository = class {
2428
+ constructor(db, ownerId) {
2429
+ this.db = db;
2430
+ this.ownerId = ownerId;
2431
+ }
2432
+ get(id) {
2433
+ const row = this.db.prepare(`SELECT ${COLUMNS} FROM environments WHERE id=? AND deleted_at IS NULL`).get(id);
2434
+ return row ? record(row) : null;
2435
+ }
2436
+ /** The owner's computer. */
2437
+ host() {
2438
+ const owner = this.ownerId();
2439
+ const find = () => this.db.prepare(`SELECT ${COLUMNS} FROM environments WHERE owner_id=? AND kind='host' AND deleted_at IS NULL`).get(owner);
2440
+ const found = find();
2441
+ if (found) return record(found);
2442
+ this.db.prepare("INSERT OR IGNORE INTO environments(id,owner_id,kind,created_at) VALUES(?,?,'host',?)").run(randomUUID7(), owner, Date.now());
2443
+ return record(find());
2444
+ }
2445
+ computerOf(botId) {
2446
+ const row = this.db.prepare(`SELECT ${COLUMNS} FROM environments WHERE bot_id=? AND kind='computer' AND deleted_at IS NULL`).get(botId);
2447
+ return row ? record(row) : null;
2448
+ }
2449
+ /** Every live Bot's computer. */
2450
+ computers() {
2451
+ return this.db.prepare(`SELECT e.id, e.owner_id AS ownerId, e.kind, e.bot_id AS botId, e.connection FROM environments e JOIN bots b ON b.id=e.bot_id
2452
+ WHERE e.kind='computer' AND e.deleted_at IS NULL AND b.deleted_at IS NULL`).all().map(record);
2453
+ }
2454
+ /** Gives the Bot a computer, reached by the container's name. */
2455
+ addComputer(botId, container) {
2456
+ const existing = this.computerOf(botId);
2457
+ if (existing) return existing;
2458
+ const id = randomUUID7();
2459
+ this.db.prepare("INSERT INTO environments(id,owner_id,kind,connection,directory,bot_id,created_at) VALUES(?,?,'computer',?,'/workspace',?,?)").run(id, this.ownerId(), JSON.stringify({ container }), botId, Date.now());
2460
+ return this.get(id);
2461
+ }
2462
+ /** The Bot's computer is gone: whoever was placed on it is back on the owner's computer. */
2463
+ dropComputer(botId) {
2464
+ const computer = this.computerOf(botId);
2465
+ if (!computer) return;
2466
+ this.db.exec("BEGIN IMMEDIATE");
2467
+ try {
2468
+ this.db.prepare("UPDATE bots SET home_environment_id=NULL WHERE home_environment_id=?").run(computer.id);
2469
+ this.db.prepare("UPDATE bots SET current_environment_id=NULL WHERE current_environment_id=?").run(computer.id);
2470
+ this.db.prepare("DELETE FROM environment_grants WHERE environment_id=?").run(computer.id);
2471
+ this.db.prepare("UPDATE environments SET deleted_at=? WHERE id=?").run(Date.now(), computer.id);
2472
+ this.db.exec("COMMIT");
2473
+ } catch (error) {
2474
+ this.db.exec("ROLLBACK");
2475
+ throw error;
2476
+ }
2477
+ }
2478
+ /** The Bot's default environment and the one it works in now. */
2479
+ placement(botId) {
2480
+ const row = this.db.prepare("SELECT home_environment_id AS home, current_environment_id AS current FROM bots WHERE id=?").get(botId);
2481
+ const home = row?.home && this.get(row.home) || this.host();
2482
+ const current = row?.current && this.get(row.current) || home;
2483
+ return { home, current };
2484
+ }
2485
+ /** Makes an environment the Bot's default and moves the Bot there. */
2486
+ setHome(botId, environmentId) {
2487
+ this.db.prepare("UPDATE bots SET home_environment_id=?, current_environment_id=? WHERE id=?").run(environmentId, environmentId, botId);
2488
+ }
2489
+ /** Moves the Bot without changing its default. */
2490
+ setCurrent(botId, environmentId) {
2491
+ this.db.prepare("UPDATE bots SET current_environment_id=? WHERE id=?").run(environmentId, botId);
2492
+ }
2493
+ /** The environments the owner granted the Bot, oldest grant first. */
2494
+ grants(botId) {
2495
+ return this.db.prepare(`SELECT e.id, e.owner_id AS ownerId, e.kind, e.bot_id AS botId, e.connection FROM environment_grants g JOIN environments e ON e.id=g.environment_id
2496
+ WHERE g.bot_id=? AND e.deleted_at IS NULL ORDER BY g.granted_at, e.id`).all(botId).map(record);
2497
+ }
2498
+ /** A grant lasts until it is revoked (A50); false when nothing changed. */
2499
+ grant(environmentId, botId, by) {
2500
+ return Number(this.db.prepare("INSERT OR IGNORE INTO environment_grants(environment_id,bot_id,granted_by,granted_at) VALUES(?,?,?,?)").run(environmentId, botId, by, Date.now()).changes) > 0;
2501
+ }
2502
+ revoke(environmentId, botId) {
2503
+ return Number(this.db.prepare("DELETE FROM environment_grants WHERE environment_id=? AND bot_id=?").run(environmentId, botId).changes) > 0;
2504
+ }
2505
+ };
2506
+
2194
2507
  // src/server/infra/storage/store.ts
2195
- var defaultDataDir = () => process.env.JARVY_DATA_DIR || join(homedir(), ".jarvy");
2508
+ var defaultDataDir = () => process.env.JARVY_DATA_DIR || join2(homedir(), ".jarvy");
2196
2509
  var DATABASE_FILENAME = "jarvy.db";
2197
2510
  var SESSION_LIFETIME_MS = 7 * 24 * 60 * 60 * 1e3;
2198
2511
  function passwordHash(password, salt) {
@@ -2260,10 +2573,12 @@ var Store = class {
2260
2573
  chats;
2261
2574
  runtime;
2262
2575
  credentials;
2576
+ agent;
2577
+ environments;
2263
2578
  constructor(dataDir = defaultDataDir()) {
2264
2579
  const root = resolve(dataDir);
2265
2580
  mkdirSync(root, { recursive: true, mode: 448 });
2266
- this.path = join(root, DATABASE_FILENAME);
2581
+ this.path = join2(root, DATABASE_FILENAME);
2267
2582
  this.db = new DatabaseSync(this.path);
2268
2583
  chmodSync(this.path, 384);
2269
2584
  this.db.exec("PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; PRAGMA foreign_keys=ON; PRAGMA busy_timeout=5000;");
@@ -2276,6 +2591,9 @@ var Store = class {
2276
2591
  this.chats = new ChatRepository(this.db);
2277
2592
  this.runtime = new RuntimeRepository(this.db);
2278
2593
  this.credentials = new CredentialRepository(this.db);
2594
+ this.agent = new AgentRepository(this.db, () => this.userMemberId());
2595
+ this.environments = new EnvironmentRepository(this.db, () => this.userMemberId());
2596
+ this.agent.ensure();
2279
2597
  }
2280
2598
  close() {
2281
2599
  this.db.close();
@@ -2284,7 +2602,7 @@ var Store = class {
2284
2602
  installationDeviceId() {
2285
2603
  const row = this.db.prepare("SELECT device_id FROM installation WHERE id=1").get();
2286
2604
  if (row) return row.device_id;
2287
- const id = randomUUID7();
2605
+ const id = randomUUID8();
2288
2606
  this.db.prepare("INSERT INTO installation(id,device_id) VALUES(1,?)").run(id);
2289
2607
  return id;
2290
2608
  }
@@ -2332,8 +2650,8 @@ var Store = class {
2332
2650
  if (token) this.db.prepare("DELETE FROM auth_sessions WHERE token_hash=?").run(tokenHash(token));
2333
2651
  }
2334
2652
  botFromRow(row) {
2335
- const workspacePath = join(dirname(this.path), "bots", row.id, "workspace");
2336
- mkdirSync(join(workspacePath, "temp"), { recursive: true, mode: 448 });
2653
+ const workspacePath = join2(dirname2(this.path), "bots", row.id, "workspace");
2654
+ mkdirSync(join2(workspacePath, "temp"), { recursive: true, mode: 448 });
2337
2655
  return { ...row, workspacePath };
2338
2656
  }
2339
2657
  listBots() {
@@ -2360,6 +2678,7 @@ var Store = class {
2360
2678
  this.db.prepare("DELETE FROM chats WHERE type='dm' AND id IN (SELECT chat_id FROM chat_members WHERE member_id=?)").run(id);
2361
2679
  for (const table of ["chat_members", "chat_states", "inbox"]) this.db.prepare(`DELETE FROM ${table} WHERE member_id=?`).run(id);
2362
2680
  this.db.prepare("DELETE FROM bot_sessions WHERE bot_id=?").run(id);
2681
+ this.db.prepare("DELETE FROM environment_grants WHERE bot_id=?").run(id);
2363
2682
  this.db.exec("COMMIT");
2364
2683
  } catch (error) {
2365
2684
  this.db.exec("ROLLBACK");
@@ -2376,11 +2695,11 @@ var Store = class {
2376
2695
  /** The caller names the Bot (A45: the interface picks a free default in its own language). */
2377
2696
  createBot(name2) {
2378
2697
  const clean = this.botName(name2);
2379
- const id = randomUUID7(), createdAt = Date.now();
2698
+ const id = randomUUID8(), createdAt = Date.now();
2380
2699
  this.db.exec("BEGIN IMMEDIATE");
2381
2700
  try {
2382
2701
  this.db.prepare("INSERT INTO members VALUES(?,'bot')").run(id);
2383
- this.db.prepare("INSERT INTO bots(id,name,description,system_prompt,model_id,created_at,owner_id,thinking_level,color) VALUES(?,?,?,?,?,?,?,'auto',?)").run(id, clean, "", "", "", createdAt, this.userMemberId(), BOT_COLORS[randomInt2(BOT_COLORS.length)]);
2702
+ this.db.prepare("INSERT INTO bots(id,name,description,system_prompt,model_id,created_at,owner_id,thinking_level,color) VALUES(?,?,?,?,?,?,?,'auto',?)").run(id, clean, "", "", "", createdAt, this.userMemberId(), BOT_COLORS[randomInt3(BOT_COLORS.length)]);
2384
2703
  this.db.exec("COMMIT");
2385
2704
  } catch (error) {
2386
2705
  this.db.exec("ROLLBACK");
@@ -2448,7 +2767,7 @@ var Store = class {
2448
2767
  }
2449
2768
  const key = input.apiKey === void 0 ? null : requireText(input.apiKey, "API Key", 8192);
2450
2769
  if (key && authType !== "api_key") throw new ValidationError("Invalid credential type");
2451
- const id = randomUUID7(), now = Date.now();
2770
+ const id = randomUUID8(), now = Date.now();
2452
2771
  this.db.exec("BEGIN IMMEDIATE");
2453
2772
  try {
2454
2773
  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());
@@ -2582,7 +2901,6 @@ var AccountService = class {
2582
2901
  };
2583
2902
 
2584
2903
  // src/server/infra/pi/model.ts
2585
- import { builtinProviders as builtinProviders2 } from "@earendil-works/pi-ai/providers/all";
2586
2904
  import { ModelRuntime } from "@earendil-works/pi-coding-agent";
2587
2905
  var FORMATS = /* @__PURE__ */ new Set(["openai", "openrouter", "deepseek", "together", "baseten", "zai", "qwen", "chat-template", "qwen-chat-template", "string-thinking", "ant-ling"]);
2588
2906
  function installModel(runtime, connection, configured2, headers2 = {}) {
@@ -2617,12 +2935,12 @@ function installModel(runtime, connection, configured2, headers2 = {}) {
2617
2935
  model.headers = { ...model.headers, ...headers2 };
2618
2936
  return model;
2619
2937
  }
2620
- async function modelCatalog(credentials, providerName) {
2938
+ async function modelCatalog(credentials, providerName, catalog) {
2621
2939
  const runtime = await ModelRuntime.create({ credentials, modelsPath: null, allowModelNetwork: false, refreshOnCreate: false });
2622
- return runtime.getModels(providerName).map(publicModel);
2940
+ return catalog.overlay(providerName, runtime.getModels(providerName)).map(publicModel);
2623
2941
  }
2624
- function builtinModelCatalog(providerName) {
2625
- return (builtinProviders2().find((provider) => provider.id === providerName)?.getModels() ?? []).map(publicModel);
2942
+ function providerModels(providers, providerName) {
2943
+ return (providers.find((provider) => provider.id === providerName)?.getModels() ?? []).map(publicModel);
2626
2944
  }
2627
2945
  function catalogCompat(model) {
2628
2946
  const { thinkingFormat: _format, ...compat } = model.compat ?? {};
@@ -2664,12 +2982,12 @@ var CredentialCoordinator = class {
2664
2982
  if (this.tails.get(connectionId) === tail) this.tails.delete(connectionId);
2665
2983
  });
2666
2984
  if (!signal) return operation;
2667
- return new Promise((resolve6, reject) => {
2985
+ return new Promise((resolve5, reject) => {
2668
2986
  const aborted = () => reject(signal.reason);
2669
2987
  signal.addEventListener("abort", aborted, { once: true });
2670
2988
  operation.then((value2) => {
2671
2989
  signal.removeEventListener("abort", aborted);
2672
- resolve6(value2);
2990
+ resolve5(value2);
2673
2991
  }, (error) => {
2674
2992
  signal.removeEventListener("abort", aborted);
2675
2993
  reject(error);
@@ -2752,7 +3070,6 @@ function resolveThinking(model, choice) {
2752
3070
  }
2753
3071
 
2754
3072
  // src/server/infra/pi/autoconfig.ts
2755
- import { builtinProviders as builtinProviders3 } from "@earendil-works/pi-ai/providers/all";
2756
3073
  var VENDORS = [
2757
3074
  [/^glm/i, ["zai"]],
2758
3075
  [/^deepseek/i, ["deepseek"]],
@@ -2773,23 +3090,20 @@ function officialScore(provider, id) {
2773
3090
  return (id.includes("/") ? 0 : 1) + (words.some((word) => provider.includes(word)) ? 1 : 0);
2774
3091
  }
2775
3092
  var strip = (id) => id.toLowerCase().replace(/^.*\//, "").replace(/-(\d{6,8})$/, "");
2776
- var rows;
2777
- function catalog() {
2778
- rows ??= builtinProviders3().flatMap((provider) => provider.getModels().map((model) => ({ provider: provider.id, model, official: officialScore(provider.id, model.id) })));
2779
- return rows;
3093
+ function rows(providers) {
3094
+ return providers.flatMap((provider) => provider.getModels().map((model) => ({ provider: provider.id, model, official: officialScore(provider.id, model.id) })));
2780
3095
  }
2781
3096
  var source = (row) => ({ provider: row.provider, modelId: row.model.id, name: row.model.name, api: row.model.api, official: row.official === 2 });
2782
3097
  var completeness = (row) => Object.values(row.model.thinkingLevelMap ?? {}).filter((value2) => value2 !== void 0).length + Object.keys(row.model.compat ?? {}).length + (row.model.cost.input > 0 ? 1 : 0);
2783
- function candidates(query) {
3098
+ function candidates(all, query) {
2784
3099
  const q = query.trim().toLowerCase();
2785
3100
  if (!q) return [];
2786
- const all = catalog();
2787
- const same2 = all.filter((row) => row.model.id.toLowerCase() === q || strip(row.model.id) === strip(q));
2788
- return same2.length ? same2 : all.filter((row) => row.model.id.toLowerCase().includes(strip(q)));
3101
+ const same3 = all.filter((row) => row.model.id.toLowerCase() === q || strip(row.model.id) === strip(q));
3102
+ return same3.length ? same3 : all.filter((row) => row.model.id.toLowerCase().includes(strip(q)));
2789
3103
  }
2790
- function similar(query) {
3104
+ function similar(all, query) {
2791
3105
  const q = strip(query);
2792
- return catalog().map((row) => ({ row, ...similarity(strip(row.model.id), q) })).filter((item) => item.prefix >= 3).sort((a, b) => b.score - a.score || b.row.official - a.row.official).slice(0, 5).map((item) => item.row);
3106
+ return all.map((row) => ({ row, ...similarity(strip(row.model.id), q) })).filter((item) => item.prefix >= 3).sort((a, b) => b.score - a.score || b.row.official - a.row.official).slice(0, 5).map((item) => item.row);
2793
3107
  }
2794
3108
  function similarity(a, b) {
2795
3109
  let prefix = 0;
@@ -2826,14 +3140,14 @@ function matched(row, api, alternatives, reseller) {
2826
3140
  alternatives: alternatives.filter((item) => item !== row).slice(0, 8).map(source)
2827
3141
  };
2828
3142
  }
2829
- function autoconfigure(modelId, api, choice) {
2830
- const hits = candidates(modelId);
3143
+ function autoconfigure(providers, modelId, api, choice) {
3144
+ const all = rows(providers), hits = candidates(all, modelId);
2831
3145
  if (choice) {
2832
- const row = catalog().find((item) => item.provider === choice.provider && item.model.id === choice.modelId);
3146
+ const row = all.find((item) => item.provider === choice.provider && item.model.id === choice.modelId);
2833
3147
  if (row) return matched(row, api, hits, row.official < 2);
2834
3148
  }
2835
3149
  if (!hits.length) {
2836
- const options = similar(modelId);
3150
+ const options = similar(all, modelId);
2837
3151
  return options.length ? { status: "choose", reason: "similar", options: options.map(source) } : { status: "none" };
2838
3152
  }
2839
3153
  const official = hits.filter((row) => row.official === 2);
@@ -2917,11 +3231,12 @@ async function discoverModels(input, signal) {
2917
3231
 
2918
3232
  // src/server/service/models/service.ts
2919
3233
  var ModelService = class {
2920
- constructor(store, credentials, logins, changed) {
3234
+ constructor(store, credentials, logins, changed, models) {
2921
3235
  this.store = store;
2922
3236
  this.credentials = credentials;
2923
3237
  this.logins = logins;
2924
3238
  this.changed = changed;
3239
+ this.models = models;
2925
3240
  }
2926
3241
  required(value2) {
2927
3242
  if (!value2) throw new RuntimeFault("connection_missing", "Connection not found", 404);
@@ -2956,15 +3271,19 @@ var ModelService = class {
2956
3271
  return this.required(this.store.deleteConnectionModel(id, modelId));
2957
3272
  }
2958
3273
  providers() {
2959
- return providerCatalog();
3274
+ return providerCatalog(this.models.providers());
3275
+ }
3276
+ /** Brings pi.dev's newer catalog entries in the background (A49). */
3277
+ refreshCatalog() {
3278
+ this.models.refresh();
2960
3279
  }
2961
3280
  builtinModels(provider) {
2962
- return builtinModelCatalog(provider);
3281
+ return providerModels(this.models.providers(), provider);
2963
3282
  }
2964
3283
  async catalog(id) {
2965
3284
  const connection = this.required(this.store.getModelConnection(id));
2966
3285
  const credentials = new ConnectionCredentials(this.store.credentials, this.credentials, this.store.userMemberId(), id, connection.providerName);
2967
- return modelCatalog(credentials, connection.providerName);
3286
+ return modelCatalog(credentials, connection.providerName, this.models);
2968
3287
  }
2969
3288
  discover(input, signal) {
2970
3289
  if (typeof input.baseUrl !== "string") throw new RuntimeFault("discovery_url_missing", "Enter the API URL.", 400);
@@ -2977,7 +3296,7 @@ var ModelService = class {
2977
3296
  if (!key) throw new RuntimeFault("discovery_key", "Enter the API key.", 400);
2978
3297
  return discoverModels({ api: String(input.api ?? "openai-completions"), baseUrl: input.baseUrl, key }, signal);
2979
3298
  }
2980
- /** Suggests model fields from the local Pi catalog; the caller decides whether to save them. */
3299
+ /** Suggests model fields from Pi's catalog (with pi.dev's newer entries, A49); the caller decides whether to save them. */
2981
3300
  autoconfigure(input) {
2982
3301
  const text3 = (value2) => typeof value2 === "string" && value2.trim() && value2.length <= 200 ? value2.trim() : null;
2983
3302
  const modelId = text3(input.modelId), api = text3(input.api);
@@ -2989,7 +3308,7 @@ var ModelService = class {
2989
3308
  if (!provider || !chosen) throw new RuntimeFault("autoconfig_source", "The setup source isn't valid.", 400);
2990
3309
  choice = { provider, modelId: chosen };
2991
3310
  }
2992
- return autoconfigure(modelId, api, choice);
3311
+ return autoconfigure(this.models.providers(), modelId, api, choice);
2993
3312
  }
2994
3313
  options() {
2995
3314
  return this.list().filter((connection) => connection.models.length).map((connection) => ({
@@ -3016,7 +3335,7 @@ var ModelService = class {
3016
3335
  };
3017
3336
 
3018
3337
  // src/server/infra/pi/auth.ts
3019
- import { randomUUID as randomUUID8 } from "node:crypto";
3338
+ import { randomUUID as randomUUID9 } from "node:crypto";
3020
3339
  import { ModelRuntime as ModelRuntime2 } from "@earendil-works/pi-coding-agent";
3021
3340
  var CredentialLogins = class {
3022
3341
  constructor(store, credentials, changed) {
@@ -3064,7 +3383,7 @@ var CredentialLogins = class {
3064
3383
  prompt(flow, prompt) {
3065
3384
  const signal = prompt.signal ? AbortSignal.any([flow.controller.signal, prompt.signal]) : flow.controller.signal;
3066
3385
  signal.throwIfAborted();
3067
- const id = randomUUID8();
3386
+ const id = randomUUID9();
3068
3387
  flow.state.prompt = {
3069
3388
  id,
3070
3389
  type: prompt.type,
@@ -3072,7 +3391,7 @@ var CredentialLogins = class {
3072
3391
  ..."placeholder" in prompt ? { placeholder: prompt.placeholder } : {},
3073
3392
  ...prompt.type === "select" ? { options: prompt.options } : {}
3074
3393
  };
3075
- return new Promise((resolve6, reject) => {
3394
+ return new Promise((resolve5, reject) => {
3076
3395
  const clear = () => {
3077
3396
  signal.removeEventListener("abort", aborted);
3078
3397
  flow.answer = void 0;
@@ -3084,7 +3403,7 @@ var CredentialLogins = class {
3084
3403
  };
3085
3404
  flow.answer = (value2) => {
3086
3405
  clear();
3087
- resolve6(value2);
3406
+ resolve5(value2);
3088
3407
  };
3089
3408
  signal.addEventListener("abort", aborted, { once: true });
3090
3409
  if (signal.aborted) aborted();
@@ -3149,9 +3468,9 @@ var User = class extends Member {
3149
3468
  };
3150
3469
 
3151
3470
  // src/server/application.ts
3152
- import { createHash as createHash4 } from "node:crypto";
3153
- import { mkdir as mkdir4, realpath as realpath2 } from "node:fs/promises";
3154
- import { join as join7, resolve as resolve4 } from "node:path";
3471
+ import { createHash as createHash6 } from "node:crypto";
3472
+ import { mkdir as mkdir5, realpath } from "node:fs/promises";
3473
+ import { join as join9, resolve as resolve3 } from "node:path";
3155
3474
  import lockfile from "proper-lockfile";
3156
3475
 
3157
3476
  // src/server/service/chat/mentions.ts
@@ -3159,12 +3478,12 @@ var WORD = /[\p{Script=Latin}\p{Nd}_]/u;
3159
3478
  function findMentions(source2, candidates2) {
3160
3479
  const text3 = source2.replace(/```[\s\S]*?```|`[^`\n]*`/g, (code) => " ".repeat(code.length));
3161
3480
  const lower = text3.toLowerCase(), sorted = [...candidates2].sort((a, b) => b.name.length - a.name.length), found = [];
3162
- for (let at = lower.indexOf("@"); at >= 0; at = lower.indexOf("@", at + 1)) {
3163
- if (at > 0 && WORD.test(text3[at - 1])) continue;
3481
+ for (let at2 = lower.indexOf("@"); at2 >= 0; at2 = lower.indexOf("@", at2 + 1)) {
3482
+ if (at2 > 0 && WORD.test(text3[at2 - 1])) continue;
3164
3483
  const match = sorted.find((candidate) => {
3165
3484
  const name2 = candidate.name.toLowerCase();
3166
- if (!name2 || !lower.startsWith(name2, at + 1)) return false;
3167
- const next = text3[at + 1 + name2.length];
3485
+ if (!name2 || !lower.startsWith(name2, at2 + 1)) return false;
3486
+ const next = text3[at2 + 1 + name2.length];
3168
3487
  return !(next && WORD.test(next) && WORD.test(name2.at(-1)));
3169
3488
  });
3170
3489
  if (match && !found.includes(match)) found.push(match);
@@ -3200,7 +3519,13 @@ var POLICY = {
3200
3519
  // Marks on your messages are queued: they wake you but never interrupt (A39).
3201
3520
  reaction: { author: "queue" },
3202
3521
  // A new version of a notice or summary is queued like a mark.
3203
- note: { member: "queue", follower: "queue" }
3522
+ note: { member: "queue", follower: "queue" },
3523
+ // The owner just gave the agent Bot its first model: it should welcome them now (A48).
3524
+ onboard: { setup: "interrupt" },
3525
+ // What the agent Bot changed about a Bot rides along with that Bot's next batch, without waking it. The owner moving
3526
+ // a Bot to another work environment stops its running turn like an @, so it doesn't go on in the old place (A50); a
3527
+ // Bot not running, and a change of access alone, hear of it with their next batch instead (fish m1012-m1015).
3528
+ change: { agent: "digest", owner: "interrupt" }
3204
3529
  };
3205
3530
  function urgencyOf(kind, reason) {
3206
3531
  return POLICY[kind][reason];
@@ -3364,8 +3689,8 @@ var ChatMessages = class {
3364
3689
  const message = this.repository.message(this.actor, id);
3365
3690
  return message?.chatId === this.id ? message : null;
3366
3691
  }
3367
- async list({ after = 0, limit }) {
3368
- return this.repository.messages(this.actor, this.id, after, limit);
3692
+ async list({ after: after2 = 0, limit }) {
3693
+ return this.repository.messages(this.actor, this.id, after2, limit);
3369
3694
  }
3370
3695
  async search(query, { limit }) {
3371
3696
  return this.repository.search(this.actor, this.id, query, limit);
@@ -3424,7 +3749,7 @@ var ChatQuestions = class {
3424
3749
  if (question.state === "answered") return Promise.resolve(resultOf(question, question.answer));
3425
3750
  if (question.state === "dismissed") return Promise.resolve({ status: "dismissed" });
3426
3751
  if (question.state === "ignored") return Promise.reject(new ChatError(409, "The question has expired", "question_expired"));
3427
- return new Promise((resolve6, reject) => {
3752
+ return new Promise((resolve5, reject) => {
3428
3753
  const stop = () => {
3429
3754
  this.waiting.delete(message.id);
3430
3755
  if (this.repository.settleQuestion(message.id, "ignored", null)) this.changed(message.chatId);
@@ -3437,7 +3762,7 @@ var ChatQuestions = class {
3437
3762
  signal?.addEventListener("abort", stop, { once: true });
3438
3763
  this.waiting.set(message.id, (result) => {
3439
3764
  signal?.removeEventListener("abort", stop);
3440
- resolve6(result);
3765
+ resolve5(result);
3441
3766
  });
3442
3767
  });
3443
3768
  }
@@ -3795,13 +4120,13 @@ var ChatService = class {
3795
4120
  return this.repository.getChat(actorId, chatId);
3796
4121
  }
3797
4122
  /** The message with up to `context` messages before and after it (read position unchanged). */
3798
- around(actorId, messageId, context) {
4123
+ around(actorId, messageId, context2) {
3799
4124
  const message = this.repository.message(actorId, messageId);
3800
4125
  if (!message) throw new ChatError(404, "Message not found");
3801
4126
  return [
3802
- ...this.repository.messagesBefore(actorId, message.chatId, message.sequence, context),
4127
+ ...this.repository.messagesBefore(actorId, message.chatId, message.sequence, context2),
3803
4128
  message,
3804
- ...this.repository.messages(actorId, message.chatId, message.sequence, context)
4129
+ ...this.repository.messages(actorId, message.chatId, message.sequence, context2)
3805
4130
  ];
3806
4131
  }
3807
4132
  /** Exactly the unread messages of a chat, oldest first; the caller moves the read position. */
@@ -3818,10 +4143,10 @@ var ChatService = class {
3818
4143
  find(actorId, query, { within, limit = 50 } = {}) {
3819
4144
  const messages = this.searchAll(actorId, query, limit, within);
3820
4145
  const chats = within ? [] : this.repository.searchChatIds(actorId, query.trim(), 30).map((id) => this.repository.getChat(actorId, id)).filter((chat) => chat !== null);
3821
- const where = /* @__PURE__ */ new Map();
4146
+ const where2 = /* @__PURE__ */ new Map();
3822
4147
  const chatOf = (id) => {
3823
- if (!where.has(id)) where.set(id, this.repository.getChat(actorId, id));
3824
- return where.get(id);
4148
+ if (!where2.has(id)) where2.set(id, this.repository.getChat(actorId, id));
4149
+ return where2.get(id);
3825
4150
  };
3826
4151
  return { chats, messages: messages.flatMap((message) => {
3827
4152
  const chat = chatOf(message.chatId);
@@ -3835,10 +4160,10 @@ var ChatService = class {
3835
4160
  const messages = this.repository.messagesBefore(actorId, chatId, before ?? Number.MAX_SAFE_INTEGER, limit + 1);
3836
4161
  return { messages: messages.slice(-limit), hasOlder: messages.length > limit };
3837
4162
  }
3838
- read(actorId, chatId, after = 0, limit = 1e3) {
3839
- if (!Number.isSafeInteger(after) || after < 0 || !Number.isSafeInteger(limit) || limit < 1 || limit > 1e3)
4163
+ read(actorId, chatId, after2 = 0, limit = 1e3) {
4164
+ if (!Number.isSafeInteger(after2) || after2 < 0 || !Number.isSafeInteger(limit) || limit < 1 || limit > 1e3)
3840
4165
  throw new ChatError(400, "Invalid message cursor or limit");
3841
- return this.repository.messages(actorId, chatId, after, limit);
4166
+ return this.repository.messages(actorId, chatId, after2, limit);
3842
4167
  }
3843
4168
  instance(actorId, chatId) {
3844
4169
  this.repository.assertAccess(actorId, chatId);
@@ -3913,70 +4238,11 @@ var ChatService = class {
3913
4238
  }
3914
4239
  };
3915
4240
 
3916
- // src/server/infra/files/attachments.ts
3917
- import { constants } from "node:fs";
3918
- import { mkdir, open, realpath, rename, rm } from "node:fs/promises";
3919
- import { join as join2, resolve as resolve2 } from "node:path";
3920
- import { randomUUID as randomUUID9 } from "node:crypto";
3921
- var MAX_FILE = 10 * 1024 * 1024;
3922
- var AttachmentFiles = class {
3923
- constructor(dataDir) {
3924
- this.dataDir = dataDir;
3925
- }
3926
- async materialize(file, signal) {
3927
- const dir = join2(this.dataDir, "attachments", file.id);
3928
- await mkdir(dir, { recursive: true, mode: 448 });
3929
- if (await realpath(dir) !== resolve2(dir)) throw new ChatError(400, "Invalid attachment directory");
3930
- const path = join2(dir, file.name), temporary = join2(dir, `.${randomUUID9()}.tmp`);
3931
- try {
3932
- const handle = await open(temporary, "wx", 384);
3933
- try {
3934
- await handle.writeFile(file.data, { signal });
3935
- } finally {
3936
- await handle.close();
3937
- }
3938
- signal?.throwIfAborted();
3939
- await rename(temporary, path);
3940
- } finally {
3941
- await rm(temporary, { force: true });
3942
- }
3943
- return { path, name: file.name };
3944
- }
3945
- async readOutgoing(workspace, items, signal) {
3946
- if (items.length > 5) throw new ChatError(400, "Too many attachments");
3947
- const result = [];
3948
- for (const item of items) {
3949
- signal?.throwIfAborted();
3950
- const path = await realpath(resolve2(workspace, item.path));
3951
- const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
3952
- try {
3953
- const stat = await handle.stat();
3954
- if (!stat.isFile() || !stat.size || stat.size > MAX_FILE) throw new ChatError(400, "Invalid attachment size or type");
3955
- const data = Buffer.alloc(Math.min(MAX_FILE + 1, stat.size + 1));
3956
- let bytes = 0;
3957
- while (bytes < data.length) {
3958
- signal?.throwIfAborted();
3959
- const read = await handle.read(data, bytes, data.length - bytes, bytes);
3960
- if (!read.bytesRead) break;
3961
- bytes += read.bytesRead;
3962
- }
3963
- if (bytes !== stat.size) throw new ChatError(409, "Attachment changed while reading");
3964
- result.push({ name: item.name, mimeType: "application/octet-stream", data: data.subarray(0, bytes) });
3965
- } finally {
3966
- await handle.close();
3967
- }
3968
- }
3969
- return result;
3970
- }
3971
- };
3972
-
3973
4241
  // src/server/service/chat/files.ts
3974
4242
  var ChatFiles = class {
3975
- constructor(dataDir, chats) {
4243
+ constructor(chats) {
3976
4244
  this.chats = chats;
3977
- this.files = new AttachmentFiles(dataDir);
3978
4245
  }
3979
- files;
3980
4246
  async envelope(botId, messageIds, signal) {
3981
4247
  const output = [];
3982
4248
  for (const id of messageIds) {
@@ -4000,7 +4266,7 @@ var ChatFiles = class {
4000
4266
  sender: message.sender,
4001
4267
  content: expandQuotes(message.content, message.quotes, (memberId) => this.chats.member(memberId)?.name ?? memberId),
4002
4268
  createdAt: message.createdAt,
4003
- attachments: await this.paths(botId, message, signal),
4269
+ attachments: listing(message),
4004
4270
  ...this.reply(message)
4005
4271
  });
4006
4272
  }
@@ -4011,20 +4277,21 @@ var ChatFiles = class {
4011
4277
  const sender = message.replyTo ? this.chats.member(message.replyTo.senderId) : null;
4012
4278
  return message.replyTo && sender ? { replyTo: { id: message.replyTo.id, sender, text: replyExcerpt(message.replyTo) } } : {};
4013
4279
  }
4014
- async paths(actorId, message, signal) {
4015
- const paths = [];
4016
- for (const item of message.attachments) {
4017
- signal?.throwIfAborted();
4018
- const attachment = this.chats.download(actorId, item.id);
4019
- if (!attachment) throw new ChatError(404, "Attachment not found");
4020
- paths.push(await this.files.materialize({ ...attachment, id: item.id, name: item.name }, signal));
4021
- }
4022
- return paths;
4023
- }
4024
- readOutgoing(workspace, items, signal) {
4025
- return this.files.readOutgoing(workspace, items, signal);
4280
+ /** A file of a message the actor can read, with its contents. */
4281
+ open(actorId, attachmentId) {
4282
+ const found = this.chats.download(actorId, attachmentId);
4283
+ if (!found) throw new ChatError(404, "File not found, or in a message you can't read");
4284
+ return found;
4026
4285
  }
4027
4286
  };
4287
+ function listing(message) {
4288
+ return message.attachments.map((file) => ({ id: file.id, name: file.name, type: file.mimeType || "application/octet-stream", size: byteSize(file.size) }));
4289
+ }
4290
+ function byteSize(bytes) {
4291
+ if (bytes < 1024) return `${bytes} B`;
4292
+ if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
4293
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
4294
+ }
4028
4295
 
4029
4296
  // src/server/service/events.ts
4030
4297
  var Changes = class {
@@ -4049,8 +4316,8 @@ var Changes = class {
4049
4316
  // src/server/service/bot/activity.ts
4050
4317
  function activityPage(messages, history, limit) {
4051
4318
  const candidates2 = [
4052
- ...messages.map((record2) => ({ kind: "chat", record: record2 })),
4053
- ...history.map((record2) => ({ kind: "history", record: record2 }))
4319
+ ...messages.map((record3) => ({ kind: "chat", record: record3 })),
4320
+ ...history.map((record3) => ({ kind: "history", record: record3 }))
4054
4321
  ];
4055
4322
  candidates2.sort((a, b) => {
4056
4323
  if (a.record.timestamp !== b.record.timestamp) return b.record.timestamp - a.record.timestamp;
@@ -4063,8 +4330,8 @@ function activityPage(messages, history, limit) {
4063
4330
  }
4064
4331
 
4065
4332
  // src/server/service/bot/service.ts
4066
- import { dirname as dirname5, join as join6 } from "node:path";
4067
- import { rm as rm2 } from "node:fs/promises";
4333
+ import { dirname as dirname7, join as join8 } from "node:path";
4334
+ import { rm as rm3 } from "node:fs/promises";
4068
4335
 
4069
4336
  // src/server/infra/pi/history.ts
4070
4337
  var value = (input) => JSON.parse(JSON.stringify(input));
@@ -4181,7 +4448,7 @@ var PiHistoryAdapter = class {
4181
4448
  // src/server/infra/pi/session.ts
4182
4449
  import { existsSync, readFileSync } from "node:fs";
4183
4450
  import { mkdir as mkdir2 } from "node:fs/promises";
4184
- import { dirname as dirname2, join as join3, resolve as resolve3, sep } from "node:path";
4451
+ import { dirname as dirname3, join as join3, resolve as resolve2, sep } from "node:path";
4185
4452
  import { createAgentSession, createCodemodeExtension, DefaultResourceLoader, ModelRuntime as ModelRuntime3, SessionManager, SettingsManager } from "@earendil-works/pi-coding-agent";
4186
4453
 
4187
4454
  // src/server/infra/pi/http.ts
@@ -4207,6 +4474,9 @@ function configureHttp(timeoutMs) {
4207
4474
  // src/server/infra/pi/session.ts
4208
4475
  import { Type } from "typebox";
4209
4476
 
4477
+ // src/server/shared/tools.ts
4478
+ var isToolMedia = (value2) => !!value2 && typeof value2 === "object" && value2.kind === "media" && Array.isArray(value2.images);
4479
+
4210
4480
  // src/server/infra/pi/live.ts
4211
4481
  var LiveTranslator = class {
4212
4482
  message = 0;
@@ -4269,6 +4539,130 @@ function resultText(result) {
4269
4539
  }
4270
4540
  }
4271
4541
 
4542
+ // src/server/infra/pi/environment-tools.ts
4543
+ import { AsyncLocalStorage } from "node:async_hooks";
4544
+ import { readFile as readLocal, rm } from "node:fs/promises";
4545
+ import { basename } from "node:path";
4546
+ import { createBashToolDefinition, createEditToolDefinition, createReadToolDefinition, createWriteToolDefinition } from "@earendil-works/pi-coding-agent";
4547
+
4548
+ // src/server/infra/pi/image-kind.ts
4549
+ var IMAGE_SNIFF_BYTES = 4100;
4550
+ function imageKind(bytes) {
4551
+ if (startsWith(bytes, [255, 216, 255])) return bytes[3] === 247 ? null : "image/jpeg";
4552
+ if (startsWith(bytes, PNG)) return isPng(bytes) && !isAnimatedPng(bytes) ? "image/png" : null;
4553
+ if (ascii(bytes, 0, "GIF87a") || ascii(bytes, 0, "GIF89a")) return "image/gif";
4554
+ if (ascii(bytes, 0, "RIFF") && ascii(bytes, 8, "WEBP")) return "image/webp";
4555
+ if (ascii(bytes, 0, "BM") && isBmp(bytes)) return "image/bmp";
4556
+ return null;
4557
+ }
4558
+ var PNG = [137, 80, 78, 71, 13, 10, 26, 10];
4559
+ var isPng = (bytes) => bytes.length >= 16 && u32be(bytes, PNG.length) === 13 && ascii(bytes, 12, "IHDR");
4560
+ function isAnimatedPng(bytes) {
4561
+ let offset = PNG.length;
4562
+ while (offset + 8 <= bytes.length) {
4563
+ const length = u32be(bytes, offset), type = offset + 4;
4564
+ if (ascii(bytes, type, "acTL")) return true;
4565
+ if (ascii(bytes, type, "IDAT")) return false;
4566
+ const next = offset + 8 + length + 4;
4567
+ if (next <= offset || next > bytes.length) return false;
4568
+ offset = next;
4569
+ }
4570
+ return false;
4571
+ }
4572
+ function isBmp(bytes) {
4573
+ if (bytes.length < 26) return false;
4574
+ const size = u32le(bytes, 2), pixels = u32le(bytes, 10), header = u32le(bytes, 14);
4575
+ if (size !== 0 && size < 26) return false;
4576
+ if (pixels < 14 + header) return false;
4577
+ if (size !== 0 && pixels >= size) return false;
4578
+ let planes, bits;
4579
+ if (header === 12) {
4580
+ planes = u16le(bytes, 22);
4581
+ bits = u16le(bytes, 24);
4582
+ } else if (header >= 40 && header <= 124) {
4583
+ if (bytes.length < 30) return false;
4584
+ planes = u16le(bytes, 26);
4585
+ bits = u16le(bytes, 28);
4586
+ } else return false;
4587
+ return planes === 1 && [1, 4, 8, 16, 24, 32].includes(bits);
4588
+ }
4589
+ var at = (bytes, offset) => bytes[offset] ?? 0;
4590
+ var u16le = (bytes, offset) => at(bytes, offset) + (at(bytes, offset + 1) << 8);
4591
+ var u32be = (bytes, offset) => at(bytes, offset) * 16777216 + (at(bytes, offset + 1) << 16) + (at(bytes, offset + 2) << 8) + at(bytes, offset + 3);
4592
+ var u32le = (bytes, offset) => at(bytes, offset) + (at(bytes, offset + 1) << 8) + (at(bytes, offset + 2) << 16) + at(bytes, offset + 3) * 16777216;
4593
+ var startsWith = (bytes, prefix) => bytes.length >= prefix.length && prefix.every((byte, index) => bytes[index] === byte);
4594
+ function ascii(bytes, offset, text3) {
4595
+ if (bytes.length < offset + text3.length) return false;
4596
+ for (let index = 0; index < text3.length; index++) if (bytes[offset + index] !== text3.charCodeAt(index)) return false;
4597
+ return true;
4598
+ }
4599
+
4600
+ // src/server/infra/pi/environment-tools.ts
4601
+ function environmentTools(lookup) {
4602
+ const pinned = new AsyncLocalStorage();
4603
+ const current = () => pinned.getStore() ?? lookup();
4604
+ const access = async (path) => {
4605
+ if (!await current().exists(path)) throw Object.assign(new Error(`ENOENT: no such file or directory, access '${path}'`), { code: "ENOENT" });
4606
+ };
4607
+ const readFile3 = (path) => current().readFile(path), writeFile4 = (path, content2) => current().writeFile(path, content2);
4608
+ const cwd = current().cwd;
4609
+ const definitions2 = [
4610
+ relocated(createBashToolDefinition(cwd, { operations: {
4611
+ exec: (command, dir, { onData, signal, timeout, env }) => current().exec(command, {
4612
+ cwd: dir,
4613
+ onData,
4614
+ ...signal ? { signal } : {},
4615
+ ...timeout ? { timeout } : {},
4616
+ ...env ? { env: defined(env) } : {}
4617
+ })
4618
+ } }), current),
4619
+ // Pictures go to the model as pictures; the kind comes from a file's first bytes, as Pi's local read does.
4620
+ createReadToolDefinition(cwd, { operations: { readFile: readFile3, access, detectImageMimeType: async (path) => imageKind(await current().head(path, IMAGE_SNIFF_BYTES)) } }),
4621
+ createWriteToolDefinition(cwd, { operations: { writeFile: writeFile4, mkdir: (path) => current().mkdir(path) } }),
4622
+ createEditToolDefinition(cwd, { operations: { readFile: readFile3, writeFile: writeFile4, access } })
4623
+ ];
4624
+ return definitions2.map((definition) => inside(definition, lookup, pinned));
4625
+ }
4626
+ function inside(definition, lookup, pinned) {
4627
+ const execute = definition.execute.bind(definition);
4628
+ return { ...definition, execute: ((...args) => {
4629
+ const env = lookup(), ctx = args[4];
4630
+ if (ctx && typeof ctx === "object") args[4] = new Proxy(ctx, { get: (target2, key) => key === "cwd" ? env.cwd : Reflect.get(target2, key, target2) });
4631
+ return pinned.run(env, () => execute(...args));
4632
+ }) };
4633
+ }
4634
+ function relocated(definition, current) {
4635
+ const execute = definition.execute.bind(definition);
4636
+ return { ...definition, execute: (async (...args) => {
4637
+ const env = current();
4638
+ if (env.kind === "host") return execute(...args);
4639
+ try {
4640
+ const result = await execute(...args), path = result.details?.fullOutputPath;
4641
+ if (typeof path !== "string") return result;
4642
+ const target2 = await moveOut(path, env), swap = (text3) => text3?.replaceAll(path, target2);
4643
+ return {
4644
+ ...result,
4645
+ content: result.content.map((part) => part.type === "text" ? { ...part, text: swap(part.text) } : part),
4646
+ details: { ...result.details, fullOutputPath: target2 },
4647
+ ...result.structuredContent?.full_output_path ? { structuredContent: { ...result.structuredContent, full_output_path: target2 } } : {}
4648
+ };
4649
+ } catch (error) {
4650
+ const path = error instanceof Error ? /Full output: (\S+?)\]/.exec(error.message)?.[1] : void 0;
4651
+ if (!path) throw error;
4652
+ throw new Error(error.message.replaceAll(path, await moveOut(path, env)));
4653
+ }
4654
+ }) };
4655
+ }
4656
+ async function moveOut(path, env) {
4657
+ const target2 = `${env.cwd}/temp/${basename(path)}`;
4658
+ await env.writeFile(target2, await readLocal(path));
4659
+ await rm(path, { force: true });
4660
+ return target2;
4661
+ }
4662
+ function defined(vars) {
4663
+ return Object.fromEntries(Object.entries(vars).filter((entry) => entry[1] !== void 0));
4664
+ }
4665
+
4272
4666
  // src/server/infra/pi/session.ts
4273
4667
  var HTTP_IDLE_TIMEOUT_MS = 9e5;
4274
4668
  function compactionReserve(contextWindow) {
@@ -4309,10 +4703,10 @@ var PiSession = class _PiSession {
4309
4703
  }
4310
4704
  static async create(options) {
4311
4705
  const { bot, store, reference, selection } = options;
4312
- const agentDir = join3(dirname2(bot.workspacePath), "agent", "pi"), sessionsDir = join3(agentDir, "sessions");
4706
+ const agentDir = join3(dirname3(bot.workspacePath), "agent", "pi"), sessionsDir = join3(agentDir, "sessions");
4313
4707
  await mkdir2(sessionsDir, { recursive: true, mode: 448 });
4314
- const expected = resolve3(sessionsDir) + sep;
4315
- if (!resolve3(reference.file).startsWith(expected) || dirname2(resolve3(reference.file)) !== resolve3(sessionsDir))
4708
+ const expected = resolve2(sessionsDir) + sep;
4709
+ if (!resolve2(reference.file).startsWith(expected) || dirname3(resolve2(reference.file)) !== resolve2(sessionsDir))
4316
4710
  throw new RuntimeFault("session_path", "The session file path isn't valid.");
4317
4711
  const exists = existsSync(reference.file);
4318
4712
  if (!exists && (reference.persisted || store.runtime.runs(bot.id).some((run) => run.state === "unknown")))
@@ -4363,6 +4757,10 @@ var PiSession = class _PiSession {
4363
4757
  executionMode: "sequential",
4364
4758
  execute: async (toolCallId, args, signal) => {
4365
4759
  const output = await tool.execute(args, { toolCallId, signal });
4760
+ if (isToolMedia(output)) return { content: [
4761
+ { type: "text", text: output.text },
4762
+ ...output.images.map((image) => ({ type: "image", data: image.data, mimeType: image.mimeType }))
4763
+ ], details: {} };
4366
4764
  return { content: [{ type: "text", text: typeof output === "string" ? output : JSON.stringify(output) }], details: {} };
4367
4765
  }
4368
4766
  });
@@ -4379,7 +4777,8 @@ var PiSession = class _PiSession {
4379
4777
  modelRuntime: models,
4380
4778
  model,
4381
4779
  thinkingLevel: resolveThinking(model, selection.thinkingLevel),
4382
- tools
4780
+ tools,
4781
+ customTools: environmentTools(options.environment)
4383
4782
  });
4384
4783
  adapter.session = result.session;
4385
4784
  adapter.settings = settingsManager;
@@ -4396,8 +4795,8 @@ var PiSession = class _PiSession {
4396
4795
  for (let index = entries.length - 1; index >= 0; index--) {
4397
4796
  const entry = entries[index];
4398
4797
  if (entry.type !== "message" || entry.message !== event.message) continue;
4399
- const record2 = history.convert(entry);
4400
- if (record2) await options.onHistory(record2);
4798
+ const record3 = history.convert(entry);
4799
+ if (record3) await options.onHistory(record3);
4401
4800
  break;
4402
4801
  }
4403
4802
  });
@@ -4444,7 +4843,7 @@ var PiSession = class _PiSession {
4444
4843
  }
4445
4844
  /** The last assistant message decides the prompt (a later retry success replaces an earlier error).
4446
4845
  * pi-ai providers and Agent.handleRunFailure report a failure as "aborted" when the run's signal was
4447
- * cancelled, but Pi 0.99.1's lazyStream reports request-setup failures as "error" regardless — e.g. the
4846
+ * cancelled, but Pi 1.2.0's lazyStream reports request-setup failures as "error" regardless — e.g. the
4448
4847
  * request that follows a tool cancelled mid-run. Apply the same signal rule to those. */
4449
4848
  classify(message) {
4450
4849
  if (message.stopReason === "aborted" || message.stopReason === "error" && this.session.agent.signal?.aborted) return { outcome: "interrupted" };
@@ -4480,8 +4879,8 @@ var PiSession = class _PiSession {
4480
4879
  this.wantsAbort = false;
4481
4880
  this.abortFailure = void 0;
4482
4881
  this.result = { outcome: "completed" };
4483
- const settled = new Promise((resolve6) => {
4484
- this.settledResolve = resolve6;
4882
+ const settled = new Promise((resolve5) => {
4883
+ this.settledResolve = resolve5;
4485
4884
  });
4486
4885
  this.options.live?.({ type: "start" });
4487
4886
  let outcome = "failed";
@@ -4536,10 +4935,617 @@ var PiSession = class _PiSession {
4536
4935
  }
4537
4936
  };
4538
4937
 
4938
+ // src/server/infra/env/docker.ts
4939
+ import { execFile, spawn } from "node:child_process";
4940
+ import { createHash as createHash3 } from "node:crypto";
4941
+ import { createWriteStream } from "node:fs";
4942
+ import { mkdir as mkdir3, mkdtemp, readFile as readFile2, rename as rename2, rm as rm2, writeFile as writeFile2 } from "node:fs/promises";
4943
+ import { tmpdir } from "node:os";
4944
+ import { join as join4 } from "node:path";
4945
+ import { Connection, RemoteExecutionEnv, packagedDaemon } from "@earendil-works/pi-env";
4946
+
4947
+ // src/server/infra/env/computer-image.ts
4948
+ var COMPUTER_DOCKERFILE = `FROM mcr.microsoft.com/playwright:v1.61.1-noble
4949
+ ARG UID=1000
4950
+ ARG GID=1000
4951
+ RUN apt-get update \\
4952
+ && apt-get install -y --no-install-recommends build-essential ca-certificates curl fonts-noto-cjk git python3 python3-dev python3-pip python3-venv sudo \\
4953
+ && rm -rf /var/lib/apt/lists/* \\
4954
+ && npm install -g playwright@1.61.1 && npm cache clean --force \\
4955
+ && taken="$(getent passwd "$UID" | cut -d: -f1)"; if [ -n "$taken" ]; then userdel -r "$taken" 2>/dev/null || userdel "$taken"; fi \\
4956
+ && (getent group "$GID" > /dev/null || groupadd -g "$GID" bot) \\
4957
+ && useradd -m -d /workspace -u "$UID" -g "$GID" -s /bin/bash bot \\
4958
+ && install -d -o "$UID" -g "$GID" /home/bot/.npm-global /home/bot/.npm-global/bin /home/bot/.npm-global/lib \\
4959
+ && echo "bot ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/bot && chmod 0440 /etc/sudoers.d/bot \\
4960
+ && mkdir -p /workspace && chown "$UID:$GID" /workspace
4961
+ ENV NPM_CONFIG_PREFIX=/home/bot/.npm-global PATH=/home/bot/.npm-global/bin:$PATH \\
4962
+ NODE_PATH=/home/bot/.npm-global/lib/node_modules:/usr/lib/node_modules
4963
+ USER bot
4964
+ WORKDIR /workspace
4965
+ `;
4966
+
4967
+ // src/server/infra/env/durable.ts
4968
+ import { BACKGROUND_CONTEXT, withAbortSignal } from "@earendil-works/chord/context";
4969
+ function overDurable(id, kind, env, options) {
4970
+ let closed = false;
4971
+ return {
4972
+ id,
4973
+ kind,
4974
+ cwd: env.cwd,
4975
+ async exec(command, exec) {
4976
+ const result = await env.exec(command, {
4977
+ cwd: exec.cwd,
4978
+ ...options.passEnv && exec.env ? { env: exec.env, inheritEnv: false } : { inheritEnv: true },
4979
+ ...exec.timeout ? { timeout: exec.timeout } : {},
4980
+ onOutput: (text3) => exec.onData(Buffer.from(text3))
4981
+ }, context(exec.signal));
4982
+ if (result.ok) return { exitCode: result.value.exitCode };
4983
+ if (result.error.code === "aborted") throw new Error("aborted");
4984
+ if (result.error.code === "timeout") throw new Error(`timeout:${exec.timeout}`);
4985
+ throw fault(result.error);
4986
+ },
4987
+ async readFile(path) {
4988
+ return Buffer.from(unwrap(await env.readBinaryFile(path, BACKGROUND_CONTEXT)));
4989
+ },
4990
+ async head(path, bytes) {
4991
+ const reader = unwrap(await env.openBinaryReader(path, void 0, BACKGROUND_CONTEXT));
4992
+ try {
4993
+ return Buffer.from(unwrap(await reader.read(0, bytes, BACKGROUND_CONTEXT)));
4994
+ } finally {
4995
+ await reader.close(BACKGROUND_CONTEXT);
4996
+ }
4997
+ },
4998
+ async writeFile(path, content2) {
4999
+ unwrap(await env.writeFile(path, content2, BACKGROUND_CONTEXT));
5000
+ },
5001
+ async mkdir(path) {
5002
+ unwrap(await env.createDir(path, { recursive: true }, BACKGROUND_CONTEXT));
5003
+ },
5004
+ async exists(path) {
5005
+ return unwrap(await env.exists(path, BACKGROUND_CONTEXT));
5006
+ },
5007
+ async fileSize(path) {
5008
+ const real = await env.canonicalPath(path, BACKGROUND_CONTEXT);
5009
+ if (!real.ok) {
5010
+ if (real.error.code === "not_found") return null;
5011
+ throw fault(real.error);
5012
+ }
5013
+ const info = unwrap(await env.fileInfo(real.value, BACKGROUND_CONTEXT));
5014
+ return info.kind === "file" ? info.size : null;
5015
+ },
5016
+ async close() {
5017
+ if (closed) return;
5018
+ closed = true;
5019
+ await options.close?.();
5020
+ }
5021
+ };
5022
+ }
5023
+ var context = (signal) => signal ? withAbortSignal(signal, BACKGROUND_CONTEXT) : BACKGROUND_CONTEXT;
5024
+ function unwrap(result) {
5025
+ if (result.ok) return result.value;
5026
+ throw fault(result.error);
5027
+ }
5028
+ function fault(error) {
5029
+ return Object.assign(new Error(error.message), { code: error.code });
5030
+ }
5031
+
5032
+ // src/server/infra/env/docker.ts
5033
+ var COMPUTER_CWD = "/workspace";
5034
+ var DAEMON_DIR = "/opt/jarvy";
5035
+ function docker(args, timeoutMs = 6e4) {
5036
+ return new Promise((resolve5, reject) => execFile("docker", args, { timeout: timeoutMs, maxBuffer: 8 << 20 }, (error, stdout, stderr) => {
5037
+ if (error) reject(Object.assign(new Error((stderr || error.message).trim().split("\n").at(-1) ?? "docker failed"), { code: "docker_failed" }));
5038
+ else resolve5(stdout.trim());
5039
+ }));
5040
+ }
5041
+ async function dockerAvailable() {
5042
+ try {
5043
+ await docker(["version", "--format", "{{.Server.Version}}"], 1e4);
5044
+ return true;
5045
+ } catch {
5046
+ return false;
5047
+ }
5048
+ }
5049
+ function computerImage(uid, gid) {
5050
+ return `jarvy-computer:${createHash3("sha256").update(`${COMPUTER_DOCKERFILE}
5051
+ ${uid}:${gid}`).digest("hex").slice(0, 12)}`;
5052
+ }
5053
+ async function pruneImages(keep) {
5054
+ const tags = (await docker(["images", "--filter", "label=jarvy.computer=1", "--format", "{{.Repository}}:{{.Tag}}"])).split("\n").filter(Boolean);
5055
+ const removed = [];
5056
+ for (const tag of tags) if (tag !== keep) {
5057
+ try {
5058
+ await docker(["image", "rm", tag], 12e4);
5059
+ removed.push(tag);
5060
+ } catch {
5061
+ }
5062
+ }
5063
+ return removed;
5064
+ }
5065
+ async function imageExists(tag) {
5066
+ try {
5067
+ await docker(["image", "inspect", "--format", "{{.Id}}", tag]);
5068
+ return true;
5069
+ } catch {
5070
+ return false;
5071
+ }
5072
+ }
5073
+ async function buildImage(tag, uid, gid, logFile) {
5074
+ const dir = await mkdtemp(join4(tmpdir(), "jarvy-computer-"));
5075
+ try {
5076
+ await writeFile2(join4(dir, "Dockerfile"), COMPUTER_DOCKERFILE);
5077
+ const log = createWriteStream(logFile, { flags: "w", mode: 384 });
5078
+ const code = await new Promise((resolve5, reject) => {
5079
+ const child = spawn(
5080
+ "docker",
5081
+ ["build", "-t", tag, "--label", "jarvy.computer=1", "--build-arg", `UID=${uid}`, "--build-arg", `GID=${gid}`, dir],
5082
+ { stdio: ["ignore", "pipe", "pipe"] }
5083
+ );
5084
+ child.stdout.pipe(log, { end: false });
5085
+ child.stderr.pipe(log, { end: false });
5086
+ child.on("error", reject);
5087
+ child.on("close", resolve5);
5088
+ }).finally(() => log.end());
5089
+ if (code !== 0) throw Object.assign(new Error(`docker build exited with ${code}`), { code: "docker_failed" });
5090
+ } finally {
5091
+ await rm2(dir, { recursive: true, force: true });
5092
+ }
5093
+ }
5094
+ async function containerStates(label) {
5095
+ const out = await docker(["ps", "-a", "--filter", `label=${label}`, "--format", "{{.Names}} {{.State}}"]);
5096
+ const states = /* @__PURE__ */ new Map();
5097
+ for (const line of out.split("\n").filter(Boolean)) {
5098
+ const [name2, state] = line.split(" ");
5099
+ if (name2) states.set(name2, state === "running" ? "running" : "stopped");
5100
+ }
5101
+ return states;
5102
+ }
5103
+ async function containerState(name2) {
5104
+ try {
5105
+ return await docker(["inspect", "--format", "{{.State.Running}}", name2]) === "true" ? "running" : "stopped";
5106
+ } catch (error) {
5107
+ if (/no such (object|container)/i.test(error.message)) return "missing";
5108
+ throw error;
5109
+ }
5110
+ }
5111
+ async function runContainer(spec) {
5112
+ await docker([
5113
+ "run",
5114
+ "-d",
5115
+ "--name",
5116
+ spec.name,
5117
+ "--init",
5118
+ "--user",
5119
+ `${spec.uid}:${spec.gid}`,
5120
+ "--shm-size",
5121
+ "1g",
5122
+ ...Object.entries(spec.labels).flatMap(([key, value2]) => ["--label", `${key}=${value2}`]),
5123
+ "-v",
5124
+ `${spec.workspace}:${COMPUTER_CWD}`,
5125
+ "-v",
5126
+ `${spec.daemonDir}:${DAEMON_DIR}:ro`,
5127
+ "-w",
5128
+ COMPUTER_CWD,
5129
+ spec.image,
5130
+ "sleep",
5131
+ "infinity"
5132
+ ], 12e4);
5133
+ }
5134
+ async function startContainer(name2) {
5135
+ await docker(["start", name2], 6e4);
5136
+ }
5137
+ async function homeAtWorkspace(name2) {
5138
+ const home = await docker(["exec", name2, "sh", "-c", "getent passwd bot | cut -d: -f6"], 3e4);
5139
+ if (home !== COMPUTER_CWD) await docker(["exec", "-u", "root", name2, "usermod", "-d", COMPUTER_CWD, "bot"], 3e4);
5140
+ }
5141
+ async function stopContainer(name2) {
5142
+ await docker(["stop", "--time", "5", name2], 6e4);
5143
+ }
5144
+ async function removeContainer(name2) {
5145
+ try {
5146
+ await docker(["rm", "--force", name2], 6e4);
5147
+ } catch (error) {
5148
+ if (!/no such (object|container)/i.test(error.message)) throw error;
5149
+ }
5150
+ }
5151
+ async function syncDaemon(dir) {
5152
+ const arch = process.arch === "arm64" ? "arm64" : "x64";
5153
+ const wanted = await readFile2(packagedDaemon({ platform: "linux", arch }));
5154
+ const target2 = join4(dir, "pi-env");
5155
+ const current = await readFile2(target2).catch(() => null);
5156
+ if (current?.equals(wanted)) return;
5157
+ await mkdir3(dir, { recursive: true, mode: 448 });
5158
+ const temp = `${target2}.${process.pid}.tmp`;
5159
+ await writeFile2(temp, wanted, { mode: 493 });
5160
+ await rename2(temp, target2);
5161
+ }
5162
+ function containerEnvironment(id, container, log) {
5163
+ const connection = new Connection({ command: ["docker", "exec", "-i", container, `${DAEMON_DIR}/pi-env`], onLog: (text3) => log(text3.trim()) });
5164
+ return overDurable(id, "computer", new RemoteExecutionEnv({ connection, id, cwd: COMPUTER_CWD }), { passEnv: false, close: () => connection.close() });
5165
+ }
5166
+
5167
+ // src/server/infra/env/host.ts
5168
+ import { NodeExecutionEnv } from "@earendil-works/pi-durable/env/node";
5169
+ function hostEnvironment(cwd) {
5170
+ return overDurable("host", "host", new NodeExecutionEnv({ cwd }), { passEnv: true });
5171
+ }
5172
+
5173
+ // src/server/service/bot/environments.ts
5174
+ var ABOUT = {
5175
+ 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.",
5176
+ host: "The machine running Jarvy. 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."
5177
+ };
5178
+ var BotEnvironments = class {
5179
+ constructor(store, computers) {
5180
+ this.store = store;
5181
+ this.computers = computers;
5182
+ }
5183
+ open = /* @__PURE__ */ new Map();
5184
+ current(botId, workspacePath) {
5185
+ const target2 = this.store.environments.placement(botId).current;
5186
+ const open = this.open.get(botId);
5187
+ if (open?.id === target2.id) return open.view;
5188
+ if (open) this.retire(open);
5189
+ const opened = this.track(target2.id, target2.kind === "computer" ? this.computers.environment(target2.botId ?? botId, target2) : hostEnvironment(workspacePath));
5190
+ this.open.set(botId, opened);
5191
+ return opened.view;
5192
+ }
5193
+ placement(botId) {
5194
+ const environments = this.store.environments, { home, current } = environments.placement(botId);
5195
+ const hostId = environments.host().id;
5196
+ return {
5197
+ home: home.kind,
5198
+ current: current.kind,
5199
+ computer: this.computers.status(botId),
5200
+ docker: this.computers.available,
5201
+ hostGranted: environments.grants(botId).some((grant) => grant.id === hostId)
5202
+ };
5203
+ }
5204
+ /** The environments the Bot may be in: its sandbox, its default, and the ones its owner granted it. */
5205
+ usable(botId) {
5206
+ const environments = this.store.environments;
5207
+ const all = [environments.computerOf(botId), environments.placement(botId).home, ...environments.grants(botId)];
5208
+ return [...new Map(all.filter((record3) => !!record3).map((record3) => [record3.id, record3])).values()];
5209
+ }
5210
+ place(botId, record3, workspacePath) {
5211
+ if (record3.kind === "host") return { id: record3.id, kind: "host", name: "your owner's computer", directory: workspacePath };
5212
+ return { id: record3.id, kind: "sandbox", name: record3.botId === botId ? "your sandbox" : "another Bot's sandbox", directory: COMPUTER_CWD };
5213
+ }
5214
+ /** A Bot's environments as other Bots see them in bots.list. */
5215
+ summary(botId) {
5216
+ const name2 = (record3) => record3.kind === "host" ? "owner's computer" : record3.botId === botId ? "own sandbox" : "another Bot's sandbox";
5217
+ const { home, current } = this.store.environments.placement(botId);
5218
+ return { default: name2(home), current: name2(current), usable: this.usable(botId).map(name2) };
5219
+ }
5220
+ /** The Bot's default environment, which its instructions describe. */
5221
+ home(botId, workspacePath) {
5222
+ return this.place(botId, this.store.environments.placement(botId).home, workspacePath);
5223
+ }
5224
+ /** Where the Bot works now, for the end of each batch. */
5225
+ here(botId, workspacePath) {
5226
+ return this.place(botId, this.store.environments.placement(botId).current, workspacePath);
5227
+ }
5228
+ /** environments.list: where the Bot is and where else it may go. */
5229
+ list(botId, workspacePath) {
5230
+ const { home, current } = this.store.environments.placement(botId);
5231
+ return { current: current.id, environments: this.usable(botId).map((record3) => ({
5232
+ ...this.place(botId, record3, workspacePath),
5233
+ ...record3.id === home.id ? { default: true } : {},
5234
+ ...record3.kind === "computer" ? { state: this.computers.status(record3.botId ?? botId)?.state ?? "stopped" } : {}
5235
+ })) };
5236
+ }
5237
+ /** environments.switch: the Bot moves itself, like cd, until it switches back. A sandbox is started first; if it
5238
+ * can't start, the Bot stays where it was. */
5239
+ async switch(botId, environmentId, workspacePath) {
5240
+ const target2 = this.usable(botId).find((record3) => record3.id === environmentId);
5241
+ if (!target2) throw new ChatError(404, "No work environment with this id that you may use; see environments.list");
5242
+ const already = this.store.environments.placement(botId).current.id === target2.id;
5243
+ if (target2.kind === "computer") await this.computers.ensure(target2.botId ?? botId);
5244
+ this.store.environments.setCurrent(botId, target2.id);
5245
+ return { ...this.place(botId, target2, workspacePath), about: ABOUT[target2.kind], ...already ? { note: "You were already here." } : {} };
5246
+ }
5247
+ /** The owner makes the Bot's sandbox (given it on first use) or their computer its default, and moves it there. */
5248
+ setHome(botId, kind, workspacePath) {
5249
+ const environments = this.store.environments, from = this.here(botId, workspacePath);
5250
+ if (kind === "host") environments.setHome(botId, environments.host().id);
5251
+ else {
5252
+ const computer = environments.computerOf(botId);
5253
+ if (computer) {
5254
+ environments.setHome(botId, computer.id);
5255
+ void this.computers.ensure(botId).catch(() => {
5256
+ });
5257
+ } else this.computers.create(botId);
5258
+ }
5259
+ const to = this.here(botId, workspacePath);
5260
+ return from.id === to.id ? null : { from, to };
5261
+ }
5262
+ /** The owner grants or takes back their computer (A50); taking it back while the Bot works there sends it home. */
5263
+ grantHost(botId, granted, by, workspacePath) {
5264
+ const environments = this.store.environments, host = environments.host();
5265
+ if (granted) return { changed: environments.grant(host.id, botId, by), moved: null };
5266
+ const changed = environments.revoke(host.id, botId), { home, current } = environments.placement(botId);
5267
+ if (current.id !== host.id || home.id === host.id) return { changed, moved: null };
5268
+ const from = this.here(botId, workspacePath);
5269
+ environments.setCurrent(botId, home.id);
5270
+ return { changed, moved: { from, to: this.here(botId, workspacePath) } };
5271
+ }
5272
+ async release(botId) {
5273
+ const open = this.open.get(botId);
5274
+ this.open.delete(botId);
5275
+ await open?.env.close();
5276
+ await this.computers.remove(botId);
5277
+ }
5278
+ async close() {
5279
+ const all = [...this.open.values()];
5280
+ this.open.clear();
5281
+ await Promise.all(all.map((open) => open.env.close()));
5282
+ await this.computers.close();
5283
+ }
5284
+ retire(open) {
5285
+ open.retired = true;
5286
+ if (!open.busy) void open.env.close();
5287
+ }
5288
+ /** Counts the operations running on an environment, so one the Bot has left closes only when they are done. */
5289
+ track(id, env) {
5290
+ const opened = { id, env, view: env, busy: 0, retired: false };
5291
+ const run = async (work) => {
5292
+ opened.busy++;
5293
+ try {
5294
+ return await work();
5295
+ } finally {
5296
+ if (--opened.busy === 0 && opened.retired) void env.close();
5297
+ }
5298
+ };
5299
+ opened.view = {
5300
+ id: env.id,
5301
+ kind: env.kind,
5302
+ cwd: env.cwd,
5303
+ exec: (command, options) => run(() => env.exec(command, options)),
5304
+ readFile: (path) => run(() => env.readFile(path)),
5305
+ head: (path, bytes) => run(() => env.head(path, bytes)),
5306
+ writeFile: (path, content2) => run(() => env.writeFile(path, content2)),
5307
+ mkdir: (path) => run(() => env.mkdir(path)),
5308
+ exists: (path) => run(() => env.exists(path)),
5309
+ fileSize: (path) => run(() => env.fileSize(path)),
5310
+ close: () => env.close()
5311
+ };
5312
+ return opened;
5313
+ }
5314
+ };
5315
+
5316
+ // src/server/service/bot/computers.ts
5317
+ import { createHash as createHash4 } from "node:crypto";
5318
+ import { dirname as dirname4, join as join5 } from "node:path";
5319
+ var SWEEP_MS = 6e4;
5320
+ var Computers = class {
5321
+ constructor(deps) {
5322
+ this.deps = deps;
5323
+ this.dataDir = dirname4(deps.store.path);
5324
+ this.tag = createHash4("sha256").update(this.dataDir).digest("hex").slice(0, 12);
5325
+ }
5326
+ docker = null;
5327
+ probing = null;
5328
+ image = null;
5329
+ timer = null;
5330
+ states = /* @__PURE__ */ new Map();
5331
+ preparing = /* @__PURE__ */ new Map();
5332
+ connections = /* @__PURE__ */ new Map();
5333
+ /** Containers whose home was checked since Jarvy started (`homeAtWorkspace`). */
5334
+ homes = /* @__PURE__ */ new Map();
5335
+ dataDir;
5336
+ /** Tells this Jarvy's containers from those of other data directories on the same Docker. */
5337
+ tag;
5338
+ uid = process.getuid?.() ?? 1e3;
5339
+ gid = process.getgid?.() ?? 1e3;
5340
+ /** Looks for Docker and learns the state of every computer, once; every computer waits for it. */
5341
+ start() {
5342
+ this.probing ??= this.probe();
5343
+ return this.probing;
5344
+ }
5345
+ async probe() {
5346
+ this.docker = await dockerAvailable();
5347
+ if (!this.docker) return;
5348
+ try {
5349
+ await syncDaemon(join5(this.dataDir, "computers", "bin"));
5350
+ } catch (error) {
5351
+ this.docker = false;
5352
+ this.log(`computers disabled: ${error.message}`);
5353
+ return;
5354
+ }
5355
+ await this.sweep().catch((error) => this.log(`computer sweep failed: ${error.message}`));
5356
+ this.timer = setInterval(() => void this.sweep().catch((error) => this.log(`computer sweep failed: ${error.message}`)), SWEEP_MS);
5357
+ this.timer.unref();
5358
+ }
5359
+ /** Whether new computers can be made here; false until `start` found Docker. */
5360
+ get available() {
5361
+ return this.docker === true;
5362
+ }
5363
+ status(botId) {
5364
+ if (!this.deps.store.environments.computerOf(botId)) return null;
5365
+ if (!this.available) return { state: "unavailable" };
5366
+ return this.states.get(botId) ?? { state: "stopped" };
5367
+ }
5368
+ /** Gives a Bot its computer and makes it the Bot's default environment; the container is made in the background. */
5369
+ create(botId) {
5370
+ if (!this.available) throw new RuntimeFault("docker_unavailable", "Docker is not available on this machine.", 409);
5371
+ const computer = this.deps.store.environments.addComputer(botId, `jarvy-${this.tag.slice(0, 6)}-${botId.slice(0, 8)}`);
5372
+ this.deps.store.environments.setHome(botId, computer.id);
5373
+ void this.ensure(botId).catch(() => {
5374
+ });
5375
+ }
5376
+ /** What the Bot's tools use while it works on its computer: every call first makes sure the container runs. */
5377
+ environment(botId, record3) {
5378
+ const run = async (op) => {
5379
+ try {
5380
+ return await op(await this.connect(botId, record3));
5381
+ } catch (error) {
5382
+ if (error instanceof RuntimeFault || await containerState(record3.spec.container).catch(() => "missing") === "running") throw error;
5383
+ await this.disconnect(botId);
5384
+ this.set(botId, { state: "stopped" });
5385
+ return op(await this.connect(botId, record3));
5386
+ }
5387
+ };
5388
+ return {
5389
+ id: record3.id,
5390
+ kind: "computer",
5391
+ cwd: COMPUTER_CWD,
5392
+ exec: (command, options) => run((env) => env.exec(command, options)),
5393
+ readFile: (path) => run((env) => env.readFile(path)),
5394
+ head: (path, bytes) => run((env) => env.head(path, bytes)),
5395
+ writeFile: (path, content2) => run((env) => env.writeFile(path, content2)),
5396
+ mkdir: (path) => run((env) => env.mkdir(path)),
5397
+ exists: (path) => run((env) => env.exists(path)),
5398
+ fileSize: (path) => run((env) => env.fileSize(path)),
5399
+ close: () => this.disconnect(botId)
5400
+ };
5401
+ }
5402
+ async connect(botId, record3) {
5403
+ const container = await this.ensure(botId);
5404
+ let home = this.homes.get(container);
5405
+ if (!home) {
5406
+ home = homeAtWorkspace(container).catch((error) => this.log(`home of ${botId} not moved: ${error.message}`));
5407
+ this.homes.set(container, home);
5408
+ }
5409
+ await home;
5410
+ let env = this.connections.get(botId);
5411
+ if (!env) {
5412
+ env = containerEnvironment(record3.id, container, (text3) => this.log(`[${botId.slice(0, 8)}] ${text3}`));
5413
+ this.connections.set(botId, env);
5414
+ }
5415
+ return env;
5416
+ }
5417
+ /** Makes sure the container exists and runs, building the image first when needed; returns its name. */
5418
+ ensure(botId) {
5419
+ const pending = this.preparing.get(botId);
5420
+ if (pending) return pending;
5421
+ const computer = this.deps.store.environments.computerOf(botId);
5422
+ if (!computer) return Promise.reject(new RuntimeFault("computer_missing", "This Bot has no sandbox.", 404));
5423
+ const container = computer.spec.container;
5424
+ if (this.states.get(botId)?.state === "running") return Promise.resolve(container);
5425
+ if (this.docker !== false) this.set(botId, { state: "preparing" });
5426
+ const task = (async () => {
5427
+ try {
5428
+ await this.start();
5429
+ if (!this.available) throw new RuntimeFault("docker_unavailable", "Docker is not available on this machine.", 503);
5430
+ const image = await this.imageReady();
5431
+ const state = await containerState(container);
5432
+ if (state === "missing") await runContainer({
5433
+ name: container,
5434
+ image,
5435
+ workspace: join5(this.dataDir, "bots", botId, "workspace"),
5436
+ daemonDir: join5(this.dataDir, "computers", "bin"),
5437
+ uid: this.uid,
5438
+ gid: this.gid,
5439
+ labels: { "jarvy.data": this.tag, "jarvy.bot": botId }
5440
+ });
5441
+ else if (state === "stopped") await startContainer(container);
5442
+ this.set(botId, { state: "running" });
5443
+ return container;
5444
+ } catch (error) {
5445
+ this.set(botId, { state: "failed", error: error instanceof RuntimeFault ? error.code : "computer_failed" });
5446
+ this.log(`computer of ${botId} failed: ${error.message}`);
5447
+ if (error instanceof RuntimeFault) throw error;
5448
+ throw new RuntimeFault("computer_failed", `Your sandbox could not start: ${error.message}`, 503);
5449
+ }
5450
+ })().finally(() => this.preparing.delete(botId));
5451
+ this.preparing.set(botId, task);
5452
+ return task;
5453
+ }
5454
+ async stop(botId) {
5455
+ const computer = this.need(botId);
5456
+ await this.disconnect(botId);
5457
+ await stopContainer(computer.spec.container);
5458
+ this.set(botId, { state: "stopped" });
5459
+ }
5460
+ /** Removes the container, so the next start makes a fresh one from the image: what was installed goes, the
5461
+ * workspace stays. */
5462
+ async reset(botId) {
5463
+ const computer = this.need(botId);
5464
+ await this.disconnect(botId);
5465
+ await removeContainer(computer.spec.container);
5466
+ this.homes.delete(computer.spec.container);
5467
+ this.set(botId, { state: "stopped" });
5468
+ }
5469
+ /** The Bot is being deleted: its container goes with it. */
5470
+ async remove(botId) {
5471
+ const computer = this.deps.store.environments.computerOf(botId);
5472
+ if (!computer) return;
5473
+ await this.disconnect(botId);
5474
+ if (this.available) await removeContainer(computer.spec.container);
5475
+ this.deps.store.environments.dropComputer(botId);
5476
+ this.states.delete(botId);
5477
+ }
5478
+ /** Closes the connections; containers keep running. */
5479
+ async close() {
5480
+ if (this.timer) clearInterval(this.timer);
5481
+ this.timer = null;
5482
+ await Promise.all([...this.connections.keys()].map((botId) => this.disconnect(botId)));
5483
+ }
5484
+ need(botId) {
5485
+ const computer = this.deps.store.environments.computerOf(botId);
5486
+ if (!computer) throw new RuntimeFault("computer_missing", "This Bot has no sandbox.", 404);
5487
+ if (!this.available) throw new RuntimeFault("docker_unavailable", "Docker is not available on this machine.", 409);
5488
+ return computer;
5489
+ }
5490
+ imageReady() {
5491
+ this.image ??= (async () => {
5492
+ const tag = computerImage(this.uid, this.gid);
5493
+ if (!await imageExists(tag)) {
5494
+ this.log(`building ${tag}`);
5495
+ await buildImage(tag, this.uid, this.gid, join5(this.dataDir, "computers", "build.log"));
5496
+ void pruneImages(tag).then((removed) => {
5497
+ if (removed.length) this.log(`removed ${removed.join(", ")}`);
5498
+ }).catch(() => {
5499
+ });
5500
+ }
5501
+ return tag;
5502
+ })().catch((error) => {
5503
+ this.image = null;
5504
+ throw error;
5505
+ });
5506
+ return this.image;
5507
+ }
5508
+ async disconnect(botId) {
5509
+ const env = this.connections.get(botId);
5510
+ this.connections.delete(botId);
5511
+ await env?.close();
5512
+ }
5513
+ set(botId, status) {
5514
+ const before = this.states.get(botId);
5515
+ this.states.set(botId, status);
5516
+ if (before?.state !== status.state || before?.error !== status.error) this.deps.changed();
5517
+ }
5518
+ /** Learns what happened to containers outside Jarvy, and removes containers of this Jarvy that no Bot has any more
5519
+ * (a Bot deleted while Docker was out of reach). */
5520
+ async sweep() {
5521
+ const states = await containerStates(`jarvy.data=${this.tag}`);
5522
+ const computers = this.deps.store.environments.computers();
5523
+ const owned = new Set(computers.map((computer) => computer.spec.container));
5524
+ for (const name2 of states.keys()) if (!owned.has(name2)) {
5525
+ this.log(`removing ${name2}, which no Bot has`);
5526
+ await removeContainer(name2).catch((error) => this.log(`removing ${name2} failed: ${error.message}`));
5527
+ }
5528
+ for (const computer of computers) {
5529
+ const botId = computer.botId;
5530
+ if (this.preparing.has(botId)) continue;
5531
+ const running = states.get(computer.spec.container) === "running";
5532
+ if (!running) {
5533
+ if (this.states.get(botId)?.state !== "failed") this.set(botId, { state: "stopped" });
5534
+ await this.disconnect(botId);
5535
+ continue;
5536
+ }
5537
+ this.set(botId, { state: "running" });
5538
+ }
5539
+ }
5540
+ log(text3) {
5541
+ (this.deps.log ?? console.log)(`[computers] ${text3}`);
5542
+ }
5543
+ };
5544
+
4539
5545
  // src/server/infra/storage/history-repository.ts
4540
5546
  import { DatabaseSync as DatabaseSync2 } from "node:sqlite";
4541
5547
  import { chmodSync as chmodSync2, mkdirSync as mkdirSync2 } from "node:fs";
4542
- import { dirname as dirname3 } from "node:path";
5548
+ import { dirname as dirname5 } from "node:path";
4543
5549
  var json = (value2) => value2 == null ? null : JSON.stringify(value2);
4544
5550
  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')=?)";
4545
5551
  var BLOCKS = "FROM json_each(history.message,'$.content') b WHERE json_extract(b.value,'$.type')";
@@ -4551,7 +5557,7 @@ var SUMMARY = `SELECT id,timestamp,role,model,
4551
5557
  var HistoryRepository = class {
4552
5558
  db;
4553
5559
  constructor(path) {
4554
- mkdirSync2(dirname3(path), { recursive: true, mode: 448 });
5560
+ mkdirSync2(dirname5(path), { recursive: true, mode: 448 });
4555
5561
  this.db = new DatabaseSync2(path);
4556
5562
  chmodSync2(path, 384);
4557
5563
  const version = this.db.prepare("PRAGMA user_version").get().user_version;
@@ -4607,17 +5613,17 @@ var HistoryRepository = class {
4607
5613
  const row = this.db.prepare("SELECT * FROM history WHERE id=?").get(id);
4608
5614
  return row ? this.decode(row) : null;
4609
5615
  }
4610
- links(record2) {
5616
+ links(record3) {
4611
5617
  const links = [];
4612
- for (const block of record2.message.content) {
5618
+ for (const block of record3.message.content) {
4613
5619
  if (block.type !== "tool_call" && block.type !== "tool_result") continue;
4614
5620
  const callId = block.tool_call_id;
4615
5621
  if (block.type === "tool_result") {
4616
- const call = this.db.prepare(`SELECT id,role FROM history WHERE id<? AND ${CALL} ORDER BY id DESC LIMIT 1`).get(record2.id, "tool_call", callId);
5622
+ const call = this.db.prepare(`SELECT id,role FROM history WHERE id<? AND ${CALL} ORDER BY id DESC LIMIT 1`).get(record3.id, "tool_call", callId);
4617
5623
  if (call) links.push({ ...call, tool_call_id: callId });
4618
5624
  } else {
4619
- const next = this.db.prepare(`SELECT id FROM history WHERE id>? AND ${CALL} ORDER BY id LIMIT 1`).get(record2.id, "tool_call", callId);
4620
- const results = this.db.prepare(`SELECT id,role FROM history WHERE id>? AND id<? AND ${CALL} ORDER BY id`).all(record2.id, next?.id ?? Number.MAX_SAFE_INTEGER, "tool_result", callId);
5625
+ const next = this.db.prepare(`SELECT id FROM history WHERE id>? AND ${CALL} ORDER BY id LIMIT 1`).get(record3.id, "tool_call", callId);
5626
+ const results = this.db.prepare(`SELECT id,role FROM history WHERE id>? AND id<? AND ${CALL} ORDER BY id`).all(record3.id, next?.id ?? Number.MAX_SAFE_INTEGER, "tool_result", callId);
4621
5627
  links.push(...results.map((result) => ({ ...result, tool_call_id: callId })));
4622
5628
  }
4623
5629
  }
@@ -4661,14 +5667,14 @@ var Memory = class {
4661
5667
  return this.history().usage(since);
4662
5668
  }
4663
5669
  read(id) {
4664
- const record2 = this.history().read(id);
4665
- return record2 ? { record: record2, links: this.history().links(record2) } : null;
5670
+ const record3 = this.history().read(id);
5671
+ return record3 ? { record: record3, links: this.history().links(record3) } : null;
4666
5672
  }
4667
5673
  readMany(ids, mode) {
4668
5674
  const found = mode === "summary" ? this.history().summaries(ids).map((item) => item && summaryLine(item)) : this.history().details(ids);
4669
5675
  const result = { mode, records: [], missingIds: [] };
4670
- found.forEach((record2, index) => {
4671
- if (record2) result.records.push(record2);
5676
+ found.forEach((record3, index) => {
5677
+ if (record3) result.records.push(record3);
4672
5678
  else result.missingIds.push(ids[index]);
4673
5679
  });
4674
5680
  return result;
@@ -4680,7 +5686,7 @@ var Memory = class {
4680
5686
  };
4681
5687
 
4682
5688
  // src/server/service/bot/bot.ts
4683
- import { createHash as createHash3 } from "node:crypto";
5689
+ import { createHash as createHash5 } from "node:crypto";
4684
5690
  var Bot = class extends Member {
4685
5691
  kind = "bot";
4686
5692
  identity;
@@ -4771,7 +5777,7 @@ var Bot = class extends Member {
4771
5777
  ...message.question ? { question: message.question } : {},
4772
5778
  ...message.alsoToChannel ? { alsoToChannel: true } : {},
4773
5779
  ...message.replyTo ? { replyTo: message.replyTo } : {},
4774
- clientMessageId: source2 ? createHash3("sha256").update(JSON.stringify([this.id, source2.historyId, source2.toolCallId])).digest("hex") : void 0
5780
+ clientMessageId: source2 ? createHash5("sha256").update(JSON.stringify([this.id, source2.historyId, source2.toolCallId])).digest("hex") : void 0
4775
5781
  });
4776
5782
  }
4777
5783
  /** 由 Bot 协调配置与运行实例;配置在应用成功后提交。执行中切换从下一次模型请求生效(A28)。 */
@@ -4794,19 +5800,20 @@ var Bot = class extends Member {
4794
5800
  identity: () => this.identity.snapshot(),
4795
5801
  model: () => this.model.current(),
4796
5802
  workspace: this.workspace.location(),
5803
+ home: () => this.workspace.home(),
4797
5804
  tools: this.tools.definitions(),
4798
5805
  onStatus: (status) => this.updateState(status),
4799
- onHistory: (record2) => this.recordHistory(record2)
5806
+ onHistory: (record3) => this.recordHistory(record3)
4800
5807
  };
4801
5808
  }
4802
5809
  updateState(status) {
4803
5810
  this.state.update(status);
4804
5811
  }
4805
- async recordHistory(record2) {
4806
- const historyId = await this.memory.append(record2);
4807
- if (record2.role !== "assistant") return;
5812
+ async recordHistory(record3) {
5813
+ const historyId = await this.memory.append(record3);
5814
+ if (record3.role !== "assistant") return;
4808
5815
  this.callHistory.clear();
4809
- for (const block of record2.message.content) {
5816
+ for (const block of record3.message.content) {
4810
5817
  if (block.type === "tool_call") this.callHistory.set(block.tool_call_id, historyId);
4811
5818
  }
4812
5819
  }
@@ -4831,16 +5838,16 @@ var Identity = class {
4831
5838
  }
4832
5839
  snapshot() {
4833
5840
  const bot = this.store.getBot(this.botId);
4834
- return { name: bot.name, description: bot.description, persona: bot.systemPrompt };
5841
+ return { name: bot.name, description: bot.description, persona: bot.systemPrompt, agent: this.botId === AGENT_BOT_ID };
4835
5842
  }
4836
5843
  };
4837
5844
 
4838
5845
  // src/server/service/bot/model.ts
4839
5846
  var Model = class {
4840
- constructor(botId, store, resolve6) {
5847
+ constructor(botId, store, resolve5) {
4841
5848
  this.botId = botId;
4842
5849
  this.store = store;
4843
- this.resolve = resolve6;
5850
+ this.resolve = resolve5;
4844
5851
  }
4845
5852
  current() {
4846
5853
  const bot = this.store.getBot(this.botId);
@@ -4861,43 +5868,86 @@ var Model = class {
4861
5868
  }
4862
5869
  };
4863
5870
 
5871
+ // src/server/service/bot/workspace.ts
5872
+ import { posix } from "node:path";
5873
+
4864
5874
  // src/server/infra/files/workspace.ts
4865
- import { mkdir as mkdir3, writeFile } from "node:fs/promises";
4866
- import { join as join4 } from "node:path";
5875
+ import { mkdir as mkdir4, writeFile as writeFile3 } from "node:fs/promises";
5876
+ import { join as join6 } from "node:path";
4867
5877
  var LocalWorkspace = class {
4868
5878
  constructor(cwd) {
4869
5879
  this.cwd = cwd;
4870
5880
  }
4871
5881
  get tempDir() {
4872
- return join4(this.cwd, "temp");
5882
+ return join6(this.cwd, "temp");
4873
5883
  }
4874
5884
  async prepare() {
4875
- await mkdir3(this.tempDir, { recursive: true, mode: 448 });
5885
+ await mkdir4(this.tempDir, { recursive: true, mode: 448 });
4876
5886
  }
4877
5887
  async writeTempFile(name2, content2, signal) {
4878
5888
  await this.prepare();
4879
- const path = join4(this.tempDir, name2);
4880
- await writeFile(path, content2, { encoding: "utf8", flag: "wx", mode: 384, signal });
5889
+ const path = join6(this.tempDir, name2);
5890
+ await writeFile3(path, content2, { encoding: "utf8", flag: "wx", mode: 384, signal });
4881
5891
  return path;
4882
5892
  }
4883
5893
  };
4884
5894
 
4885
5895
  // src/server/service/bot/workspace.ts
5896
+ var MAX_FILE = 10 * 1024 * 1024;
4886
5897
  var Workspace = class {
4887
- local;
4888
- constructor(cwd) {
5898
+ constructor(cwd, current, home) {
5899
+ this.current = current;
5900
+ this.home = home;
4889
5901
  this.local = new LocalWorkspace(cwd);
4890
5902
  }
5903
+ local;
4891
5904
  prepare() {
4892
5905
  return this.local.prepare();
4893
5906
  }
4894
- writeTempFile(name2, content2, signal) {
4895
- return this.local.writeTempFile(name2, content2, signal);
5907
+ /** A file in the temporary directory of the environment the Bot works in, by the path it sees there. */
5908
+ async writeTempFile(name2, content2, signal) {
5909
+ const env = this.current();
5910
+ if (env.kind === "host") return this.local.writeTempFile(name2, content2, signal);
5911
+ const path = `${env.cwd}/temp/${name2}`;
5912
+ await env.writeFile(path, content2);
5913
+ return path;
5914
+ }
5915
+ /** Puts a chat file into the Bot's environment: at `path`, or in temp/files/ under its own name (with part of its
5916
+ * id when another file has that name). Returns where it went. */
5917
+ async put(name2, id, data, path) {
5918
+ const env = this.current();
5919
+ let target2 = path ? posix.resolve(env.cwd, path) : `${env.cwd}/temp/files/${plain(name2)}`;
5920
+ if (!path && await env.exists(target2)) {
5921
+ const dot = plain(name2).lastIndexOf("."), stem = dot > 0 ? plain(name2).slice(0, dot) : plain(name2), extension = dot > 0 ? plain(name2).slice(dot) : "";
5922
+ target2 = `${env.cwd}/temp/files/${stem}-${id.slice(0, 8)}${extension}`;
5923
+ }
5924
+ await env.mkdir(posix.dirname(target2));
5925
+ await env.writeFile(target2, data);
5926
+ return target2;
5927
+ }
5928
+ /** The files a Bot attaches to a message, read from its environment: paths there, relative to its directory. */
5929
+ async readOutgoing(items, signal) {
5930
+ if (items.length > 5) throw new ChatError(400, "Too many attachments");
5931
+ const env = this.current(), files = [];
5932
+ for (const item of items) {
5933
+ signal?.throwIfAborted();
5934
+ const path = posix.resolve(env.cwd, item.path), size = await env.fileSize(path);
5935
+ if (size === null) throw new ChatError(400, `No file at ${path} in your environment`);
5936
+ if (!size || size > MAX_FILE) throw new ChatError(400, "Invalid attachment size or type");
5937
+ const data = await env.readFile(path);
5938
+ if (data.length !== size) throw new ChatError(409, "Attachment changed while reading");
5939
+ files.push({ name: item.name, mimeType: "application/octet-stream", data });
5940
+ }
5941
+ return files;
4896
5942
  }
4897
5943
  location() {
4898
5944
  return { type: "local", cwd: this.local.cwd, tempDir: this.local.tempDir };
4899
5945
  }
4900
5946
  };
5947
+ function plain(name2) {
5948
+ const cleaned = name2.replace(/[/\\\0]/g, "_").trim();
5949
+ return cleaned && cleaned !== "." && cleaned !== ".." ? cleaned : "file";
5950
+ }
4901
5951
 
4902
5952
  // src/server/service/bot/state.ts
4903
5953
  var BotState = class {
@@ -4916,7 +5966,7 @@ var BotState = class {
4916
5966
  };
4917
5967
 
4918
5968
  // src/server/service/bot/tools.ts
4919
- import { Type as Type4 } from "typebox";
5969
+ import { Type as Type5 } from "typebox";
4920
5970
  import { Value } from "typebox/value";
4921
5971
 
4922
5972
  // src/server/service/bot/history-read.ts
@@ -4965,20 +6015,20 @@ ${content(block.result.content, heading + "#")}`;
4965
6015
  function formatHistory(result, format) {
4966
6016
  if (format === "json") return JSON.stringify(result, null, 2);
4967
6017
  const sections = [`# History (${result.mode})`];
4968
- for (const record2 of result.records) {
4969
- sections.push(`## #${record2.id} \xB7 ${record2.role}`, `Time: ${new Date(record2.timestamp).toISOString()}`);
4970
- if (record2.model) sections.push(`Model:
6018
+ for (const record3 of result.records) {
6019
+ sections.push(`## #${record3.id} \xB7 ${record3.role}`, `Time: ${new Date(record3.timestamp).toISOString()}`);
6020
+ if (record3.model) sections.push(`Model:
4971
6021
 
4972
- ${jsonBlock(record2.model)}`);
4973
- if ("preview" in record2) {
4974
- sections.push(fence(record2.preview));
6022
+ ${jsonBlock(record3.model)}`);
6023
+ if ("preview" in record3) {
6024
+ sections.push(fence(record3.preview));
4975
6025
  continue;
4976
6026
  }
4977
- sections.push(content(record2.message.content));
4978
- if (record2.message.tools !== void 0) sections.push("### Tool definitions", jsonBlock(record2.message.tools));
4979
- if (record2.message.tools_removed !== void 0) sections.push("### Tools removed", jsonBlock(record2.message.tools_removed));
4980
- if (record2.message.stop_reason !== void 0) sections.push("### Stop reason", fence(record2.message.stop_reason));
4981
- if (record2.message.error !== void 0) sections.push("### Error", fence(record2.message.error));
6027
+ sections.push(content(record3.message.content));
6028
+ if (record3.message.tools !== void 0) sections.push("### Tool definitions", jsonBlock(record3.message.tools));
6029
+ if (record3.message.tools_removed !== void 0) sections.push("### Tools removed", jsonBlock(record3.message.tools_removed));
6030
+ if (record3.message.stop_reason !== void 0) sections.push("### Stop reason", fence(record3.message.stop_reason));
6031
+ if (record3.message.error !== void 0) sections.push("### Error", fence(record3.message.error));
4982
6032
  }
4983
6033
  if (result.missingIds.length) sections.push(`## Missing IDs
4984
6034
 
@@ -5004,6 +6054,38 @@ async function readHistory(memory, workspace, input, signal) {
5004
6054
  return workspace.writeTempFile(name2, rendered, signal);
5005
6055
  }
5006
6056
 
6057
+ // src/server/service/bot/file-view.ts
6058
+ var TEXT_LIMIT = 64 * 1024;
6059
+ var PICTURE_LIMIT = 4 * 1024 * 1024;
6060
+ function viewFile(file, data) {
6061
+ const head = `${file.name} (${file.mimeType || "application/octet-stream"}, ${byteSize(file.size)})`;
6062
+ const picture = pictureType(data);
6063
+ if (picture) {
6064
+ if (data.length > PICTURE_LIMIT) throw new ChatError(400, "This picture is over 4 MB; use files.get and look at a smaller copy");
6065
+ return { kind: "media", text: head, images: [{ data: data.toString("base64"), mimeType: picture }] };
6066
+ }
6067
+ const text3 = data.length <= TEXT_LIMIT ? asText(data) : null;
6068
+ if (text3 !== null) return `${head}
6069
+
6070
+ ${text3}`;
6071
+ 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");
6072
+ }
6073
+ function pictureType(data) {
6074
+ if (data.length >= 8 && data.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) return "image/png";
6075
+ if (data.length >= 3 && data[0] === 255 && data[1] === 216 && data[2] === 255) return "image/jpeg";
6076
+ if (data.length >= 6 && /^GIF8[79]a$/.test(data.subarray(0, 6).toString("latin1"))) return "image/gif";
6077
+ if (data.length >= 12 && data.subarray(0, 4).toString("latin1") === "RIFF" && data.subarray(8, 12).toString("latin1") === "WEBP") return "image/webp";
6078
+ return null;
6079
+ }
6080
+ function asText(data) {
6081
+ if (data.includes(0)) return null;
6082
+ try {
6083
+ return new TextDecoder("utf-8", { fatal: true }).decode(data);
6084
+ } catch {
6085
+ return null;
6086
+ }
6087
+ }
6088
+
5007
6089
  // src/server/service/bot/chat-views.ts
5008
6090
  var iso = (time) => new Date(time).toISOString();
5009
6091
  var name = (chats) => (memberId) => chats.member(memberId)?.name ?? memberId;
@@ -5114,6 +6196,7 @@ var ChatTools = class {
5114
6196
  if (missing.length) throw new ChatError(404, `No member named: ${missing.join(", ")} (see members.list)`);
5115
6197
  return found.map((item) => item.member);
5116
6198
  }
6199
+ /** A channel group by ID or name. */
5117
6200
  section(value2) {
5118
6201
  const sections = this.chats.channels.sections(this.bot);
5119
6202
  const matches = sections.filter((section) => section.id === value2 || same(value2, section.name));
@@ -5228,7 +6311,8 @@ var ChatTools = class {
5228
6311
  return { messages: await this.rows(page, signal), ...messages.length > limit ? { hasMore: true } : {} };
5229
6312
  }
5230
6313
  async rows(messages, signal) {
5231
- return Promise.all(messages.map(async (message) => messageRow(this.chats, this.bot, message, await this.scope.files.paths(this.bot, message, signal))));
6314
+ signal?.throwIfAborted();
6315
+ return messages.map((message) => messageRow(this.chats, this.bot, message, listing(message)));
5232
6316
  }
5233
6317
  search(input) {
5234
6318
  const found = input.chatId === void 0 ? this.chats.searchAll(this.bot, input.query, input.limit ?? 30) : this.chats.search(this.bot, this.chat(input.chatId).id, input.query, input.limit ?? 30);
@@ -5237,13 +6321,7 @@ var ChatTools = class {
5237
6321
  if (!chats.has(id)) chats.set(id, this.chats.get(this.bot, id));
5238
6322
  return chats.get(id);
5239
6323
  };
5240
- return found.map((message) => messageRow(
5241
- this.chats,
5242
- this.bot,
5243
- message,
5244
- message.attachments.map((file) => ({ name: file.name })),
5245
- input.chatId === void 0 ? chatOf(message.chatId) : null
5246
- ));
6324
+ return found.map((message) => messageRow(this.chats, this.bot, message, listing(message), input.chatId === void 0 ? chatOf(message.chatId) : null));
5247
6325
  }
5248
6326
  };
5249
6327
 
@@ -5252,6 +6330,7 @@ import { Type as Type3 } from "typebox";
5252
6330
  var None = Type3.Object({}, { additionalProperties: false });
5253
6331
  var Name = (description) => Type3.String({ minLength: 1, maxLength: 80, description });
5254
6332
  var MessageId = Type3.String({ pattern: "^m\\d+$" });
6333
+ var FileId = Type3.String({ minLength: 1, maxLength: 80, description: "The file's id: from <file id=\u2026/> in a message, or the attachments of messages.read and messages.search" });
5255
6334
  var QuestionOption = Type3.Object({
5256
6335
  label: Type3.String({ minLength: 1, maxLength: 80, description: "Text shown on the option" }),
5257
6336
  value: Type3.Optional(Type3.String({ minLength: 1, maxLength: 500, description: "What you get back when it is picked, written as the person would naturally say it; label by default" })),
@@ -5261,7 +6340,10 @@ var QuestionOption = Type3.Object({
5261
6340
  var definitions = {
5262
6341
  "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 },
5263
6342
  "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 },
5264
- "bots.list": { 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; 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. You come first, then the rest by name.", schema: None },
6343
+ "bots.list": {
6344
+ 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 Jarvy: 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.",
6345
+ schema: Type3.Object({ environments: Type3.Optional(Type3.Boolean({ description: "Also give each Bot's work environments" })) }, { additionalProperties: false })
6346
+ },
5265
6347
  "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 },
5266
6348
  "chats.info": {
5267
6349
  description: "Details of one chat: members, the channel topic, the full notice or thread summary, your unread count and read position, and whether you subscribe; threads also give their title, status, channel and source message. Reading a notice or summary counts as having seen that version.",
@@ -5289,7 +6371,7 @@ var definitions = {
5289
6371
  }, { additionalProperties: false })
5290
6372
  },
5291
6373
  "chats.addMembers": {
5292
- description: "Add members to a channel you are in, by name (see members.list). Only people can remove members from a channel or delete channels and channel groups.",
6374
+ description: "Add members to a channel you are in, by name (see members.list). Only people can remove members from a channel or delete channels and channel groups, and the agent Bot (see bots.list) once the owner confirms.",
5293
6375
  schema: Type3.Object({ chatId: Type3.String(), members: Type3.Array(Type3.String({ minLength: 1, maxLength: 80 }), { minItems: 1, maxItems: 50 }) }, { additionalProperties: false })
5294
6376
  },
5295
6377
  "chats.subscribe": {
@@ -5311,7 +6393,7 @@ var definitions = {
5311
6393
  schema: Type3.Object({ section: Type3.String({ minLength: 1, maxLength: 80 }), name: Name("New name") }, { additionalProperties: false })
5312
6394
  },
5313
6395
  "messages.read": {
5314
- description: "Read messages and the paths of the attachments you can read. Three ways: \u2460 chatId + after: read on from that sequence number (from the start by default); \u2461 around with a message ID: that message and context messages on each side (5 by default), to see the context; this doesn't move your read position; \u2462 unread=true: your unread messages, in one chat with chatId, or in every chat (grouped by chat) without it. \u2460 and \u2462 move your read position to the last message read; hasMore=true means there is more, so call again.",
6396
+ description: "Read messages; their files are listed by id, name, type and size, for files.get and files.view. Three ways: \u2460 chatId + after: read on from that sequence number (from the start by default); \u2461 around with a message ID: that message and context messages on each side (5 by default), to see the context; this doesn't move your read position; \u2462 unread=true: your unread messages, in one chat with chatId, or in every chat (grouped by chat) without it. \u2460 and \u2462 move your read position to the last message read; hasMore=true means there is more, so call again.",
5315
6397
  schema: Type3.Object({
5316
6398
  chatId: Type3.Optional(Type3.String()),
5317
6399
  after: Type3.Optional(Type3.Integer({ minimum: 0 })),
@@ -5322,9 +6404,22 @@ var definitions = {
5322
6404
  }, { additionalProperties: false })
5323
6405
  },
5324
6406
  "messages.search": {
5325
- description: "Search message text. With chatId, only that chat; without it, every chat you can see, and each result says which chat it is in. Use messages.read to read attachments.",
6407
+ description: "Search message text. With chatId, only that chat; without it, every chat you can see, and each result says which chat it is in. Results list their files like messages.read.",
5326
6408
  schema: Type3.Object({ chatId: Type3.Optional(Type3.String()), query: Type3.String({ minLength: 1, maxLength: 500 }), limit: Type3.Optional(Type3.Integer({ minimum: 1, maximum: 100 })) }, { additionalProperties: false })
5327
6409
  },
6410
+ "files.get": {
6411
+ description: "Put a file from a message into your environment, to work on it with your tools; the result has its path there. It goes to temp/files/ under its own name unless you give a path.",
6412
+ schema: Type3.Object({ id: FileId, path: Type3.Optional(Type3.String({ minLength: 1, maxLength: 1e3, description: "Where to put it instead: absolute, or relative to your directory" })) }, { additionalProperties: false })
6413
+ },
6414
+ "environments.list": { description: "List the work environments you may use: your sandbox, your default and the ones your owner granted you. current is the id of the one you are in. Each has its id, name, the directory your file and command tools start in there, default=true for your default, and state for a sandbox (it starts when you switch to it).", schema: None },
6415
+ "environments.switch": {
6416
+ 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.",
6417
+ schema: Type3.Object({ id: Type3.String({ minLength: 1, maxLength: 80, description: "The environment's id from environments.list" }) }, { additionalProperties: false })
6418
+ },
6419
+ "files.view": {
6420
+ description: "Look at a file from a message without putting it anywhere: a picture (PNG, JPEG, GIF or WebP, up to 4 MB) is shown to you, and a text file up to 64 KB comes back as text. Anything else needs files.get.",
6421
+ schema: Type3.Object({ id: FileId }, { additionalProperties: false })
6422
+ },
5328
6423
  "messages.react": {
5329
6424
  description: `Add or remove an emoji reaction on a message (people and Bots can both react). on=false removes it. Giving an emoji the message already has from you removes it instead (and the other way round); marked or unmarked and note in the result say which happened. Use this toggle to signal states such as "got it", "on it" and "done". Only the message's author is told: if they are running, it joins their current turn without interrupting; if they are idle, it starts a turn.`,
5330
6425
  schema: Type3.Object({ messageId: MessageId, emoji: Type3.String({ minLength: 1, maxLength: 32, description: "A single emoji, such as \u{1F44D}" }), on: Type3.Optional(Type3.Boolean()) }, { additionalProperties: false })
@@ -5341,7 +6436,7 @@ var definitions = {
5341
6436
  }, { additionalProperties: false })
5342
6437
  },
5343
6438
  "messages.send": {
5344
- description: "Send a message, with files from the tool host if you like (path and name). Give chatId or to, not both: to is a member's name and sends them a DM (a person or a Bot; the first time creates the DM). Only messages sent successfully are visible in the chat. In channels and threads, only the members whose @name is in the text and the members subscribed there receive it; DMs need no @. The result lists who was @-mentioned (notified), who got it through a subscription (subscribers), and the names that aren't in the channel and weren't told. thread: the ID of a message in the channel, to reply in that message's thread (created if it has none; chatId is the channel). replyTo: the ID of a message in the same chat, to reply to it as a quote; this tells its author, like an @. When replying in a thread, alsoToChannel=true also posts a copy to the channel.",
6439
+ description: "Send a message, with files from your environment if you like (path there, absolute or relative to your directory, and the name people see). Give chatId or to, not both: to is a member's name and sends them a DM (a person or a Bot; the first time creates the DM). Only messages sent successfully are visible in the chat. In channels and threads, only the members whose @name is in the text and the members subscribed there receive it; DMs need no @. The result lists who was @-mentioned (notified), who got it through a subscription (subscribers), and the names that aren't in the channel and weren't told. thread: the ID of a message in the channel, to reply in that message's thread (created if it has none; chatId is the channel). replyTo: the ID of a message in the same chat, to reply to it as a quote; this tells its author, like an @. When replying in a thread, alsoToChannel=true also posts a copy to the channel.",
5345
6440
  schema: Type3.Object({
5346
6441
  chatId: Type3.Optional(Type3.String()),
5347
6442
  to: Type3.Optional(Type3.String({ minLength: 1, maxLength: 80 })),
@@ -5355,7 +6450,7 @@ var definitions = {
5355
6450
  };
5356
6451
 
5357
6452
  // src/server/service/bot/roster.ts
5358
- function botRows(chats, viewerId, entries) {
6453
+ function botRows(chats, viewerId, entries, environment) {
5359
6454
  const rows2 = entries.map((entry) => {
5360
6455
  const running = entry.status.status === "running";
5361
6456
  const visible = [];
@@ -5368,30 +6463,194 @@ function botRows(chats, viewerId, entries) {
5368
6463
  return { self: entry.id === viewerId, row: {
5369
6464
  name: entry.name,
5370
6465
  ...entry.id === viewerId ? { self: true } : {},
6466
+ ...entry.agent ? { agent: true } : {},
5371
6467
  status: entry.status.status,
5372
6468
  ...running && entry.status.activity ? { activity: entry.status.activity } : {},
5373
6469
  ...visible.length ? { chats: visible } : {},
5374
6470
  ...hidden ? { otherChats: hidden } : {},
5375
- ...entry.errorCode ? { error: entry.errorCode } : {}
6471
+ ...entry.errorCode ? { error: entry.errorCode } : {},
6472
+ ...environment ? { environment: environment(entry.id) } : {}
5376
6473
  } };
5377
6474
  });
5378
6475
  rows2.sort((a, b) => Number(b.self) - Number(a.self) || a.row.name.localeCompare(b.row.name));
5379
6476
  return rows2.map((item) => item.row);
5380
6477
  }
5381
6478
 
6479
+ // src/server/service/bot/agent-tools.ts
6480
+ var AgentTools = class {
6481
+ constructor(scope) {
6482
+ this.scope = scope;
6483
+ }
6484
+ get ops() {
6485
+ return this.scope.ops;
6486
+ }
6487
+ async confirmed(input, ask) {
6488
+ const chat = this.scope.tools.chat(input.askIn);
6489
+ if (!chat.memberIds.includes(this.ops.owner)) throw new ChatError(400, "The owner is not in askIn; use a chat they are in, such as your DM with them");
6490
+ const answer = await ask(chat.id, {
6491
+ question: input.question,
6492
+ multiple: false,
6493
+ allowCustom: false,
6494
+ options: [{ label: input.confirm, value: "confirm", style: "danger" }, { label: input.cancel, value: "cancel" }]
6495
+ });
6496
+ if (answer.status === "dismissed") return { deleted: false, answer: "dismissed" };
6497
+ return answer.selected[0]?.value === "confirm" ? null : { deleted: false, answer: "cancelled" };
6498
+ }
6499
+ channel(chatId) {
6500
+ const chat = this.scope.tools.chat(chatId);
6501
+ if (chat.type !== "channel") throw new ChatError(400, "Not a channel");
6502
+ return chat;
6503
+ }
6504
+ async call(name2, args, ask) {
6505
+ switch (name2) {
6506
+ case "models.list":
6507
+ return this.ops.models();
6508
+ case "bots.create": {
6509
+ const { environment, ...settings } = args;
6510
+ const bot = await this.ops.create({ ...settings, ...environment ? { environment: environment === "sandbox" ? "computer" : "host" } : {} });
6511
+ return { name: bot.name, model: bot.connectionId ? this.ops.modelLabel(bot) : null, thinking: bot.thinkingLevel, environment: this.ops.environment(bot.id) === "computer" ? "sandbox" : "host" };
6512
+ }
6513
+ case "bots.update": {
6514
+ const { bot, ...settings } = args;
6515
+ const result = await this.ops.update(this.ops.bot(bot), settings);
6516
+ return { name: result.bot.name, changed: result.changed };
6517
+ }
6518
+ case "bots.start":
6519
+ case "bots.stop": {
6520
+ const bot = this.ops.bot(args.bot);
6521
+ return { name: bot.name, status: await this.ops.run(bot, name2 === "bots.start" ? "start" : "stop") };
6522
+ }
6523
+ case "bots.usage": {
6524
+ const input = args, bot = this.ops.bot(input.bot);
6525
+ return { name: bot.name, ...this.ops.usage(bot, input.days ?? 7) };
6526
+ }
6527
+ case "bots.delete": {
6528
+ const input = args, bot = this.ops.bot(input.bot);
6529
+ if (bot.id === this.ops.agentId) throw new ChatError(400, "You can't delete yourself");
6530
+ const declined = await this.confirmed(input, ask);
6531
+ if (declined) return declined;
6532
+ await this.ops.deleteBot(this.ops.bot(input.bot));
6533
+ return { deleted: true, name: bot.name };
6534
+ }
6535
+ case "chats.delete": {
6536
+ const input = args, chat = this.channel(input.chatId);
6537
+ const declined = await this.confirmed(input, ask);
6538
+ if (declined) return declined;
6539
+ await this.ops.deleteChannel(chat.id);
6540
+ return { deleted: true, name: chat.title };
6541
+ }
6542
+ case "chats.removeMembers": {
6543
+ const input = args, chat = this.channel(input.chatId);
6544
+ const members = this.scope.tools.members(input.members).filter((member) => chat.memberIds.includes(member.id));
6545
+ if (!members.length) throw new ChatError(400, "None of them is in this channel");
6546
+ const declined = await this.confirmed(input, ask);
6547
+ if (declined) return { removed: [], answer: declined.answer };
6548
+ for (const member of members) this.ops.removeMember(chat.id, member.id);
6549
+ return { removed: members.map((member) => member.name) };
6550
+ }
6551
+ case "sections.delete": {
6552
+ const input = args, section = this.scope.tools.section(input.section);
6553
+ const declined = await this.confirmed(input, ask);
6554
+ if (declined) return declined;
6555
+ this.ops.deleteSection(section);
6556
+ return { deleted: true };
6557
+ }
6558
+ }
6559
+ }
6560
+ };
6561
+
6562
+ // src/server/service/bot/agent-schemas.ts
6563
+ import { Type as Type4 } from "typebox";
6564
+ var None2 = Type4.Object({}, { additionalProperties: false });
6565
+ var Name2 = Type4.String({ minLength: 1, maxLength: 80, description: "Unique among all members, people and Bots" });
6566
+ var BotName = Type4.String({ minLength: 1, maxLength: 80, description: "The Bot's name, as bots.list shows it" });
6567
+ var Model2 = Type4.Object({
6568
+ connection: Type4.String({ minLength: 1, maxLength: 200, description: "A connection's name or ID from models.list" }),
6569
+ model: Type4.String({ minLength: 1, maxLength: 200, description: "A model's ID or name in that connection" })
6570
+ }, { additionalProperties: false });
6571
+ var Thinking = Type4.Union(
6572
+ ["auto", "off", "minimal", "low", "medium", "high", "xhigh", "max"].map((level) => Type4.Literal(level)),
6573
+ { description: "How much the model thinks; auto lets Jarvy choose. models.list gives the levels each model has" }
6574
+ );
6575
+ var Settings = {
6576
+ description: Type4.Optional(Type4.String({ maxLength: 1e3, description: "One line that other members see in members.list" })),
6577
+ persona: Type4.Optional(Type4.String({ maxLength: 16e3, description: "Its standing instructions: who it is and how it works (the Persona section of its prompt)" })),
6578
+ model: Type4.Optional(Model2),
6579
+ thinking: Type4.Optional(Thinking)
6580
+ };
6581
+ var Environment = Type4.Union([Type4.Literal("sandbox"), Type4.Literal("host")], {
6582
+ description: "Where its file and command tools run by default: sandbox, a Linux container that Jarvy makes and only it uses (the default when Docker is available), or host, the owner's computer, where you work too"
6583
+ });
6584
+ var Confirm = {
6585
+ askIn: Type4.String({ description: "ID of a chat the owner is in, where the confirmation card goes; usually the chat you were asked in" }),
6586
+ question: Type4.String({ minLength: 1, maxLength: 500, description: "The card's question, in the owner's language: what will be deleted and what goes with it" }),
6587
+ confirm: Type4.String({ minLength: 1, maxLength: 40, description: "Label of the button that deletes, in the owner's language" }),
6588
+ cancel: Type4.String({ minLength: 1, maxLength: 40, description: "Label of the button that keeps everything, in the owner's language" })
6589
+ };
6590
+ var after = "The owner first gets a card in askIn, and nothing is deleted unless they confirm; the result says what they chose.";
6591
+ var agentDefinitions = {
6592
+ "models.list": { description: "List the models you can give a Bot: each connection with its models and their thinking levels. Only the owner adds connections, keys and logins, in Settings.", schema: None2 },
6593
+ "bots.create": {
6594
+ description: "Create a Bot. Give it a model so it can work; without one it waits until it gets one. It starts when a message first arrives for it. bots.update changes everything later.",
6595
+ schema: Type4.Object({ name: Name2, ...Settings, environment: Type4.Optional(Environment) }, { additionalProperties: false })
6596
+ },
6597
+ "bots.update": {
6598
+ description: "Change a Bot's name, description, persona, model or thinking; give only what changes. Works for any Bot, yourself included. The Bot is told what changed with its next batch, in its DM with you.",
6599
+ schema: Type4.Object({ bot: BotName, name: Type4.Optional(Name2), ...Settings }, { additionalProperties: false })
6600
+ },
6601
+ "bots.start": { description: "Start a stopped Bot. A stopped Bot also starts by itself when a message arrives for it.", schema: Type4.Object({ bot: BotName }, { additionalProperties: false }) },
6602
+ "bots.stop": { description: "Stop a Bot (not yourself): its current turn ends, and it stays stopped until it is started or a message arrives for it.", schema: Type4.Object({ bot: BotName }, { additionalProperties: false }) },
6603
+ "bots.usage": {
6604
+ description: "A Bot's token use and cost over the last days (7 by default), per model.",
6605
+ schema: Type4.Object({ bot: BotName, days: Type4.Optional(Type4.Integer({ minimum: 1, maximum: 365 })) }, { additionalProperties: false })
6606
+ },
6607
+ "bots.delete": {
6608
+ description: `Delete a Bot (not yourself). ${after} Its DMs and channel memberships go; its messages in channels and its workspace stay.`,
6609
+ schema: Type4.Object({ bot: BotName, ...Confirm }, { additionalProperties: false })
6610
+ },
6611
+ "chats.delete": {
6612
+ description: `Delete a channel you are in, with its threads, messages and files. ${after}`,
6613
+ schema: Type4.Object({ chatId: Type4.String({ description: "Channel ID" }), ...Confirm }, { additionalProperties: false })
6614
+ },
6615
+ "chats.removeMembers": {
6616
+ description: `Remove members from a channel you are in, by name (see members.list). ${after}`,
6617
+ schema: Type4.Object({ chatId: Type4.String({ description: "Channel ID" }), members: Type4.Array(Type4.String({ minLength: 1, maxLength: 80 }), { minItems: 1, maxItems: 50 }), ...Confirm }, { additionalProperties: false })
6618
+ },
6619
+ "sections.delete": {
6620
+ description: `Delete a channel group; its channels stay, outside any group. ${after}`,
6621
+ schema: Type4.Object({ section: Type4.String({ minLength: 1, maxLength: 80, description: "The group's name or ID (see sections.list)" }), ...Confirm }, { additionalProperties: false })
6622
+ }
6623
+ };
6624
+
5382
6625
  // src/server/service/bot/tools.ts
5383
- var Gateway = Type4.Object({
5384
- action: Type4.Union([Type4.Literal("list"), Type4.Literal("describe"), Type4.Literal("call")]),
5385
- tool: Type4.Optional(Type4.String()),
5386
- args: Type4.Optional(Type4.Record(Type4.String(), Type4.Unknown()))
6626
+ var Gateway = Type5.Object({
6627
+ action: Type5.Union([Type5.Literal("list"), Type5.Literal("describe"), Type5.Literal("call")]),
6628
+ tool: Type5.Optional(Type5.String()),
6629
+ args: Type5.Optional(Type5.Record(Type5.String(), Type5.Unknown()))
5387
6630
  }, { additionalProperties: false });
5388
6631
  var Tools = class {
5389
6632
  constructor(scope) {
5390
6633
  this.scope = scope;
5391
6634
  this.tools = new ChatTools(scope);
6635
+ if (scope.agent) this.agent = new AgentTools({ ops: scope.agent, tools: this.tools });
5392
6636
  }
5393
6637
  send;
5394
6638
  tools;
6639
+ /** The agent Bot's own tools (A48); other Bots have none. */
6640
+ agent;
6641
+ get catalog() {
6642
+ return this.agent ? { ...definitions, ...agentDefinitions } : definitions;
6643
+ }
6644
+ /** Posts a card and waits for the person's answer (messages.ask, and the agent's confirmations). */
6645
+ async ask(chatId, question, toolCallId, signal) {
6646
+ const sent = await this.send(chatId, { content: question.question, question, toolCallId, signal });
6647
+ this.scope.asking?.(true);
6648
+ try {
6649
+ return { messageId: sent.id, ...await this.scope.chats.questions.wait(this.scope.chats.message(this.scope.botId, sent.id), signal) };
6650
+ } finally {
6651
+ if (!signal?.aborted) this.scope.asking?.(false);
6652
+ }
6653
+ }
5395
6654
  bindSend(send) {
5396
6655
  this.send = send;
5397
6656
  }
@@ -5405,16 +6664,18 @@ var Tools = class {
5405
6664
  signal?.throwIfAborted();
5406
6665
  if (raw.action === "list") {
5407
6666
  if (raw.tool !== void 0 || raw.args !== void 0) throw new ChatError(400, "list does not accept tool or args");
5408
- return Object.entries(definitions).map(([name3, tool]) => ({ name: name3, description: tool.description }));
6667
+ return Object.entries(this.catalog).map(([name3, tool]) => ({ name: name3, description: tool.description }));
5409
6668
  }
5410
- if (!raw.tool || !Object.hasOwn(definitions, raw.tool)) throw new ChatError(400, "Unknown chat tool; use list first");
5411
- const name2 = raw.tool, definition = definitions[name2];
6669
+ if (!raw.tool || !Object.hasOwn(this.catalog, raw.tool)) throw new ChatError(400, "Unknown chat tool; use list first");
6670
+ const name2 = raw.tool, definition = this.catalog[name2];
5412
6671
  if (raw.action === "describe") {
5413
6672
  if (raw.args !== void 0) throw new ChatError(400, "describe does not accept args");
5414
6673
  return { name: name2, description: definition.description, parameters: definition.schema };
5415
6674
  }
5416
6675
  const args = raw.args;
5417
6676
  if (!args || !Value.Check(definition.schema, args)) throw new ChatError(400, "Invalid tool arguments; use describe for the parameter schema");
6677
+ if (this.agent && Object.hasOwn(agentDefinitions, name2))
6678
+ return this.agent.call(name2, args, (chatId, question) => this.ask(chatId, question, toolCallId, signal));
5418
6679
  switch (name2) {
5419
6680
  case "history.read":
5420
6681
  return readHistory(this.scope.memory, this.scope.workspace, args, signal);
@@ -5425,7 +6686,16 @@ var Tools = class {
5425
6686
  case "members.list":
5426
6687
  return this.tools.membersList();
5427
6688
  case "bots.list":
5428
- return botRows(this.scope.chats, this.scope.botId, this.scope.roster());
6689
+ return botRows(
6690
+ this.scope.chats,
6691
+ this.scope.botId,
6692
+ this.scope.roster(),
6693
+ args.environments ? (id) => this.scope.places.summary(id) : void 0
6694
+ );
6695
+ case "environments.list":
6696
+ return this.scope.places.list();
6697
+ case "environments.switch":
6698
+ return this.scope.places.switch(args.id);
5429
6699
  case "chats.create":
5430
6700
  return this.tools.create(args);
5431
6701
  case "chats.update":
@@ -5459,6 +6729,16 @@ var Tools = class {
5459
6729
  }
5460
6730
  case "messages.read":
5461
6731
  return this.tools.read(args, signal);
6732
+ case "files.get": {
6733
+ const { id, path } = args;
6734
+ const { file, data } = this.scope.files.open(this.scope.botId, id);
6735
+ signal?.throwIfAborted();
6736
+ return { path: await this.scope.workspace.put(file.name, id, data, path), name: file.name, type: file.mimeType, size: byteSize(file.size) };
6737
+ }
6738
+ case "files.view": {
6739
+ const { file, data } = this.scope.files.open(this.scope.botId, args.id);
6740
+ return viewFile(file, data);
6741
+ }
5462
6742
  case "messages.search":
5463
6743
  return this.tools.search(args);
5464
6744
  case "messages.send": {
@@ -5504,13 +6784,7 @@ var Tools = class {
5504
6784
  multiple: parsed.multiple === true,
5505
6785
  allowCustom: parsed.allowCustom === true
5506
6786
  };
5507
- const sent = await this.send(parsed.chatId, { content: parsed.question, question, toolCallId, signal });
5508
- this.scope.asking?.(true);
5509
- try {
5510
- return { messageId: sent.id, ...await this.scope.chats.questions.wait(this.scope.chats.message(this.scope.botId, sent.id), signal) };
5511
- } finally {
5512
- if (!signal?.aborted) this.scope.asking?.(false);
5513
- }
6787
+ return this.ask(parsed.chatId, question, toolCallId, signal);
5514
6788
  }
5515
6789
  }
5516
6790
  }
@@ -5519,10 +6793,13 @@ var Tools = class {
5519
6793
  };
5520
6794
 
5521
6795
  // src/server/service/bot/inbox.ts
5522
- import { join as join5, dirname as dirname4 } from "node:path";
6796
+ import { join as join7, dirname as dirname6 } from "node:path";
6797
+
6798
+ // src/server/shared/agent.ts
6799
+ var WELCOME_LANGUAGES = { en: "English", zh: "Chinese" };
6800
+ var welcomeLanguage = (value2) => typeof value2 === "string" && Object.hasOwn(WELCOME_LANGUAGES, value2) ? value2 : "en";
5523
6801
 
5524
6802
  // src/server/service/chat/envelope.ts
5525
- import { isAbsolute } from "node:path";
5526
6803
  var INVALID_XML = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\ud800-\udfff\ufffe\uffff]/u;
5527
6804
  function escape(value2) {
5528
6805
  if (INVALID_XML.test(value2)) throw new Error("Invalid XML character");
@@ -5537,23 +6814,29 @@ function remember(map, id, definition) {
5537
6814
  if (old !== void 0 && old !== definition) throw new Error("Conflicting source metadata");
5538
6815
  map.set(id, definition);
5539
6816
  }
6817
+ var environmentLine = (place) => place ? `<environment${attribute("name", place.name)}${attribute("directory", place.directory)}/>` : "";
5540
6818
  var iso2 = (time) => new Date(time).toISOString().replace(".000Z", "Z");
5541
- function encodeNoticeBatch(botId, notices) {
6819
+ var welcome = (owner, language) => `${owner} has just set up Jarvy 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 Jarvy is and how you can help, such as building a team of Bots for their work and running Jarvy for them. Then ask what they want to get done: a software project, writing, research or something else.`;
6820
+ function encodeNoticeBatch(botId, notices, environment) {
5542
6821
  if (!notices.length) throw new Error("Empty notice batch");
5543
6822
  const chats = /* @__PURE__ */ new Map(), members = /* @__PURE__ */ new Map();
5544
6823
  const list = noticeTail(notices, chats, members);
5545
- return `<notice_batch${attribute("bot", botId)}>${[...chats.values()].join("")}${[...members.values()].join("")}${list}</notice_batch>`;
6824
+ return `<notice_batch${attribute("bot", botId)}>${[...chats.values()].join("")}${[...members.values()].join("")}${list}${environmentLine(environment)}</notice_batch>`;
5546
6825
  }
5547
6826
  function noticeTail(notices, chats, members) {
5548
6827
  for (const notice of notices) {
5549
- if (!chats.has(notice.chat.id)) chats.set(notice.chat.id, `<chat${attribute("id", notice.chat.id)}${attribute("type", notice.chat.type)}${attribute("title", notice.chat.title)}/>`);
6828
+ 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) : ""}/>`);
5550
6829
  remember(members, notice.actor.id, `<member${attribute("id", notice.actor.id)}${attribute("name", notice.actor.name)}${attribute("kind", notice.actor.kind)}/>`);
5551
6830
  }
5552
6831
  const latest = /* @__PURE__ */ new Map();
5553
6832
  for (const notice of notices) if (notice.kind === "note" && (latest.get(notice.chat.id)?.version ?? 0) < notice.version) latest.set(notice.chat.id, notice);
5554
6833
  const updates = [...latest.values()].map((note) => `<${note.chat.type === "thread" ? "summary" : "notice"}${attribute("chat", note.chat.id)}${attribute("version", String(note.version))}${attribute("by", note.actor.id)}${attribute("at", iso2(note.createdAt))}/>`);
5555
6834
  const marks = notices.flatMap((notice) => notice.kind !== "reaction" ? [] : [`<reaction${attribute("message", notice.message.id)}${attribute("chat", notice.chat.id)}${attribute("by", notice.actor.id)}${attribute("emoji", notice.emoji)}${attribute("at", iso2(notice.createdAt))}>${escape(notice.message.text)}</reaction>`]);
5556
- return (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>` : "");
6835
+ const changed = notices.filter((notice) => notice.kind === "change");
6836
+ 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>`}`);
6837
+ const byAgent = changed.some((notice) => notice.actor.kind === "bot"), byOwner = changed.some((notice) => notice.actor.kind === "user");
6838
+ 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);
6839
+ return (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 persona change is already in your instructions. 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("");
5557
6840
  }
5558
6841
  function encodeChatBatch(botId, messages, digest, notices) {
5559
6842
  if (!messages.length) throw new Error("Empty chat batch");
@@ -5564,32 +6847,33 @@ function encodeChatBatch(botId, messages, digest, notices) {
5564
6847
  const { chat } = message;
5565
6848
  remember(chats, chat.id, `<chat${attribute("id", chat.id)}${attribute("type", chat.type)}${chat.title ? attribute("title", chat.title) : ""}${chat.section ? attribute("section", chat.section) : ""}${chat.parent ? attribute("parent", chat.parent) : ""}${chat.root ? attribute("root", chat.root) : ""}${chat.status ? attribute("status", chat.status) : ""}/>`);
5566
6849
  remember(members, message.sender.id, `<member${attribute("id", message.sender.id)}${attribute("name", message.sender.name)}${attribute("kind", message.sender.kind)}/>`);
5567
- const at = new Date(message.createdAt).toISOString().replace(".000Z", "Z");
5568
- const attributes = attribute("id", message.id) + attribute("chat", message.chat.id) + attribute("from", message.sender.id) + attribute("at", at);
6850
+ const at2 = new Date(message.createdAt).toISOString().replace(".000Z", "Z");
6851
+ const attributes = attribute("id", message.id) + attribute("chat", message.chat.id) + attribute("from", message.sender.id) + attribute("at", at2);
5569
6852
  const reply = message.replyTo;
5570
6853
  if (reply) remember(members, reply.sender.id, `<member${attribute("id", reply.sender.id)}${attribute("name", reply.sender.name)}${attribute("kind", reply.sender.kind)}/>`);
5571
6854
  const quote = reply ? `<reply_to${attribute("message", reply.id)}${attribute("from", reply.sender.id)}>${escape(reply.text)}</reply_to>` : "";
5572
- const files = message.attachments.map((file) => {
5573
- if (!isAbsolute(file.path)) throw new Error("Attachment path must be absolute");
5574
- return `<file${attribute("path", file.path)}${attribute("name", file.name)}/>`;
5575
- }).join("");
6855
+ const files = message.attachments.map((file) => `<file${attribute("id", file.id)}${attribute("name", file.name)}${attribute("type", file.type)}${attribute("size", file.size)}/>`).join("");
5576
6856
  return `<message${attributes}>${quote}${message.content ? `<text>${escape(message.content)}</text>` : ""}${files}</message>`;
5577
6857
  });
5578
6858
  const ref = (key, item) => attribute(key, item.chatId) + (chats.has(item.chatId) ? "" : attribute("title", item.title) + attribute("type", item.type));
5579
6859
  let tail = notices?.length ? noticeTail(notices, chats, members) : "";
5580
6860
  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>`;
6861
+ tail += environmentLine(digest?.environment);
5581
6862
  return `<chat_batch${attribute("bot", botId)}>${[...chats.values()].join("")}${[...members.values()].join("")}${encoded.join("")}${tail}</chat_batch>`;
5582
6863
  }
5583
6864
 
5584
6865
  // src/server/service/bot/inbox.ts
5585
6866
  var BotInbox = class {
5586
- constructor(botId, repository, chats, files) {
6867
+ /** `where` is the work environment the Bot is in now, named at the end of each batch (A50). */
6868
+ constructor(botId, repository, chats, files, where2) {
5587
6869
  this.botId = botId;
5588
6870
  this.repository = repository;
5589
6871
  this.chats = chats;
5590
6872
  this.files = files;
6873
+ this.where = where2;
5591
6874
  }
5592
- /** Messages and other deliveries wait in the inbox (A41); either kind is work for the next run. */
6875
+ /** Messages and other deliveries wait in the inbox (A41); either kind is work for the next run. A digest delivery
6876
+ * (a change the agent Bot made, A48) is not work of its own: it rides along with the next batch. */
5593
6877
  pending() {
5594
6878
  return this.repository.hasPending(this.botId) || this.hasNotices();
5595
6879
  }
@@ -5597,11 +6881,11 @@ var BotInbox = class {
5597
6881
  return this.repository.claim(this.botId, selection, this.hasNotices());
5598
6882
  }
5599
6883
  hasNotices() {
5600
- return this.chats.notices(this.botId).length > 0;
6884
+ return this.chats.notices(this.botId).some((notice) => notice.urgency !== "digest");
5601
6885
  }
5602
6886
  reference(workspace) {
5603
6887
  if (this.repository.hasUncertainModel(this.botId)) throw new RuntimeFault("model_switch_uncertain", "The model switch didn't finish. Pick the model again.");
5604
- return this.repository.ensureSession(this.botId, join5(dirname4(workspace), "agent", "pi", "sessions", `${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}.jsonl`));
6888
+ return this.repository.ensureSession(this.botId, join7(dirname6(workspace), "agent", "pi", "sessions", `${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}.jsonl`));
5605
6889
  }
5606
6890
  /** XML for the claimed messages plus the tail (other deliveries not told yet, unread); `notices` are told once
5607
6891
  * the batch is submitted. */
@@ -5612,7 +6896,7 @@ var BotInbox = class {
5612
6896
  }
5613
6897
  const inputs = await this.files.envelope(this.botId, batch.inputs.map((input) => input.messageId));
5614
6898
  for (const input of batch.inputs) if (!this.chats.get(this.botId, input.chatId)) throw new RuntimeFault("chat_access_changed", "Chat access has changed.");
5615
- const digest = this.chats.channels.digest(this.botId);
6899
+ const digest = { ...this.chats.channels.digest(this.botId), ...this.where ? { environment: this.where() } : {} };
5616
6900
  const pending = this.envelope(this.chats.notices(this.botId));
5617
6901
  return { xml: encodeChatBatch(this.botId, inputs, digest, pending.length ? pending : void 0), notices: pending.map((notice) => notice.id) };
5618
6902
  }
@@ -5621,6 +6905,11 @@ var BotInbox = class {
5621
6905
  return notices.map((notice) => {
5622
6906
  const chat = { id: notice.chatId, type: notice.chatType, title: notice.chatTitle };
5623
6907
  if (notice.kind === "note") return { kind: "note", id: notice.id, chat, actor: notice.actor, version: Number(notice.detail), createdAt: notice.createdAt };
6908
+ if (notice.kind === "onboard") return { kind: "onboard", id: notice.id, chat, actor: notice.actor, language: welcomeLanguage(notice.detail), createdAt: notice.createdAt };
6909
+ if (notice.kind === "change") {
6910
+ const change = JSON.parse(notice.detail);
6911
+ return { kind: "change", id: notice.id, chat, actor: notice.actor, what: change.what, value: change.value, createdAt: notice.createdAt };
6912
+ }
5624
6913
  const marked = notice.messageId ? this.chats.message(this.botId, notice.messageId) : null;
5625
6914
  const text3 = marked ? previewQuotes(marked.content, marked.quotes).slice(0, 120) : "";
5626
6915
  return { kind: "reaction", id: notice.id, chat, message: { id: notice.messageId ?? "", text: text3 }, actor: notice.actor, emoji: notice.detail, createdAt: notice.createdAt };
@@ -5634,7 +6923,7 @@ var BotInbox = class {
5634
6923
  notices() {
5635
6924
  const pending = this.chats.notices(this.botId);
5636
6925
  if (!pending.length) return null;
5637
- return { xml: encodeNoticeBatch(this.botId, this.envelope(pending)), ids: pending.map((notice) => notice.id) };
6926
+ return { xml: encodeNoticeBatch(this.botId, this.envelope(pending), this.where?.()), ids: pending.map((notice) => notice.id) };
5638
6927
  }
5639
6928
  /** These notices reached the model. */
5640
6929
  tell(ids) {
@@ -5667,8 +6956,8 @@ var AgentRuntime = class {
5667
6956
  return this.runId;
5668
6957
  }
5669
6958
  update(patch) {
5670
- const context = this.session?.context;
5671
- this.status = { ...this.status, ...patch, context: context ? { tokens: context.tokens, limit: context.contextWindow } : null };
6959
+ const context2 = this.session?.context;
6960
+ this.status = { ...this.status, ...patch, context: context2 ? { tokens: context2.tokens, limit: context2.contextWindow } : null };
5672
6961
  this.setup?.onStatus(this.status);
5673
6962
  }
5674
6963
  activity(phase) {
@@ -5679,9 +6968,9 @@ var AgentRuntime = class {
5679
6968
  if (this.prompting) this.update({ status: "running", activity: on ? "asking" : "tool_calling" });
5680
6969
  }
5681
6970
  fail(error) {
5682
- const fault = publicFault(error);
5683
- this.update({ status: this.session ? "idle" : "stop", activity: null, error: { code: fault.code, message: fault.message, ...fault.params ? { params: fault.params } : {} } });
5684
- console.error("[bot] execution failed", { botId: this.inbox.botId, runId: this.runId, code: fault.code, ...error instanceof RuntimeFault ? {} : { error } });
6971
+ const fault2 = publicFault(error);
6972
+ this.update({ status: this.session ? "idle" : "stop", activity: null, error: { code: fault2.code, message: fault2.message, ...fault2.params ? { params: fault2.params } : {} } });
6973
+ console.error("[bot] execution failed", { botId: this.inbox.botId, runId: this.runId, code: fault2.code, ...error instanceof RuntimeFault ? {} : { error } });
5685
6974
  }
5686
6975
  async start(setup) {
5687
6976
  await this.operation(async () => {
@@ -5805,7 +7094,7 @@ var AgentRuntime = class {
5805
7094
  return task;
5806
7095
  }
5807
7096
  // Model/thinking changes queue behind start/stop and each other but not behind the running turn:
5808
- // Pi 0.99.1 re-reads agent.state.model/thinkingLevel before every next request (prepareNextTurn),
7097
+ // Pi 1.2.0 re-reads agent.state.model/thinkingLevel before every next request (AgentSession's prepareNextTurnWithContext),
5809
7098
  // and each request resolves its API key when it starts, so in-flight requests are unaffected.
5810
7099
  reconfigure(operation) {
5811
7100
  const task = this.operations.catch(() => {
@@ -5868,6 +7157,14 @@ var AgentRuntime = class {
5868
7157
  };
5869
7158
 
5870
7159
  // src/server/service/bot/prompt.ts
7160
+ var AGENT_ROLE = `
7161
+ ## Your role
7162
+ You are the owner's agent Bot. Besides working like any Bot, you help them set up and run Jarvy itself.
7163
+ Help a new owner get started: find out what they want to get done, suggest a team of Bots for it, then create and set up those Bots.
7164
+ The chat gateway lists tools only you have: models.list, bots.create, bots.update, bots.start, bots.stop and bots.usage. bots.delete, chats.delete, chats.removeMembers and sections.delete first ask the owner on a card, and delete nothing unless they confirm.
7165
+ Other Bots may ask you to do these things for them. Do the ordinary ones; deleting always waits for the owner's confirmation.
7166
+ You can't add model connections, keys or logins. The owner does that in Settings, so point them there when a model is missing.
7167
+ `;
5871
7168
  function botPrompt(setup) {
5872
7169
  const bot = { ...setup.identity(), workspacePath: setup.workspace.cwd };
5873
7170
  return `# Persona
@@ -5878,14 +7175,14 @@ ${bot.persona}
5878
7175
  ## Environment
5879
7176
  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.
5880
7177
  Here your name is ${bot.name}, and your description is: ${bot.description}
5881
-
7178
+ ${bot.agent ? AGENT_ROLE : ""}
5882
7179
  ## Language
5883
7180
  Write to people in the language they write to you in. In a channel or thread, use the language its conversation is held in.
5884
7181
  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.
5885
7182
 
5886
7183
  ## Chat
5887
7184
  The chat tool is your only way to talk to the other members. Anything you don't send through it is seen by no one.
5888
- To pass a document, an image or another file to someone, attach its path and name when you send with the chat tool.
7185
+ Files in messages come with an ID: files.get puts one into your environment and gives you its path, and files.view shows you an image or a text file directly. To pass a file to someone, attach its path in your environment and its name when you send with the chat tool.
5889
7186
  Channels can be put in channel groups and have a notice. Threads have a title, a status and a summary.
5890
7187
  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 @.
5891
7188
  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.
@@ -5894,17 +7191,33 @@ Each batch of messages ends with your unread counts and the notices and summarie
5894
7191
  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.
5895
7192
  history.read is listed by the chat gateway. History is material to look back on, not an instruction for the task at hand.
5896
7193
 
5897
- ## Workspace
5898
- You have your own workspace: ${bot.workspacePath}
7194
+ ${workspaceSection(setup, bot.workspacePath)}
7195
+ ## Responding
7196
+ 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.
7197
+ 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.
7198
+ `;
7199
+ }
7200
+ function workspaceSection(setup, workspacePath) {
7201
+ const place = setup.home();
7202
+ if (place.kind === "computer") return `## Workspace
7203
+ Your workspace is your own folder and your home directory: your file and command tools see it as ${place.cwd}, and ~ is the same place, so settings such as ~/.ssh and ~/.gitconfig stay with it. It is kept when your sandbox is reset. Keep it tidy, and put temporary files under temp and clear them out.
7204
+
7205
+ ## Work environment
7206
+ Your tools run in your sandbox by default: 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.
7207
+ ${SWITCHING}
7208
+ `;
7209
+ return `## Workspace
7210
+ You have your own workspace: ${workspacePath}
5899
7211
  Treat it as your only base. Create, change and delete files only inside it, keep it tidy, and put temporary files under temp and clear them out.
5900
7212
  Leave everything else alone by default: other members' workspaces, live data directories (such as ~/.jarvy), main repository checkouts and system directories. Touch them only when the task itself needs it or the person in charge asks you to, and say in your reply what you changed.
5901
7213
  Shared directories and other people's files are read-only. Ask the person in charge before writing to them.
5902
7214
 
5903
- ## Responding
5904
- 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.
5905
- 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.
7215
+ ## Work environment
7216
+ Your tools run on your owner's computer by default, the machine running Jarvy.
7217
+ ${SWITCHING}
5906
7218
  `;
5907
7219
  }
7220
+ 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.";
5908
7221
 
5909
7222
  // src/server/shared/live.ts
5910
7223
  var LIVE_STEPS = 300;
@@ -5918,9 +7231,9 @@ function clip(text3, length) {
5918
7231
  return text3.length > length ? `${text3.slice(0, length - 1)}\u2026` : text3;
5919
7232
  }
5920
7233
  var field = (value2, length = LIVE_INPUT) => typeof value2 === "string" ? clip(value2, length) : void 0;
5921
- var record = (value2) => value2 && typeof value2 === "object" && !Array.isArray(value2) ? value2 : {};
7234
+ var record2 = (value2) => value2 && typeof value2 === "object" && !Array.isArray(value2) ? value2 : {};
5922
7235
  function liveInput(tool, args, chat) {
5923
- const values = record(args);
7236
+ const values = record2(args);
5924
7237
  switch (tool) {
5925
7238
  case "bash":
5926
7239
  return { command: field(values.command) };
@@ -5944,15 +7257,15 @@ function liveInput(tool, args, chat) {
5944
7257
  }
5945
7258
  function gateway(values, chat) {
5946
7259
  const action = values.action === "call" && typeof values.tool === "string" ? values.tool : typeof values.action === "string" ? values.action : "";
5947
- const inner = record(values.args), where = target(inner, chat);
5948
- if (action === "messages.send") return { action, chat: where, text: field(inner.message, LIVE_MESSAGE) };
7260
+ const inner = record2(values.args), where2 = target(inner, chat);
7261
+ if (action === "messages.send") return { action, chat: where2, text: field(inner.message, LIVE_MESSAGE) };
5949
7262
  if (action === "messages.ask") return {
5950
7263
  action,
5951
- chat: where,
7264
+ chat: where2,
5952
7265
  text: field(inner.question, LIVE_MESSAGE),
5953
- options: Array.isArray(inner.options) ? inner.options.map((option2) => field(record(option2).label) ?? "").filter(Boolean) : void 0
7266
+ options: Array.isArray(inner.options) ? inner.options.map((option2) => field(record2(option2).label) ?? "").filter(Boolean) : void 0
5954
7267
  };
5955
- return where ? { action, chat: where } : { action };
7268
+ return where2 ? { action, chat: where2 } : { action };
5956
7269
  }
5957
7270
  function target(inner, chat) {
5958
7271
  if (typeof inner.to === "string") return { id: null, type: "dm", title: clip(inner.to, 80) };
@@ -6158,25 +7471,156 @@ var LiveTurns = class {
6158
7471
  }
6159
7472
  };
6160
7473
 
7474
+ // src/server/service/bot/agent.ts
7475
+ var same2 = (a, b) => a.trim().replace(/^@/, "").toLocaleLowerCase() === b.trim().toLocaleLowerCase();
7476
+ var AgentOps = class {
7477
+ constructor(bots, store, chats) {
7478
+ this.bots = bots;
7479
+ this.store = store;
7480
+ this.chats = chats;
7481
+ }
7482
+ get owner() {
7483
+ return this.store.userMemberId();
7484
+ }
7485
+ agentId = AGENT_BOT_ID;
7486
+ /** A Bot by name, ignoring case and a leading @. */
7487
+ bot(name2) {
7488
+ const bot = this.store.listBots().find((record3) => same2(name2, record3.name));
7489
+ if (!bot) throw new ChatError(404, `No Bot named ${name2} (see bots.list)`);
7490
+ return bot;
7491
+ }
7492
+ /** Connected models and their thinking levels; never credentials. */
7493
+ models() {
7494
+ return this.store.listModelConnections().filter((connection) => connection.models.length).map((connection) => ({
7495
+ connection: connection.displayName,
7496
+ connectionId: connection.id,
7497
+ models: connection.models.map((model) => ({ id: model.id, name: model.name, thinking: ["auto", ...thinkingOptions(model).levels] }))
7498
+ }));
7499
+ }
7500
+ selection(bot, input) {
7501
+ if (!input.model && input.thinking === void 0) return null;
7502
+ let connectionId = bot?.connectionId ?? null, modelId = bot?.modelId ?? "";
7503
+ if (input.model) {
7504
+ const { connection, model } = input.model;
7505
+ const found = this.store.listModelConnections().find((item) => item.id === connection || same2(connection, item.displayName));
7506
+ if (!found) throw new ChatError(404, `No connection named ${connection} (see models.list)`);
7507
+ const picked = found.models.find((item) => item.id === model || same2(model, item.name));
7508
+ if (!picked) throw new ChatError(404, `No model ${model} in ${found.displayName} (see models.list)`);
7509
+ connectionId = found.id;
7510
+ modelId = picked.id;
7511
+ }
7512
+ if (!connectionId) throw new ChatError(400, "This Bot has no model yet; give model too");
7513
+ return { connectionId, modelId, ...input.thinking === void 0 ? {} : { thinkingLevel: input.thinking } };
7514
+ }
7515
+ /** Where a Bot works now: computer or host (A50). */
7516
+ environment(botId) {
7517
+ return this.bots.describe(botId).environment.home;
7518
+ }
7519
+ /** "model (connection)", as the agent and the changed Bot read it. */
7520
+ modelLabel(bot) {
7521
+ const connection = bot.connectionId ? this.store.getModelConnection(bot.connectionId) : null;
7522
+ const model = connection?.models.find((item) => item.id === bot.modelId);
7523
+ return model ? `${model.name} (${connection.displayName})` : bot.modelId;
7524
+ }
7525
+ async create(input) {
7526
+ const selection = this.selection(null, input);
7527
+ const created = this.bots.create(input.name, input.environment);
7528
+ if (input.description !== void 0 || input.persona !== void 0)
7529
+ this.bots.get(created.id).identity.update({ name: created.name, description: input.description ?? "", persona: input.persona ?? "" });
7530
+ if (selection) await this.bots.selectModel(created.id, selection);
7531
+ this.bots.listChanged();
7532
+ return this.store.getBot(created.id);
7533
+ }
7534
+ async update(bot, input) {
7535
+ const selection = this.selection(bot, input);
7536
+ if (input.name !== void 0 || input.description !== void 0 || input.persona !== void 0)
7537
+ this.bots.get(bot.id).identity.update({ name: input.name ?? bot.name, description: input.description ?? bot.description, persona: input.persona ?? bot.systemPrompt });
7538
+ if (selection) await this.bots.selectModel(bot.id, selection);
7539
+ const after2 = this.store.getBot(bot.id), changes = [];
7540
+ if (after2.name !== bot.name) changes.push({ what: "name", value: after2.name });
7541
+ if (after2.description !== bot.description) changes.push({ what: "description", value: after2.description });
7542
+ if (after2.systemPrompt !== bot.systemPrompt) changes.push({ what: "persona", value: null });
7543
+ if (after2.connectionId !== bot.connectionId || after2.modelId !== bot.modelId) changes.push({ what: "model", value: this.modelLabel(after2) });
7544
+ if (after2.thinkingLevel !== bot.thinkingLevel) changes.push({ what: "thinking", value: after2.thinkingLevel });
7545
+ this.tell(bot.id, changes);
7546
+ this.bots.listChanged();
7547
+ return { bot: after2, changed: changes.map((change) => change.what) };
7548
+ }
7549
+ async run(bot, action) {
7550
+ if (action === "stop" && bot.id === AGENT_BOT_ID) throw new ChatError(400, "You can't stop yourself");
7551
+ const runtime = this.bots.get(bot.id);
7552
+ await (action === "start" ? runtime.start() : runtime.stop());
7553
+ this.tell(bot.id, [{ what: "run", value: action }]);
7554
+ return this.bots.status(bot.id).status;
7555
+ }
7556
+ usage(bot, days) {
7557
+ const since = Date.now() - days * 24 * 60 * 60 * 1e3;
7558
+ return { since: new Date(since).toISOString(), models: this.bots.memory(bot.id).usage(since) };
7559
+ }
7560
+ /** Deletions run as the owner, who has just confirmed them on a card. */
7561
+ async deleteBot(bot) {
7562
+ if (bot.id === AGENT_BOT_ID) throw new ChatError(400, "You can't delete yourself");
7563
+ await this.bots.delete(bot.id, true);
7564
+ this.bots.listChanged();
7565
+ }
7566
+ async deleteChannel(chatId) {
7567
+ if (!await this.chats.delete(this.owner, chatId)) throw new ChatError(404, "Channel not found");
7568
+ await this.bots.chatDeleted(chatId, AGENT_BOT_ID);
7569
+ }
7570
+ removeMember(chatId, memberId) {
7571
+ this.chats.channels.removeMember(this.owner, chatId, memberId);
7572
+ }
7573
+ deleteSection(sectionId) {
7574
+ if (!this.chats.channels.deleteSection(this.owner, sectionId)) throw new ChatError(404, "Section not found");
7575
+ }
7576
+ /** The changed Bot is told with its next batch (digest: it is not woken for it), in its DM with the agent. */
7577
+ tell(botId, changes) {
7578
+ if (botId === AGENT_BOT_ID || !changes.length) return;
7579
+ const chatId = this.chats.openDirect(AGENT_BOT_ID, botId).id;
7580
+ for (const change of changes) this.store.agent.notify({ botId, kind: "change", reason: "agent", urgency: "digest", chatId, actorId: AGENT_BOT_ID, detail: JSON.stringify(change) });
7581
+ }
7582
+ /** The agent's first model: ask it, once, to welcome the owner in their DM, in their interface language. */
7583
+ onboard(language) {
7584
+ if (!this.store.agent.markOnboarded()) return;
7585
+ const chatId = this.chats.openDirect(this.owner, AGENT_BOT_ID).id;
7586
+ this.store.agent.notify({ botId: AGENT_BOT_ID, kind: "onboard", reason: "setup", urgency: "interrupt", chatId, actorId: this.owner, detail: language });
7587
+ this.bots.deliver(AGENT_BOT_ID, "interrupt");
7588
+ }
7589
+ };
7590
+
6161
7591
  // src/server/service/bot/service.ts
6162
7592
  var BotService = class {
6163
7593
  constructor(deps) {
6164
7594
  this.deps = deps;
7595
+ this.agent = new AgentOps(this, deps.store, deps.chats);
7596
+ this.computers = new Computers({ store: deps.store, changed: () => this.listChanged() });
7597
+ this.environments = new BotEnvironments(deps.store, this.computers);
6165
7598
  }
6166
7599
  bots = /* @__PURE__ */ new Map();
6167
7600
  lives = /* @__PURE__ */ new Map();
7601
+ computers;
7602
+ environments;
6168
7603
  closed = false;
6169
7604
  started = false;
7605
+ /** What only the agent Bot does (A48). */
7606
+ agent;
7607
+ listChanged() {
7608
+ this.deps.listChanged?.();
7609
+ }
6170
7610
  get(id) {
6171
7611
  if (this.closed) throw new RuntimeFault("stopping", "Shutting down.", 503);
6172
- const record2 = this.deps.store.getBot(id);
6173
- if (!record2) throw new RuntimeFault("bot_missing", "Bot not found", 404);
7612
+ const record3 = this.deps.store.getBot(id);
7613
+ if (!record3) throw new RuntimeFault("bot_missing", "Bot not found", 404);
6174
7614
  let bot = this.bots.get(id);
6175
7615
  if (bot) return bot;
6176
7616
  const { store, chats, files, credentials } = this.deps, live = this.live(id);
6177
- const runtime = new AgentRuntime(new BotInbox(id, store.runtime, chats, files), (setup, model, reference, activity) => (this.deps.factory ?? PiSession.create)({
7617
+ const here = () => {
7618
+ const place = this.environments.here(id, record3.workspacePath);
7619
+ return { name: place.name, directory: place.directory };
7620
+ };
7621
+ const runtime = new AgentRuntime(new BotInbox(id, store.runtime, chats, files, here), (setup, model, reference, activity) => (this.deps.factory ?? PiSession.create)({
6178
7622
  store,
6179
- bot: record2,
7623
+ bot: record3,
6180
7624
  selection: model.selection,
6181
7625
  reference,
6182
7626
  credentials,
@@ -6184,20 +7628,38 @@ var BotService = class {
6184
7628
  systemPrompt: () => botPrompt(setup),
6185
7629
  onHistory: setup.onHistory,
6186
7630
  activity,
6187
- live: (event) => live.event(event)
7631
+ live: (event) => live.event(event),
7632
+ environment: () => this.environments.current(id, record3.workspacePath)
6188
7633
  }));
6189
- const workspace = new Workspace(record2.workspacePath);
6190
- const memory = new Memory(join6(dirname5(record2.workspacePath), "history.db"));
7634
+ const workspace = new Workspace(record3.workspacePath, () => this.environments.current(id, record3.workspacePath), () => {
7635
+ const place = this.environments.home(id, record3.workspacePath);
7636
+ return { kind: place.kind === "sandbox" ? "computer" : "host", cwd: place.directory };
7637
+ });
7638
+ const memory = new Memory(join8(dirname7(record3.workspacePath), "history.db"));
6191
7639
  bot = new Bot(id, {
6192
7640
  identity: new Identity(id, store),
6193
7641
  model: new Model(id, store, resolveThinking),
6194
7642
  workspace,
6195
7643
  memory,
6196
- tools: new Tools({ botId: id, chats, files, memory, workspace, asking: (on) => runtime.waitingForUser(on), roster: () => this.roster() }),
7644
+ tools: new Tools({
7645
+ botId: id,
7646
+ chats,
7647
+ files,
7648
+ memory,
7649
+ workspace,
7650
+ asking: (on) => runtime.waitingForUser(on),
7651
+ roster: () => this.roster(),
7652
+ places: {
7653
+ list: () => this.environments.list(id, record3.workspacePath),
7654
+ switch: (environmentId) => this.environments.switch(id, environmentId, record3.workspacePath),
7655
+ summary: (botId) => this.environments.summary(botId)
7656
+ },
7657
+ ...id === AGENT_BOT_ID ? { agent: this.agent } : {}
7658
+ }),
6197
7659
  agentRuntime: runtime,
6198
7660
  state: new BotState(() => this.deps.changed(id))
6199
7661
  }, { deliver: async (message) => {
6200
- const outgoing = await files.readOutgoing(record2.workspacePath, message.attachments ?? [], message.signal);
7662
+ const outgoing = await workspace.readOutgoing(message.attachments ?? [], message.signal);
6201
7663
  message.signal?.throwIfAborted();
6202
7664
  return chats.deliver(id, message.chatId, {
6203
7665
  content: message.content,
@@ -6213,9 +7675,14 @@ var BotService = class {
6213
7675
  this.bots.set(id, bot);
6214
7676
  return bot;
6215
7677
  }
6216
- details(record2) {
6217
- const model = record2.connectionId ? this.deps.store.getModelConnection(record2.connectionId)?.models.find((model2) => model2.id === record2.modelId) : void 0;
6218
- return { ...record2, effectiveThinkingLevel: model ? resolveThinking(model, record2.thinkingLevel) : null };
7678
+ details(record3) {
7679
+ const model = record3.connectionId ? this.deps.store.getModelConnection(record3.connectionId)?.models.find((model2) => model2.id === record3.modelId) : void 0;
7680
+ return {
7681
+ ...record3,
7682
+ effectiveThinkingLevel: model ? resolveThinking(model, record3.thinkingLevel) : null,
7683
+ agent: record3.id === AGENT_BOT_ID,
7684
+ environment: this.environments.placement(record3.id)
7685
+ };
6219
7686
  }
6220
7687
  owns(id) {
6221
7688
  return this.deps.store.getBot(id) !== null;
@@ -6238,15 +7705,58 @@ var BotService = class {
6238
7705
  return { id: chat.id, type: chat.type, title: other ? this.deps.chats.member(other)?.name ?? chat.title : chat.title };
6239
7706
  }
6240
7707
  list() {
6241
- return this.deps.store.listBots().map((record2) => this.details(record2));
6242
- }
6243
- create(name2) {
6244
- return this.details(this.deps.store.createBot(name2));
7708
+ return this.deps.store.listBots().map((record3) => this.details(record3));
7709
+ }
7710
+ /** A new Bot works in its sandbox when this machine has Docker, unless asked for the owner's computer (A50). */
7711
+ create(name2, environment) {
7712
+ const kind = environment ?? (this.computers.available ? "computer" : "host");
7713
+ if (kind === "computer" && !this.computers.available) throw new RuntimeFault("docker_unavailable", "Docker is not available on this machine.", 409);
7714
+ const record3 = this.deps.store.createBot(name2);
7715
+ this.environments.setHome(record3.id, kind, record3.workspacePath);
7716
+ return this.details(record3);
7717
+ }
7718
+ /** The owner moves a Bot to its sandbox or to their computer, as its default; the next tool call runs there. */
7719
+ setHome(id, kind) {
7720
+ const moved = this.environments.setHome(id, kind, this.record(id).workspacePath);
7721
+ if (moved) this.tellOwner(id, [{ what: "environment", value: where(moved) }], true);
7722
+ return this.describe(id);
7723
+ }
7724
+ /** The owner lets a Bot use their computer, or takes it back; taking it back while the Bot is there sends it home. */
7725
+ grantHost(id, granted) {
7726
+ const { changed, moved } = this.environments.grantHost(id, granted, this.deps.store.userMemberId(), this.record(id).workspacePath);
7727
+ const changes = changed ? [{ what: "access", value: `your owner's computer: ${granted ? "you may switch to it" : "taken back"}` }] : [];
7728
+ if (moved) changes.push({ what: "environment", value: where(moved) });
7729
+ if (changes.length) this.tellOwner(id, changes, !!moved);
7730
+ return this.describe(id);
7731
+ }
7732
+ /** Tells a Bot what its owner changed about where it works (A41, A50). A move interrupts a running turn, as an
7733
+ * @-mention does, so the Bot doesn't go on in the old place; a Bot not running, or a change of access alone, is
7734
+ * told with its next batch (fish m1012–m1015). */
7735
+ tellOwner(id, changes, move) {
7736
+ if (!this.record(id).connectionId) return;
7737
+ const owner = this.deps.store.userMemberId(), chatId = this.deps.chats.openDirect(owner, id).id;
7738
+ const urgency = move && this.bots.get(id)?.status().status === "running" ? urgencyOf("change", "owner") : "digest";
7739
+ for (const change of changes) this.deps.store.agent.notify({ botId: id, kind: "change", reason: "owner", urgency, chatId, actorId: owner, detail: JSON.stringify(change) });
7740
+ if (urgency === "interrupt") this.deliver(id, urgency);
7741
+ }
7742
+ record(id) {
7743
+ const record3 = this.deps.store.getBot(id);
7744
+ if (!record3) throw new RuntimeFault("bot_missing", "Bot not found", 404);
7745
+ return record3;
7746
+ }
7747
+ /** Starting (or resetting, which starts afresh) returns at once: the Bot's state follows as it changes. */
7748
+ async computer(id, action) {
7749
+ this.describe(id);
7750
+ if (action === "stop") await this.computers.stop(id);
7751
+ else {
7752
+ if (action === "reset") await this.computers.reset(id);
7753
+ void this.computers.ensure(id).catch(() => {
7754
+ });
7755
+ }
7756
+ return this.describe(id);
6245
7757
  }
6246
7758
  describe(id) {
6247
- const record2 = this.deps.store.getBot(id);
6248
- if (!record2) throw new RuntimeFault("bot_missing", "Bot not found", 404);
6249
- return this.details(record2);
7759
+ return this.details(this.record(id));
6250
7760
  }
6251
7761
  runs(id) {
6252
7762
  this.describe(id);
@@ -6273,9 +7783,9 @@ var BotService = class {
6273
7783
  if (this.closed || !this.deps.store.getBot(id)) return;
6274
7784
  const bot = this.get(id);
6275
7785
  void bot.deliver(urgency).catch((error) => {
6276
- const fault = publicFault(error);
6277
- bot.state.update({ ...bot.status(), error: { code: fault.code, message: fault.message, ...fault.params ? { params: fault.params } : {} } });
6278
- console.error("[bot] delivery failed", { botId: id, code: fault.code });
7786
+ const fault2 = publicFault(error);
7787
+ bot.state.update({ ...bot.status(), error: { code: fault2.code, message: fault2.message, ...fault2.params ? { params: fault2.params } : {} } });
7788
+ console.error("[bot] delivery failed", { botId: id, code: fault2.code });
6279
7789
  });
6280
7790
  }
6281
7791
  /** Picks up what is still waiting for a Bot (after a failure, a new model, or a restart), if anything. */
@@ -6289,20 +7799,22 @@ var BotService = class {
6289
7799
  start() {
6290
7800
  if (this.started) return;
6291
7801
  this.started = true;
7802
+ void this.computers.start();
6292
7803
  for (const { botId, urgency } of this.deps.store.runtime.waitingBots(this.deps.store.userMemberId())) this.deliver(botId, urgency);
6293
7804
  }
6294
7805
  /** Every Bot's run state, for `bots.list` (A43). A Bot not loaded in this process is stopped. */
6295
7806
  roster() {
6296
7807
  const { store } = this.deps;
6297
- return store.listBots().map((record2) => {
6298
- const bot = this.bots.get(record2.id), runId = bot?.agentRuntime.activeRunId ?? null;
7808
+ return store.listBots().map((record3) => {
7809
+ const bot = this.bots.get(record3.id), runId = bot?.agentRuntime.activeRunId ?? null;
6299
7810
  const status = bot?.status() ?? { status: "stop", activity: null, context: null, error: null };
6300
7811
  return {
6301
- id: record2.id,
6302
- name: record2.name,
7812
+ id: record3.id,
7813
+ name: record3.name,
7814
+ agent: record3.id === AGENT_BOT_ID,
6303
7815
  status,
6304
7816
  runChatIds: status.status === "running" && runId ? store.runtime.inputs(runId).map((input) => input.chatId) : [],
6305
- errorCode: store.runtime.latestErrorCode(record2.id)
7817
+ errorCode: store.runtime.latestErrorCode(record3.id)
6306
7818
  };
6307
7819
  });
6308
7820
  }
@@ -6311,18 +7823,22 @@ var BotService = class {
6311
7823
  const bot = this.bots.get(id);
6312
7824
  return { ...bot?.status() ?? { status: "stop", activity: null, context: null, error: null }, botId: id, runId: bot?.agentRuntime.activeRunId ?? null };
6313
7825
  }
7826
+ /** `language` is the browser's interface language: the agent Bot's first model asks it to welcome the owner in it (A48). */
6314
7827
  async selectModel(id, input) {
6315
7828
  const selection = this.deps.store.validateSelection(id, input);
6316
7829
  if (!selection) throw new RuntimeFault("bot_missing", "Bot not found", 404);
6317
7830
  await this.get(id).changeModel(selection);
6318
7831
  this.resume(id);
7832
+ if (id === AGENT_BOT_ID) this.agent.onboard(welcomeLanguage(input.language));
6319
7833
  return this.deps.store.getBot(id);
6320
7834
  }
6321
7835
  connectionChanged(connectionId) {
6322
7836
  for (const bot of this.deps.store.listBots()) if (bot.connectionId === connectionId) this.resume(bot.id);
6323
7837
  }
6324
- async chatDeleted(chatId) {
6325
- for (const bot of this.bots.values()) {
7838
+ /** A deleted chat ends the turns handling it; `except` is the agent Bot that deleted it from inside its turn. */
7839
+ async chatDeleted(chatId, except) {
7840
+ for (const [id, bot] of this.bots) {
7841
+ if (id === except) continue;
6326
7842
  const runId = bot.agentRuntime.activeRunId;
6327
7843
  if (runId && this.deps.store.runtime.inputs(runId).some((input) => input.chatId === chatId)) await bot.abort();
6328
7844
  }
@@ -6330,21 +7846,25 @@ var BotService = class {
6330
7846
  /** Deletes a Bot: stop it, drop its data, keep its messages. `keepFiles` leaves the workspace (and
6331
7847
  * the History database) on disk, since it may hold work worth keeping. */
6332
7848
  async delete(id, keepFiles) {
7849
+ if (id === AGENT_BOT_ID) throw new RuntimeFault("agent_undeletable", "The agent Bot can't be deleted", 409);
6333
7850
  const bot = this.get(id), workspacePath = bot.workspace.location().cwd;
6334
7851
  await bot.stop();
6335
7852
  this.bots.delete(id);
6336
7853
  this.lives.get(id)?.close();
6337
7854
  this.lives.delete(id);
7855
+ await this.environments.release(id);
6338
7856
  if (!this.deps.store.deleteBot(id)) throw new RuntimeFault("bot_missing", "Bot not found", 404);
6339
- if (!keepFiles) await rm2(dirname5(workspacePath), { recursive: true, force: true });
7857
+ if (!keepFiles) await rm3(dirname7(workspacePath), { recursive: true, force: true });
6340
7858
  }
6341
7859
  async close() {
6342
7860
  if (this.closed) return;
6343
7861
  this.closed = true;
6344
7862
  await Promise.all([...this.bots.values()].map((bot) => bot.stop()));
6345
7863
  for (const live of this.lives.values()) live.close();
7864
+ await this.environments.close();
6346
7865
  }
6347
7866
  };
7867
+ var where = (moved) => `${moved.to.name}, in ${moved.to.directory}`;
6348
7868
 
6349
7869
  // src/server/application.ts
6350
7870
  var Application = class _Application {
@@ -6366,7 +7886,7 @@ var Application = class _Application {
6366
7886
  (id) => this.events.publish({ kind: "chat", id }),
6367
7887
  () => this.events.publish({ kind: "inbox", memberId: this.user.id })
6368
7888
  );
6369
- this.files = new ChatFiles(dataDir, this.chats);
7889
+ this.files = new ChatFiles(this.chats);
6370
7890
  store.runtime.recover();
6371
7891
  this.bots = new BotService({
6372
7892
  store,
@@ -6374,11 +7894,12 @@ var Application = class _Application {
6374
7894
  files: this.files,
6375
7895
  credentials: this.credentials,
6376
7896
  changed: (id) => this.events.publish({ kind: "bot", id, status: this.bots.status(id) }),
7897
+ listChanged: () => this.events.publish({ kind: "bots" }),
6377
7898
  factory
6378
7899
  });
6379
7900
  this.auth = new CredentialLogins(store, this.credentials, (id) => this.bots.connectionChanged(id));
6380
- this.account = new AccountService(store, createHash4("sha256").update(dataDir).digest("hex").slice(0, 10));
6381
- this.models = new ModelService(store, this.credentials, this.auth, (id) => this.bots.connectionChanged(id));
7901
+ this.account = new AccountService(store, createHash6("sha256").update(dataDir).digest("hex").slice(0, 10));
7902
+ this.models = new ModelService(store, this.credentials, this.auth, (id) => this.bots.connectionChanged(id), new ModelCatalog(dataDir));
6382
7903
  }
6383
7904
  events = new Changes();
6384
7905
  credentials = new CredentialCoordinator();
@@ -6391,8 +7912,8 @@ var Application = class _Application {
6391
7912
  models;
6392
7913
  closing;
6393
7914
  static async open(dataDir, options = {}) {
6394
- await mkdir4(resolve4(dataDir), { recursive: true, mode: 448 });
6395
- const root = await realpath2(resolve4(dataDir));
7915
+ await mkdir5(resolve3(dataDir), { recursive: true, mode: 448 });
7916
+ const root = await realpath(resolve3(dataDir));
6396
7917
  let app;
6397
7918
  let release;
6398
7919
  try {
@@ -6400,7 +7921,7 @@ var Application = class _Application {
6400
7921
  retries: 0,
6401
7922
  stale: 3e4,
6402
7923
  update: 1e4,
6403
- lockfilePath: join7(root, ".runtime.lock"),
7924
+ lockfilePath: join9(root, ".runtime.lock"),
6404
7925
  onCompromised: () => {
6405
7926
  console.error("[runtime] data lock compromised");
6406
7927
  options.onCompromised?.();
@@ -6423,6 +7944,7 @@ var Application = class _Application {
6423
7944
  }
6424
7945
  start() {
6425
7946
  this.bots.start();
7947
+ this.models.refreshCatalog();
6426
7948
  }
6427
7949
  close() {
6428
7950
  if (!this.closing) this.closing = (async () => {
@@ -6438,14 +7960,14 @@ var Application = class _Application {
6438
7960
  };
6439
7961
 
6440
7962
  // src/server/daemon.ts
6441
- import { spawn } from "node:child_process";
7963
+ import { spawn as spawn2 } from "node:child_process";
6442
7964
  import { closeSync, mkdirSync as mkdirSync3, openSync, readFileSync as readFileSync2, rmSync, writeFileSync } from "node:fs";
6443
- import { join as join8, resolve as resolve5 } from "node:path";
6444
- var pidPath = (dir) => join8(resolve5(dir), "jarvy.pid");
6445
- var logPath = (dir) => join8(resolve5(dir), "jarvy.log");
7965
+ import { join as join10, resolve as resolve4 } from "node:path";
7966
+ var pidPath = (dir) => join10(resolve4(dir), "jarvy.pid");
7967
+ var logPath = (dir) => join10(resolve4(dir), "jarvy.log");
6446
7968
  function writePidFile(dir, url) {
6447
- const record2 = { pid: process.pid, startToken: startToken(process.pid), url, startedAt: Date.now() };
6448
- writeFileSync(pidPath(dir), JSON.stringify(record2), { mode: 384 });
7969
+ const record3 = { pid: process.pid, startToken: startToken(process.pid), url, startedAt: Date.now() };
7970
+ writeFileSync(pidPath(dir), JSON.stringify(record3), { mode: 384 });
6449
7971
  }
6450
7972
  function removePidFile(dir) {
6451
7973
  if (readRecord(dir)?.pid === process.pid) rmSync(pidPath(dir), { force: true });
@@ -6473,19 +7995,19 @@ function alive(pid) {
6473
7995
  }
6474
7996
  }
6475
7997
  function runningServer(dir) {
6476
- const record2 = readRecord(dir);
6477
- if (!record2) return null;
6478
- const token = alive(record2.pid) ? startToken(record2.pid) : null;
6479
- if (token === null || token && record2.startToken && token !== record2.startToken) {
7998
+ const record3 = readRecord(dir);
7999
+ if (!record3) return null;
8000
+ const token = alive(record3.pid) ? startToken(record3.pid) : null;
8001
+ if (token === null || token && record3.startToken && token !== record3.startToken) {
6480
8002
  rmSync(pidPath(dir), { force: true });
6481
8003
  return null;
6482
8004
  }
6483
- return record2;
8005
+ return record3;
6484
8006
  }
6485
8007
  function startBackground(script, runArgs, dir) {
6486
- mkdirSync3(resolve5(dir), { recursive: true, mode: 448 });
8008
+ mkdirSync3(resolve4(dir), { recursive: true, mode: 448 });
6487
8009
  const log = openSync(logPath(dir), "a", 384);
6488
- const child = spawn(process.execPath, [...process.execArgv, script, "run", ...runArgs], { detached: true, stdio: ["ignore", log, log, "ipc"] });
8010
+ const child = spawn2(process.execPath, [...process.execArgv, script, "run", ...runArgs], { detached: true, stdio: ["ignore", log, log, "ipc"] });
6489
8011
  closeSync(log);
6490
8012
  return new Promise((resolvePromise, reject) => {
6491
8013
  child.once("error", reject);
@@ -6513,11 +8035,11 @@ function signalReady(url) {
6513
8035
  process.disconnect();
6514
8036
  }
6515
8037
  async function stopServer(dir, waitMs = 3e4) {
6516
- const record2 = runningServer(dir);
6517
- if (!record2) return "not_running";
6518
- process.kill(record2.pid, "SIGTERM");
8038
+ const record3 = runningServer(dir);
8039
+ if (!record3) return "not_running";
8040
+ process.kill(record3.pid, "SIGTERM");
6519
8041
  for (const deadline = Date.now() + waitMs; Date.now() < deadline; ) {
6520
- if (!alive(record2.pid)) {
8042
+ if (!alive(record3.pid)) {
6521
8043
  rmSync(pidPath(dir), { force: true });
6522
8044
  return "stopped";
6523
8045
  }
@@ -6536,7 +8058,7 @@ function option(args, flag, fallback) {
6536
8058
  function secretPrompt(label) {
6537
8059
  if (!process.stdin.isTTY || !process.stdin.setRawMode) throw new Error("Account setup requires an interactive terminal");
6538
8060
  process.stdout.write(label);
6539
- return new Promise((resolve6, reject) => {
8061
+ return new Promise((resolve5, reject) => {
6540
8062
  let value2 = "";
6541
8063
  const done = (result, error) => {
6542
8064
  process.stdin.off("data", onData);
@@ -6544,7 +8066,7 @@ function secretPrompt(label) {
6544
8066
  process.stdin.pause();
6545
8067
  process.stdout.write("\n");
6546
8068
  if (error) reject(error);
6547
- else resolve6(result ?? "");
8069
+ else resolve5(result ?? "");
6548
8070
  };
6549
8071
  const onData = (buffer) => {
6550
8072
  for (const char of buffer.toString("utf8")) {
@@ -6661,18 +8183,18 @@ async function main() {
6661
8183
  try {
6662
8184
  if (!store.isInitialized()) await initialize(store);
6663
8185
  const { host, port, url } = listenOptions(args);
6664
- const webRoot = join9(dirname6(fileURLToPath(import.meta.url)), "../web");
6665
- if (!existsSync2(join9(webRoot, "index.html"))) throw new Error("Web build missing. Run npm run build first");
8186
+ const webRoot = join11(dirname8(fileURLToPath(import.meta.url)), "../web");
8187
+ if (!existsSync2(join11(webRoot, "index.html"))) throw new Error("Web build missing. Run npm run build first");
6666
8188
  const server = createJarvyServer(app, webRoot);
6667
8189
  closeHttp = () => {
6668
8190
  server.close();
6669
8191
  server.closeAllConnections();
6670
8192
  };
6671
- await new Promise((resolve6, reject) => {
8193
+ await new Promise((resolve5, reject) => {
6672
8194
  server.once("error", reject);
6673
8195
  server.listen(port, host, () => {
6674
8196
  server.off("error", reject);
6675
- resolve6();
8197
+ resolve5();
6676
8198
  });
6677
8199
  });
6678
8200
  app.start();