@kevin5251984/guild 0.2.21 → 0.2.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/public/md.js CHANGED
@@ -19,22 +19,39 @@ function isFileCode(text) {
19
19
  return /[A-Za-z0-9]\/[A-Za-z0-9._@+-]/.test(s);
20
20
  }
21
21
 
22
+ function localImgSrc(href) {
23
+ const raw = String(href || "")
24
+ .trim()
25
+ .replace(/"/g, "");
26
+ if (!raw) return "";
27
+ if (/^\/generated\/[A-Za-z0-9._-]+$/.test(raw)) return raw;
28
+ if (/^https?:\/\//i.test(raw)) return raw;
29
+ let path = raw;
30
+ if (/^file:\/\//i.test(path)) path = path.replace(/^file:\/\//i, "");
31
+ if (!path.startsWith("/") || path.startsWith("//") || path.includes("..")) {
32
+ return "";
33
+ }
34
+ if (!/\.(?:png|jpe?g|gif|webp)$/i.test(path)) return "";
35
+ return "/local?p=" + encodeURIComponent(path);
36
+ }
37
+
22
38
  function inlineMd(text) {
23
39
  let out = escapeMd(text);
24
- out = out.replace(
25
- /!\[([^\]]*)\]\((\/generated\/[A-Za-z0-9._-]+|https?:[^)\s]+)\)/g,
26
- function (_m, alt, href) {
27
- const src = String(href).replace(/"/g, "");
28
- const label = alt || "image";
29
- return (
30
- '<img class="md-img" src="' +
31
- src +
32
- '" alt="' +
33
- label +
34
- '" loading="lazy">'
35
- );
36
- },
37
- );
40
+ out = out.replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g, function (m, alt, href) {
41
+ const src = localImgSrc(href);
42
+ if (!src) return m;
43
+ const label = alt || "image";
44
+ return (
45
+ '<a class="md-img-link" href="' +
46
+ src +
47
+ '" target="_blank" rel="noopener noreferrer">' +
48
+ '<img class="md-img" src="' +
49
+ src +
50
+ '" alt="' +
51
+ label +
52
+ '" loading="lazy"></a>'
53
+ );
54
+ });
38
55
  out = out.replace(/\[([^\]]+)\]\((https?:[^)\s]+)\)/g, function (_m, label, href) {
39
56
  return (
40
57
  '<a href="' +
@@ -327,23 +344,83 @@ function renderMarkdown(raw) {
327
344
  return html.join("");
328
345
  }
329
346
 
347
+ function htmlPreviewSrcdoc(raw, lang) {
348
+ const text = String(raw || "");
349
+ if (/svg/i.test(String(lang || ""))) {
350
+ return (
351
+ '<!doctype html><html><body style="margin:0;background:#fff">' +
352
+ text +
353
+ "</body></html>"
354
+ );
355
+ }
356
+ if (/<!doctype html|<html[\s>]/i.test(text)) return text;
357
+ return (
358
+ '<!doctype html><html><body style="margin:0;background:transparent">' +
359
+ text +
360
+ "</body></html>"
361
+ );
362
+ }
363
+
330
364
  function hydrateHtmlPreviews(root) {
331
365
  if (!root || typeof root.querySelectorAll !== "function") return;
332
366
  root.querySelectorAll(".md-html-preview").forEach((box) => {
333
367
  const src = box.querySelector(".md-html-src");
334
368
  const frame = box.querySelector(".md-html-frame");
335
- if (!src || !frame || frame.dataset.ready) return;
336
- frame.dataset.ready = "1";
369
+ if (!src || !frame) return;
337
370
  const raw = src.value;
338
371
  const lang = (box.querySelector(".md-fence-lang") || {}).textContent || "";
339
- frame.srcdoc = /svg/i.test(lang)
340
- ? '<!doctype html><html><body style="margin:0;background:#fff">' +
341
- raw +
342
- "</body></html>"
343
- : raw;
372
+ const doc = htmlPreviewSrcdoc(raw, lang);
373
+ if (frame.dataset.ready && frame.srcdoc === doc) return;
374
+ frame.dataset.ready = "1";
375
+ frame.srcdoc = doc;
376
+ });
377
+ }
378
+
379
+ function holdHtmlFrames(root) {
380
+ const frames = [];
381
+ if (!root || typeof root.querySelectorAll !== "function") return frames;
382
+ root.querySelectorAll("article.msg .md-html-preview").forEach((box) => {
383
+ const article = box.closest("article.msg[data-id]");
384
+ const frame = box.querySelector(".md-html-frame");
385
+ const src = box.querySelector(".md-html-src");
386
+ if (!article || !frame || !src || !frame.dataset.ready) return;
387
+ frames.push({
388
+ id: article.getAttribute("data-id"),
389
+ src: src.value,
390
+ view: box.getAttribute("data-view") || "preview",
391
+ frame: frame,
392
+ });
393
+ });
394
+ return frames;
395
+ }
396
+
397
+ function putHtmlFrames(root, frames) {
398
+ if (!root || typeof root.querySelector !== "function") return;
399
+ (frames || []).forEach((item) => {
400
+ const article = root.querySelector(
401
+ 'article.msg[data-id="' + CSS.escape(item.id) + '"]',
402
+ );
403
+ const box = article && article.querySelector(".md-html-preview");
404
+ const src = box && box.querySelector(".md-html-src");
405
+ const fresh = box && box.querySelector(".md-html-frame");
406
+ if (!box || !src || !fresh || src.value !== item.src) return;
407
+ item.frame.dataset.ready = "1";
408
+ fresh.replaceWith(item.frame);
409
+ box.setAttribute("data-view", item.view);
410
+ box.querySelectorAll("[data-html-view]").forEach((tab) => {
411
+ tab.classList.toggle("on", tab.getAttribute("data-html-view") === item.view);
412
+ });
344
413
  });
345
414
  }
346
415
 
347
416
  if (typeof module !== "undefined" && module.exports) {
348
- module.exports = { renderMarkdown, inlineMd, hydrateHtmlPreviews };
417
+ module.exports = {
418
+ renderMarkdown,
419
+ inlineMd,
420
+ hydrateHtmlPreviews,
421
+ htmlPreviewSrcdoc,
422
+ holdHtmlFrames,
423
+ putHtmlFrames,
424
+ localImgSrc,
425
+ };
349
426
  }
@@ -391,7 +391,8 @@ textarea:focus-visible {
391
391
  min-height: 220px;
392
392
  height: 52vh;
393
393
  border: 0;
394
- background: #fff;
394
+ background: transparent;
395
+ color-scheme: dark;
395
396
  border-radius: 0 0 12px 12px;
396
397
  }
397
398
  .assistant-text .md-html-preview[data-view="code"] .md-html-frame { display: none; }
@@ -441,8 +442,17 @@ textarea:focus-visible {
441
442
  border-radius: 16px;
442
443
  background: #fff;
443
444
  }
444
- .assistant-text img.md-img {
445
+ .assistant-text .md-img-link {
446
+ display: inline-block;
445
447
  max-width: 100%;
448
+ line-height: 0;
449
+ }
450
+ .assistant-text img.md-img {
451
+ max-width: min(100%, 22rem);
452
+ max-height: min(16rem, 40vh);
453
+ width: auto;
454
+ height: auto;
455
+ object-fit: contain;
446
456
  border-radius: 10px;
447
457
  }
448
458
  .turn-live {
@@ -9,7 +9,7 @@
9
9
  <link rel="icon" href="/favicon-32.png" type="image/png" sizes="32x32" />
10
10
  <link rel="icon" href="/favicon-16.png" type="image/png" sizes="16x16" />
11
11
  <title>Guild — 外出</title>
12
- <link rel="stylesheet" href="/mobile.css?v=html-preview" />
12
+ <link rel="stylesheet" href="/mobile.css?v=html-frame" />
13
13
  <script src="/i18n.js"></script>
14
14
  <script src="/md.js"></script>
15
15
  </head>
@@ -484,6 +484,8 @@
484
484
  return (
485
485
  '<article class="msg' +
486
486
  (you ? " you" : " bot") +
487
+ '" data-id="' +
488
+ escapeHtml(msg.id || "") +
487
489
  '"><span class="avatar" style="' +
488
490
  avStyle +
489
491
  '">' +
@@ -520,7 +522,10 @@
520
522
  escapeHtml(t("mobile.recent", { n: cap })) +
521
523
  "</div>"
522
524
  : "";
525
+ const keptHtml =
526
+ typeof holdHtmlFrames === "function" ? holdHtmlFrames(root) : [];
523
527
  root.innerHTML = notice + rows.map(msgHtml).join("") + turnStatusHtml();
528
+ if (typeof putHtmlFrames === "function") putHtmlFrames(root, keptHtml);
524
529
  if (typeof hydrateHtmlPreviews === "function") {
525
530
  hydrateHtmlPreviews(root);
526
531
  }
@@ -662,12 +667,25 @@
662
667
  renderList();
663
668
  if (state.kind) renderHead();
664
669
  }
670
+ function sameMessages(next) {
671
+ const prev = state.messages || [];
672
+ if (prev.length !== next.length) return false;
673
+ return next.every(
674
+ (msg, i) =>
675
+ prev[i].id === msg.id &&
676
+ prev[i].body === msg.body &&
677
+ Boolean(prev[i].finishedAt) === Boolean(msg.finishedAt),
678
+ );
679
+ }
665
680
  async function loadMessages(opts) {
666
681
  if (!state.kind) return;
667
682
  const res = await fetch(roomMessagesUrl());
668
683
  const body = await res.json().catch(() => []);
669
684
  if (!res.ok) throw new Error(body.error || "load failed");
670
- state.messages = Array.isArray(body) ? body : [];
685
+ const incoming = Array.isArray(body) ? body : [];
686
+ const unchanged = sameMessages(incoming);
687
+ state.messages = incoming;
688
+ if (opts && opts.merge && unchanged) return;
671
689
  renderThread({ pinBottom: !(opts && opts.merge) });
672
690
  }
673
691
  async function pollLive() {
@@ -684,10 +702,10 @@
684
702
  state.lives = lives;
685
703
  if (!lives.length && !state.posting) {
686
704
  if (was) await loadMessages({ merge: true }).catch(() => {});
687
- else renderThread();
705
+ } else if (now !== was) {
706
+ await loadMessages({ merge: true }).catch(() => {});
688
707
  } else {
689
- if (now !== was) await loadMessages({ merge: true }).catch(() => {});
690
- else renderThread();
708
+ renderThread();
691
709
  }
692
710
  syncComposer();
693
711
  } catch {
package/src/router.ts CHANGED
@@ -1,5 +1,6 @@
1
- import { existsSync, readFileSync } from "node:fs";
1
+ import { existsSync, readFileSync, statSync } from "node:fs";
2
2
  import type { IncomingMessage, ServerResponse } from "node:http";
3
+ import { extname } from "node:path";
3
4
  import { fileURLToPath } from "node:url";
4
5
  import type { LibraryKind, ModelRef, ModelsFile } from "@guild/protocol";
5
6
  import {
@@ -66,6 +67,30 @@ import { hostGit, hostList, hostRead, hostTree } from "./host-browse.ts";
66
67
  import { listHostSkills } from "./host-skills.ts";
67
68
  import { listHostAgents } from "./host-agents.ts";
68
69
  import { generatedDir, isSafeGeneratedName } from "./image-gen.ts";
70
+ import {
71
+ createCronJob,
72
+ fireCronJob,
73
+ pauseCronJob,
74
+ publicCronJob,
75
+ removeCronJob,
76
+ resumeCronJob,
77
+ } from "./cron.ts";
78
+ import {
79
+ allowedWorkspaceWritePath,
80
+ resolveToolPath,
81
+ workspaceFromEnv,
82
+ } from "./harness.ts";
83
+
84
+ const LOCAL_IMAGE_CAP = 12 * 1024 * 1024;
85
+
86
+ function localImageType(path: string): string | null {
87
+ const ext = extname(path).toLowerCase();
88
+ if (ext === ".png") return "image/png";
89
+ if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg";
90
+ if (ext === ".gif") return "image/gif";
91
+ if (ext === ".webp") return "image/webp";
92
+ return null;
93
+ }
69
94
 
70
95
  const PUBLIC = fileURLToPath(new URL("./public/", import.meta.url));
71
96
 
@@ -391,6 +416,38 @@ export async function handleRequest(
391
416
  return;
392
417
  }
393
418
 
419
+ if (method === "GET" && path === "/local") {
420
+ const raw = requestUrl(req).searchParams.get("p") || "";
421
+ const type = localImageType(raw);
422
+ if (!raw.startsWith("/") || raw.includes("\0") || raw.includes("..") || !type) {
423
+ json(res, 404, { error: "not_found", path });
424
+ return;
425
+ }
426
+ const target = resolveToolPath(raw);
427
+ if (!allowedWorkspaceWritePath(target, workspaceFromEnv(env), store.dataDir)) {
428
+ json(res, 404, { error: "not_found", path });
429
+ return;
430
+ }
431
+ if (!existsSync(target)) {
432
+ json(res, 404, { error: "not_found", path });
433
+ return;
434
+ }
435
+ const st = statSync(target);
436
+ if (!st.isFile() || st.size > LOCAL_IMAGE_CAP) {
437
+ json(res, 404, { error: "not_found", path });
438
+ return;
439
+ }
440
+ const bytes = readFileSync(target);
441
+ res.writeHead(200, {
442
+ "content-type": type,
443
+ "content-length": bytes.length,
444
+ "cache-control": "private, max-age=60",
445
+ "x-content-type-options": "nosniff",
446
+ });
447
+ res.end(bytes);
448
+ return;
449
+ }
450
+
394
451
  if (method === "GET" && path.startsWith("/generated/")) {
395
452
  const name = decodeURIComponent(path.slice("/generated/".length));
396
453
  if (!isSafeGeneratedName(name)) {
@@ -444,6 +501,45 @@ export async function handleRequest(
444
501
  return;
445
502
  }
446
503
 
504
+ if (path === "/cron" && method === "GET") {
505
+ const room = requestUrl(req).searchParams.get("room") || "";
506
+ json(res, 200, {
507
+ jobs: store.listCronJobs(room || undefined).map(publicCronJob),
508
+ });
509
+ return;
510
+ }
511
+ if (path === "/cron" && method === "POST") {
512
+ const body = asRecord(await readJson(req));
513
+ const job = createCronJob(store, {
514
+ roomId: str(body, "roomId") || str(body, "room_id"),
515
+ botId: str(body, "botId") || str(body, "bot_id"),
516
+ prompt: str(body, "prompt"),
517
+ schedule: str(body, "schedule"),
518
+ name: str(body, "name"),
519
+ });
520
+ json(res, 201, publicCronJob(job));
521
+ return;
522
+ }
523
+ const cronAct = path.match(/^\/cron\/([^/]+)\/(pause|resume|run)$/);
524
+ if (cronAct && method === "POST") {
525
+ const id = decodeURIComponent(cronAct[1]);
526
+ if (cronAct[2] === "pause") {
527
+ json(res, 200, publicCronJob(pauseCronJob(store, id)));
528
+ return;
529
+ }
530
+ if (cronAct[2] === "resume") {
531
+ json(res, 200, publicCronJob(resumeCronJob(store, id)));
532
+ return;
533
+ }
534
+ json(res, 200, await fireCronJob(store, id, env));
535
+ return;
536
+ }
537
+ const cronOne = path.match(/^\/cron\/([^/]+)$/);
538
+ if (cronOne && method === "DELETE") {
539
+ json(res, 200, removeCronJob(store, decodeURIComponent(cronOne[1])));
540
+ return;
541
+ }
542
+
447
543
  if (method === "GET" && (path === "/bots" || path === "/bench")) {
448
544
  json(res, 200, listBench(store));
449
545
  return;
package/src/store.ts CHANGED
@@ -10,7 +10,7 @@ import type { TrajectoryDraft, TrajectoryEvent } from "./trajectory.ts";
10
10
  import { homedir } from "node:os";
11
11
  import { join } from "node:path";
12
12
  import { randomUUID } from "node:crypto";
13
- import { closeGuildDb, openGuildDb, type GuildDb } from "./db.ts";
13
+ import { closeGuildDb, openGuildDb, type CronJobRow, type GuildDb } from "./db.ts";
14
14
  import type {
15
15
  Bot,
16
16
  ChatAttachment,
@@ -1329,6 +1329,27 @@ export class GuildStore {
1329
1329
  if (!this.getRoom(roomId)) throw new StoreError(404, "room not found");
1330
1330
  this.db.writeCompact(roomId, compact);
1331
1331
  }
1332
+
1333
+ listCronJobs(roomId?: string) {
1334
+ if (roomId && !this.getRoom(roomId)) throw new StoreError(404, "room not found");
1335
+ return this.db.listCronJobs(roomId);
1336
+ }
1337
+
1338
+ getCronJob(id: string) {
1339
+ const job = this.db.getCronJob(id);
1340
+ if (!job) throw new StoreError(404, "cron job not found");
1341
+ return job;
1342
+ }
1343
+
1344
+ writeCronJob(job: CronJobRow): void {
1345
+ if (!this.getRoom(job.roomId)) throw new StoreError(404, "room not found");
1346
+ if (!this.getBot(job.botId)) throw new StoreError(400, "bot not found");
1347
+ this.db.upsertCronJob(job);
1348
+ }
1349
+
1350
+ deleteCronJob(id: string): boolean {
1351
+ return this.db.deleteCronJob(id);
1352
+ }
1332
1353
  }
1333
1354
 
1334
1355
  function normalizePortrait(raw: string | null | undefined): string | undefined {
package/src/subagent.ts CHANGED
@@ -373,6 +373,9 @@ export async function spawnSubagent(input: {
373
373
  allowWrite: child.allowWrite,
374
374
  sandbox: child.sandbox,
375
375
  workspace: input.ctx.workspace,
376
+ roomId: input.ctx.roomId,
377
+ botId: input.ctx.botId,
378
+ cronRun: input.ctx.cronRun,
376
379
  dispatch: input.ctx.dispatch,
377
380
  signal: input.ctx.signal,
378
381
  },
package/src/tools.ts CHANGED
@@ -78,6 +78,12 @@ export type ToolContext = {
78
78
  ) => Promise<ToolOutcome>;
79
79
  /** Devin-style background spawn handles for this turn. Same Map across dispatch clones. */
80
80
  spawnHandles?: Map<string, SpawnHandle>;
81
+ /** Channel or DM this turn is in. Cron jobs default here. */
82
+ roomId?: string;
83
+ /** Seat running this turn. cronjob create defaults here. */
84
+ botId?: string;
85
+ /** Hermes: cron child sessions cannot manage cron. */
86
+ cronRun?: boolean;
81
87
  };
82
88
 
83
89
  export type SpawnHandle = {
@@ -191,6 +197,33 @@ export function guildTools(
191
197
  (tool) => tool.name !== "image_gen" && tool.name !== "browser",
192
198
  );
193
199
  }
200
+ if (!ctx.cronRun) {
201
+ tools.push({
202
+ name: "cronjob",
203
+ description:
204
+ "Schedule a later hall turn (Hermes cronjob). Fresh @handle turn with a self-contained prompt. Actions: create, list, pause, resume, run, remove. schedule may be natural language (每10分鐘, 10分鐘後, 每天9點, in 30 minutes, every 2h, 0 9 * * *, ISO). Split when vs task: schedule is the time phrase, prompt is the work. bot_id defaults to this seat. Do not create cron jobs from a cron run.",
205
+ parameters: Type.Object({
206
+ action: Type.String({
207
+ description: "create | list | pause | resume | run | remove",
208
+ }),
209
+ schedule: Type.Optional(
210
+ Type.String({
211
+ description:
212
+ "Natural language or Hermes form: 每10分鐘, in 30m, every 2h, 每天9點, 0 9 * * *, ISO",
213
+ }),
214
+ ),
215
+ prompt: Type.Optional(
216
+ Type.String({ description: "Self-contained task for the seat" }),
217
+ ),
218
+ name: Type.Optional(Type.String({ description: "Short job name" })),
219
+ job_id: Type.Optional(Type.String({ description: "Job id or name" })),
220
+ bot_id: Type.Optional(Type.String({ description: "Seat to run" })),
221
+ room_id: Type.Optional(
222
+ Type.String({ description: "Room id. Defaults to this hall." }),
223
+ ),
224
+ }),
225
+ });
226
+ }
194
227
  tools.push({
195
228
  name: "skill",
196
229
  description: `Load a staffed skill's full instructions by name.${available}`,
@@ -387,6 +420,21 @@ function openaiParameters(name: string): {
387
420
  required: ["prompt"],
388
421
  };
389
422
  }
423
+ if (name === "cronjob") {
424
+ return {
425
+ type: "object",
426
+ properties: {
427
+ action: { type: "string", description: "create | list | pause | resume | run | remove" },
428
+ schedule: { type: "string" },
429
+ prompt: { type: "string" },
430
+ name: { type: "string" },
431
+ job_id: { type: "string" },
432
+ bot_id: { type: "string" },
433
+ room_id: { type: "string" },
434
+ },
435
+ required: ["action"],
436
+ };
437
+ }
390
438
  if (name === "browser") {
391
439
  return {
392
440
  type: "object",
@@ -437,6 +485,7 @@ export const BUILTIN_TOOL_NAMES = [
437
485
  "read_spawn",
438
486
  "image_gen",
439
487
  "browser",
488
+ "cronjob",
440
489
  ] as const;
441
490
 
442
491
  export async function executeTool(
@@ -512,6 +561,12 @@ export async function builtinExecute(
512
561
  env: ctx.env,
513
562
  });
514
563
  }
564
+ if (name === "cronjob") {
565
+ return {
566
+ text: "cronjob needs guildd (the cron plugin)",
567
+ isError: true,
568
+ };
569
+ }
515
570
  if (name === "browser") {
516
571
  const { runBrowser } = await import("./browser.ts");
517
572
  return runBrowser(args, {
@@ -817,7 +872,7 @@ export function nextToolRound(round: number): ToolRoundPhase {
817
872
  }
818
873
 
819
874
  export const TOOL_SYSTEM = `You ARE already running on the user's local computer (Guild, same design as Pi / DeepSeek Harness).
820
- Tools: run, read, write, list, skill, spawn, image_gen, browser, plus any connected MCP tools (names start with mcp__).
875
+ Tools: run, read, write, list, skill, spawn, image_gen, browser, cronjob, plus any connected MCP tools (names start with mcp__).
821
876
  You can inspect RAM, disk, CPU, processes, files, and run shell commands.
822
877
  Never say you cannot access this machine. Never tell the user to run the command themselves.
823
878
  When the question is about this computer, call tools first, then answer with evidence from the output.
@@ -827,4 +882,5 @@ You stay coordinator. Spawn is the specialist, not a last resort (Devin run_suba
827
882
  Independent tool calls in one round also run in parallel — fire several reads/searches together.
828
883
  Check the [exit code: N] marker on every run result; investigate failures before moving on. Prefer the workdir argument over cd.
829
884
  To follow a staffed skill, call skill with its exact name (or slug) before applying it. Relative paths in a skill resolve against that skill's base directory.
885
+ When the user asks to 排程 / schedule a later hall turn — including natural-language times like 每10分鐘, 10分鐘後, tomorrow 9am — call cronjob create. schedule is the time phrase; prompt is the self-contained task (the job will not see this live turn). bot_id defaults to you. Also accepts in 30m, every 2h, 0 9 * * *, ISO. A cron run cannot create more cron jobs.
830
886
  Prefer small commands. macOS RAM: sysctl hw.memsize ; memory_pressure. Disk: df -h.`;