agello 0.4.1 → 0.4.3

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/server.ts CHANGED
@@ -6,17 +6,22 @@
6
6
  // GET /events SSE: status, chat messages, tool start/end (from transcript JSONL)
7
7
  // POST /send {action, text, images?: [{type, data(base64)}]} -> herdr agent prompt
8
8
  // (images are saved to $TMPDIR/agello-uploads and sent as [image: <path>])
9
+ // (action "annotate": + target {selector, label, rect, text?, url?} from inspect)
9
10
  // GET /screen SSE: terminal-browser screen frames (CDP screencast)
10
11
  // POST /upload {images: [{type, data(base64)}]} -> {paths}: terminal image paste
11
12
  // WS /terminal interactive herdr terminal frames, keyboard input, resize
12
13
  // GET /panes herdr workspace -> tab -> pane tree
13
14
  // POST /panes/connect {pane} -> url of that pane's server (started if needed)
14
15
  // POST /panes/create {kind: workspace|tab|pane, cwd?, label?, workspace?, pane?, direction?}
15
- // WS /input user control: CDP Input.* commands forwarded to the page
16
+ // WS /input user control: CDP Input.* commands forwarded to the page;
17
+ // {id, inspect: {x, y} | {selector}, full?} -> {id, result}: element at a point (annotation)
16
18
  // GET /present presentation state
17
19
  // POST /present {rect?: {x,y,w,h}} start (or move the crop), {stop: true} end
18
20
  // ({stop: true, from: "viewer"}: ended from the page, the agent is told
19
21
  // with "[browser] action=present-stop")
22
+ // GET /script presentation script + player position
23
+ // POST /script {script: {rect?, steps: [{go?, say: string[], hold?}]}} load (keeps position)
24
+ // POST /player {cmd: "resume" | "pause" | "goto", at?: "3" | "3.2"}
20
25
 
21
26
  import { createTerminal, type SocketData } from "./terminal.ts";
22
27
  import { panesRoute } from "./panes.ts";
@@ -24,7 +29,8 @@ import { mkdir } from "node:fs/promises";
24
29
  import { homedir, tmpdir } from "node:os";
25
30
  import { join } from "node:path";
26
31
  import { runJson } from "./run.ts";
27
- import { createScreencast, type Rect } from "./screencast.ts";
32
+ import { createPlayer, parsePos, parseScript, type PlayerInfo } from "./player.ts";
33
+ import { createScreencast, type Inspected, type Rect } from "./screencast.ts";
28
34
 
29
35
  export type ServerOptions = {
30
36
  port: number;
@@ -123,7 +129,22 @@ export function parseRect(r: any): Rect | null {
123
129
  return { x, y, w, h };
124
130
  }
125
131
 
126
- export type Present = { on: boolean; rect?: Rect; since?: string };
132
+ // Annotation prompt: the viewer's request plus the element it points at, in
133
+ // 3 lines (request / selector / element summary).
134
+ export function formatAnnotation(text: string, t: any): string | null {
135
+ if (!t || typeof t.selector !== "string" || !t.selector || t.selector.length > 1000) return null;
136
+ const clean = (v: unknown, n: number) => String(v ?? "").replace(/\s+/g, " ").trim().slice(0, n);
137
+ const request = text.split(/\r?\n/).map((l) => l.trim()).filter(Boolean).join(" / ");
138
+ const r = t.rect ?? {};
139
+ const [x, y, w, h] = [r.x, r.y, r.w, r.h].map((v) => Math.round(Number(v) || 0));
140
+ const label = clean(t.label, 120);
141
+ const snippet = clean(t.text, 80);
142
+ const url = clean(t.url, 300);
143
+ const info = [`요소: ${label}${snippet ? ` "${snippet}"` : ""}`, `위치 ${x},${y} 크기 ${w}x${h}`, url].filter(Boolean);
144
+ return [request, `대상: ${clean(t.selector, 1000)}`, info.join(" · ")].join("\n");
145
+ }
146
+
147
+ export type Present = { on: boolean; rect?: Rect; since?: string; player?: PlayerInfo };
127
148
 
128
149
  type Status =
129
150
  | { alive: false; reason: string; pane: string }
@@ -351,10 +372,67 @@ export async function startServer(opts: ServerOptions) {
351
372
  // short-lived bubbles. rect: crop of the visible viewport (unset = whole screen).
352
373
 
353
374
  let present: Present = { on: false };
375
+ const presentView = (): Present => ({ ...present, player: player.info() });
354
376
  function setPresent(next: Present) {
355
377
  present = next;
356
378
  screen.setClip(next.on ? (next.rect ?? null) : null);
357
- broadcast("present", present);
379
+ if (!next.on) player.stop();
380
+ broadcast("present", presentView());
381
+ }
382
+
383
+ // Script player: agello owns order and timing, the agent owns the deck
384
+ // (each step's `go`) and decides when to resume after a question.
385
+ const player = createPlayer({
386
+ async go(expr) {
387
+ const js = expr.startsWith("#") ? `location.hash = ${JSON.stringify(expr)}` : expr;
388
+ if (!(await screen.evaluate(js))) console.error(`present: go failed: ${expr}`);
389
+ },
390
+ say(text, at, hold) {
391
+ broadcast("message", { role: "assistant", text, ts: new Date().toISOString(), script: at, hold });
392
+ },
393
+ done() {
394
+ const n = player.info().steps;
395
+ promptAgent("present-done", `대본 끝까지 재생함 (${n}장)`);
396
+ },
397
+ changed() {
398
+ broadcast("present", presentView());
399
+ },
400
+ });
401
+
402
+ // "마지막 표시 3.2 · 다음 3.3" for agent notices
403
+ const where = () => {
404
+ const i = player.info();
405
+ if (i.state === "empty") return "";
406
+ return [i.last && `마지막 표시 ${i.last}`, i.next && `다음 ${i.next}`].filter(Boolean).join(" · ");
407
+ };
408
+
409
+ async function scriptRequest(req: Request): Promise<Response> {
410
+ if (req.method !== "POST") return Response.json({ script: player.script(), player: player.info() });
411
+ const data = (await req.json().catch(() => ({}))) as { script?: unknown };
412
+ const script = parseScript(data.script);
413
+ if (typeof script === "string") return Response.json({ ok: false, error: script }, { status: 400 });
414
+ if (script.rect && !parseRect(script.rect))
415
+ return Response.json({ ok: false, error: "invalid_rect" }, { status: 400 });
416
+ player.load(script);
417
+ return Response.json({ ok: true, player: player.info() });
418
+ }
419
+
420
+ async function playerRequest(req: Request): Promise<Response> {
421
+ const data = (await req.json().catch(() => ({}))) as { cmd?: string; at?: string };
422
+ let error: string | null = null;
423
+ if (data.cmd === "resume") {
424
+ if (!present.on) {
425
+ const rect = player.script()?.rect;
426
+ setPresent({ on: true, rect: rect ? parseRect(rect)! : undefined, since: new Date().toISOString() });
427
+ }
428
+ error = player.resume();
429
+ } else if (data.cmd === "pause") player.pause();
430
+ else if (data.cmd === "goto") {
431
+ const pos = parsePos(data.at ?? "");
432
+ error = pos ? await player.goto(pos) : "invalid_position";
433
+ } else error = "unknown_cmd";
434
+ if (error) return Response.json({ ok: false, error, player: player.info() }, { status: 400 });
435
+ return Response.json({ ok: true, player: player.info() });
358
436
  }
359
437
 
360
438
  async function presentRequest(req: Request): Promise<Response> {
@@ -366,10 +444,11 @@ export async function startServer(opts: ServerOptions) {
366
444
  // (The CLI stop comes from the agent itself and needs no notice.)
367
445
  let notified: string | undefined;
368
446
  if (was && data.from === "viewer") {
369
- const r = await promptAgent("present-stop", "사용자가 페이지에서 발표를 종료함");
447
+ const at = where();
448
+ const r = await promptAgent("present-stop", `사용자가 페이지에서 발표를 종료함${at ? ` (대본 정지: ${at})` : ""}`);
370
449
  notified = r.ok ? "ok" : r.error;
371
450
  }
372
- return Response.json({ ok: true, present, notified });
451
+ return Response.json({ ok: true, present: presentView(), notified });
373
452
  }
374
453
  let rect: Rect | undefined;
375
454
  if (data.rect != null) {
@@ -377,7 +456,7 @@ export async function startServer(opts: ServerOptions) {
377
456
  if (!rect) return Response.json({ ok: false, error: "invalid_rect" }, { status: 400 });
378
457
  }
379
458
  setPresent({ on: true, rect, since: present.on ? present.since : new Date().toISOString() });
380
- return Response.json({ ok: true, present });
459
+ return Response.json({ ok: true, present: presentView() });
381
460
  }
382
461
 
383
462
  // ----- HTTP -----
@@ -418,7 +497,7 @@ export async function startServer(opts: ServerOptions) {
418
497
  tools: [...pendingTools.keys()],
419
498
  });
420
499
  if (lastStatus) send(c, "status", lastStatus);
421
- send(c, "present", present);
500
+ send(c, "present", presentView());
422
501
  for (const t of pendingTools.values()) send(c, "tool_start", t);
423
502
  ping = setInterval(() => send(c, "ping", {}), 15000);
424
503
  },
@@ -443,11 +522,25 @@ export async function startServer(opts: ServerOptions) {
443
522
  }
444
523
 
445
524
  async function sendPrompt(req: Request): Promise<Response> {
446
- const data = (await req.json().catch(() => ({}))) as { action?: string; text?: string; images?: unknown };
525
+ const data = (await req.json().catch(() => ({}))) as { action?: string; text?: string; images?: unknown; target?: Inspected };
447
526
  const action = /^[\w-]{1,32}$/.test(data.action ?? "") ? data.action! : "message";
527
+ let text = data.text ?? "";
528
+ if (action === "annotate") {
529
+ const t = formatAnnotation(text, data.target);
530
+ if (!t || !text.trim()) return Response.json({ ok: false, error: "invalid_target" }, { status: 400 });
531
+ text = t;
532
+ }
533
+ // Raised hand: stop the script at once (no agent round trip), rewind so
534
+ // resume replays the interrupted line, and tell the agent where it stopped.
535
+ if (action === "hand-raise") {
536
+ player.pause();
537
+ player.rewind();
538
+ const at = where();
539
+ if (at) text += ` (대본 정지: ${at})`;
540
+ }
448
541
  const images = await saveImages(data.images);
449
542
  if (!images) return Response.json({ ok: false, error: "invalid_images" }, { status: 400 });
450
- const text = [data.text ?? "", ...images.map((p) => `[image: ${p}]`)].join("\n");
543
+ text = [text, ...images.map((p) => `[image: ${p}]`)].join("\n");
451
544
  const r = await promptAgent(action, text);
452
545
  return r.ok ? Response.json({ ok: true }) : Response.json({ ok: false, error: r.error }, { status: r.status });
453
546
  }
@@ -463,7 +556,7 @@ export async function startServer(opts: ServerOptions) {
463
556
  ...(await checkStatus()),
464
557
  browser: opts.browser,
465
558
  screen: await screen.describe(),
466
- present,
559
+ present: presentView(),
467
560
  });
468
561
  case "/events":
469
562
  return events();
@@ -488,7 +581,12 @@ export async function startServer(opts: ServerOptions) {
488
581
  }
489
582
  break;
490
583
  case "/present":
491
- return req.method === "POST" ? presentRequest(req) : Response.json(present);
584
+ return req.method === "POST" ? presentRequest(req) : Response.json(presentView());
585
+ case "/script":
586
+ return scriptRequest(req);
587
+ case "/player":
588
+ if (req.method === "POST") return playerRequest(req);
589
+ break;
492
590
  }
493
591
  return new Response(HTML, { headers: { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" } });
494
592
  }
@@ -500,9 +598,25 @@ export async function startServer(opts: ServerOptions) {
500
598
  websocket: {
501
599
  maxPayloadLength: 128 * 1024,
502
600
  open(ws) { if (ws.data.kind === "terminal") terminal.open(ws); },
503
- message(ws, msg) {
504
- if (ws.data.kind === "terminal") terminal.message(ws, String(msg));
505
- else screen.input(String(msg));
601
+ async message(ws, msg) {
602
+ const raw = String(msg);
603
+ if (ws.data.kind === "terminal") return terminal.message(ws, raw);
604
+ let m: any;
605
+ try {
606
+ m = JSON.parse(raw);
607
+ } catch {
608
+ return;
609
+ }
610
+ if (m?.inspect) {
611
+ const q = m.inspect;
612
+ const query =
613
+ typeof q.selector === "string"
614
+ ? { selector: q.selector.slice(0, 1000), full: !!q.full }
615
+ : { x: Number(q.x) || 0, y: Number(q.y) || 0, full: !!q.full };
616
+ ws.send(JSON.stringify({ id: m.id, result: await screen.inspect(query) }));
617
+ return;
618
+ }
619
+ screen.input(raw);
506
620
  },
507
621
  close(ws) { if (ws.data.kind === "terminal") terminal.close(ws); },
508
622
  },