@signalcore/ui 1.0.0-beta.1 → 1.0.0-beta.2

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.
Files changed (108) hide show
  1. package/README.md +8 -1
  2. package/dist/AgentAbout.js +7 -4
  3. package/dist/AgentAbout.js.map +1 -1
  4. package/dist/AgentTasks.js +2 -1
  5. package/dist/AgentTasks.js.map +1 -1
  6. package/dist/VoiceCall.js +1 -1
  7. package/dist/VoiceCall.js.map +1 -1
  8. package/dist/about.d.ts +34 -144
  9. package/dist/about.js +6 -8
  10. package/dist/about.js.map +1 -1
  11. package/dist/automation/cron.d.ts +1 -1
  12. package/dist/automation/cron.js.map +1 -1
  13. package/dist/automation/marks.js +7 -3
  14. package/dist/automation/marks.js.map +1 -1
  15. package/dist/automation/messages.d.ts +2 -0
  16. package/dist/automation/messages.js +2 -0
  17. package/dist/automation/messages.js.map +1 -1
  18. package/dist/automation/types.d.ts +25 -128
  19. package/dist/automation/types.js +36 -0
  20. package/dist/automation/types.js.map +1 -0
  21. package/dist/automation/when.d.ts +1 -1
  22. package/dist/automation/when.js +5 -1
  23. package/dist/automation/when.js.map +1 -1
  24. package/dist/blocks.d.ts +23 -1
  25. package/dist/browser.js +2 -2
  26. package/dist/browser.js.map +1 -1
  27. package/dist/call.d.ts +1 -1
  28. package/dist/call.js.map +1 -1
  29. package/dist/contract.d.ts +262 -0
  30. package/dist/credentials.js.map +1 -1
  31. package/dist/dev.js +1 -1
  32. package/dist/dev.js.map +1 -1
  33. package/dist/es.js +9 -1
  34. package/dist/es.js.map +1 -1
  35. package/dist/index.d.ts +2 -2
  36. package/dist/index.js +2 -1
  37. package/dist/kit/AudioPlayer.js +1 -1
  38. package/dist/kit/AudioPlayer.js.map +1 -1
  39. package/dist/kit/Choices.js +1 -1
  40. package/dist/kit/Choices.js.map +1 -1
  41. package/dist/kit/FileField.js +1 -1
  42. package/dist/kit/FileField.js.map +1 -1
  43. package/dist/kit/Popover.js.map +1 -1
  44. package/dist/kit/Tile.d.ts +1 -1
  45. package/dist/kit/Tile.js +1 -1
  46. package/dist/kit/Tile.js.map +1 -1
  47. package/dist/kit/messages.d.ts +13 -0
  48. package/dist/kit/messages.js +7 -1
  49. package/dist/kit/messages.js.map +1 -1
  50. package/dist/kit/uploads.d.ts +2 -1
  51. package/dist/kit/uploads.js +1 -1
  52. package/dist/kit/uploads.js.map +1 -1
  53. package/dist/kit/waveform.js.map +1 -1
  54. package/dist/markdown.js.map +1 -1
  55. package/dist/marks.js +2 -3
  56. package/dist/marks.js.map +1 -1
  57. package/dist/surface/Markdown.js +1 -1
  58. package/dist/surface/Markdown.js.map +1 -1
  59. package/dist/surface/Surface.js +43 -4
  60. package/dist/surface/Surface.js.map +1 -1
  61. package/dist/surface/scope.d.ts +1 -1
  62. package/dist/surface/scope.js.map +1 -1
  63. package/dist/surface/values.js +1 -1
  64. package/dist/surface/values.js.map +1 -1
  65. package/dist/tint.d.ts +1 -1
  66. package/dist/tint.js +1 -1
  67. package/dist/tint.js.map +1 -1
  68. package/dist/usePolled.js +1 -1
  69. package/dist/usePolled.js.map +1 -1
  70. package/dist/voice/webgl.js +1 -1
  71. package/dist/voice/webgl.js.map +1 -1
  72. package/dist/widget.js +14 -14
  73. package/package.json +1 -1
  74. package/src/AgentAbout.tsx +10 -3
  75. package/src/AgentTasks.tsx +2 -2
  76. package/src/VoiceCall.tsx +1 -1
  77. package/src/about.ts +42 -144
  78. package/src/automation/cron.ts +1 -1
  79. package/src/automation/marks.tsx +6 -2
  80. package/src/automation/messages.ts +4 -0
  81. package/src/automation/types.ts +58 -118
  82. package/src/automation/when.ts +3 -1
  83. package/src/blocks.ts +25 -0
  84. package/src/browser.ts +4 -4
  85. package/src/call.ts +1 -1
  86. package/src/contract.ts +1720 -0
  87. package/src/credentials.ts +2 -2
  88. package/src/dev.ts +1 -1
  89. package/src/es.ts +8 -0
  90. package/src/index.ts +1 -0
  91. package/src/kit/AudioPlayer.tsx +1 -1
  92. package/src/kit/Choices.tsx +1 -1
  93. package/src/kit/FileField.tsx +1 -1
  94. package/src/kit/Popover.tsx +1 -1
  95. package/src/kit/Tile.tsx +1 -1
  96. package/src/kit/messages.ts +20 -0
  97. package/src/kit/uploads.ts +2 -2
  98. package/src/kit/waveform.ts +1 -1
  99. package/src/markdown.ts +1 -1
  100. package/src/marks.tsx +3 -123
  101. package/src/styles.css +15 -0
  102. package/src/surface/Markdown.tsx +1 -1
  103. package/src/surface/Surface.tsx +53 -4
  104. package/src/surface/scope.ts +3 -1
  105. package/src/surface/values.ts +5 -5
  106. package/src/tint.ts +1 -1
  107. package/src/usePolled.ts +2 -2
  108. package/src/voice/webgl.ts +1 -1
@@ -1,143 +1,40 @@
1
+ import { AutomationDetailOut, AutomationOut, Board as Board$1, RunDetailOut, RunOut, RunStepOut, Step as Step$1, TaskOut, TriggerView, Use as Use$1 } from "../contract.js";
1
2
  //#region src/automation/types.d.ts
2
- /**
3
- * An agent's automations and their runs, as the API gives them (v3/AUTOMATIONS.md): what starts
4
- * each one, the steps its code names, the table it moves, and every run with its record. Written
5
- * by hand, one to one with the API's answers; times are ISO 8601 in UTC.
6
- */
7
3
  /**
8
4
  * What a step uses, as the manifest reads it from the code before any run: an app, the model,
9
5
  * the agent, the owner's inbox, a message to a contact on a channel (`sc.send`), a table, the web.
10
6
  */
11
- export interface Use {
12
- kind: "app" | "ai" | "agent" | "notify" | "send" | "data" | "http";
13
- /** An app's: the connection's name (its alias). */
14
- app: string | null;
15
- /** An app's: its display name. */
16
- title: string | null;
17
- /** An app's: its logo (https), when known. */
18
- icon: string | null;
19
- }
7
+ export type Use = Use$1;
20
8
  /** A step of the code (`sc.step("…")`), in the code's order, with what it uses. */
21
- export interface Step {
22
- name: string;
23
- uses: Use[];
24
- }
25
- /** What starts an automation: a time (cron, on a zone's clock), a call to its URL, an app's event. */
26
- export interface Trigger {
27
- kind: "schedule" | "webhook" | "event";
28
- /** Five-field cron. */
29
- schedule: string | null;
30
- /** IANA zone. */
31
- timezone: string | null;
32
- /** The webhook's full URL. */
33
- url: string | null;
34
- /** An event's app: the connection's name, its display name, its logo. */
35
- app: string | null;
36
- app_title: string | null;
37
- /** The provider's event slug, and the event in words when the provider gives them. */
38
- event: string | null;
39
- event_title: string | null;
40
- icon: string | null;
41
- }
42
- /** The table an automation moves, as counts of its rows per value of one field. */
43
- export interface Board {
44
- table: string;
45
- /** The table's title. */
46
- title: string;
47
- /** The field the rows are counted by. */
48
- field: string;
49
- /** Per value, biggest first, at most 8. */
50
- counts: {
51
- value: string;
52
- count: number;
53
- }[];
54
- }
55
- export type RunStatus = "running" | "waiting" | "done" | "failed" | "skipped";
56
- /** What started a run: its time, a call, an app's event, the agent in a chat, a test. */
57
- export type RunBy = "schedule" | "webhook" | "event" | "agent" | "test";
9
+ export type Step = Step$1;
10
+ /** What starts an automation: a time (cron, on a zone's clock), a call to its URL, an app's
11
+ * event, one of the platform's. */
12
+ export type Trigger = TriggerView;
13
+ /** The table an automation moves, as counts of its rows per value of one field (biggest first,
14
+ * at most 8). */
15
+ export type Board = Board$1;
16
+ export type RunStatus = RunOut["status"];
17
+ /** What started a run: its time, a call, an app's event, the platform, the agent, a test. */
18
+ export type RunBy = RunOut["by"];
58
19
  /** One start of an automation, whatever started it. */
59
- export interface Run {
60
- id: string;
61
- agent_id: string;
62
- /** The automation's name. */
63
- automation: string;
64
- /** The automation's title when it ran. */
65
- title: string;
66
- by: RunBy;
67
- /** Started by the agent: the conversation it was asked in. */
68
- conversation_id: string | null;
69
- status: RunStatus;
70
- /** What the code returned: the run's one line. */
71
- summary: string | null;
72
- /** Why it failed, in words, naming the step: "Paró en «…»: …". */
73
- error: string | null;
74
- /** A test: what would change the world was recorded, not done. */
75
- dry: boolean;
76
- started_at: string;
77
- ended_at: string | null;
78
- tokens_in: number;
79
- tokens_out: number;
80
- /** The conversations it opened. */
81
- opened: string[];
82
- }
20
+ export type Run = RunOut;
83
21
  /** One call of a run's record, in the order it was made. */
84
- export interface RunStep {
85
- seq: number;
86
- /** The `sc.step` it ran in. */
87
- step: string | null;
88
- /**
89
- * `tool:<app>.<TOOL>` · `ai` · `notify` · `send` · `agent.start` · `data.find` · `data.add` ·
90
- * `data.update` · `http` · `files.search` · `memory.remember` · `memory.recall`.
91
- */
92
- call: string;
93
- /** Both trimmed. */
94
- input: unknown;
95
- output: unknown;
96
- error: string | null;
97
- ms: number;
98
- at: string;
99
- /** A test's write: recorded, not done. */
100
- would: boolean;
101
- }
22
+ export type RunStep = RunStepOut;
102
23
  /** A run with its record (`GET /runs/{id}`). */
103
- export type RunRecord = Run & {
104
- steps: RunStep[];
105
- };
106
- export type AutomationState = "active" | "paused" | "failing";
24
+ export type RunRecord = RunDetailOut;
25
+ export type AutomationState = AutomationOut["state"];
107
26
  /** One automation: `automations/<name>.py` in the agent's folder, with its trigger and its state. */
108
- export interface Automation {
109
- /** Its trigger's id. */
110
- id: string;
111
- agent_id: string;
112
- /** Its file's name; unique per agent. */
113
- name: string;
114
- title: string;
115
- /** What it does, in the owner's words. */
116
- summary: string;
117
- trigger: Trigger;
118
- on: boolean;
119
- next_run_at: string | null;
120
- steps: Step[];
121
- board: Board | null;
122
- /** `failing`: its last finished real run failed. */
123
- state: AutomationState;
124
- /** Its newest run, tests included. */
125
- last_run: Run | null;
126
- }
27
+ export type Automation = AutomationOut;
127
28
  /** An automation with its newest runs (`GET /agents/{id}/automations/{name}`: 20; in a block: 5). */
128
- export type AutomationView = Automation & {
129
- runs: Run[];
130
- };
29
+ export type AutomationView = AutomationDetailOut;
131
30
  /**
132
- * A task the agent runs for one contact (`GET /agents/{id}/tasks`): set up in words in a
133
- * conversation with them, read as an automation is (its instruction is its `summary`, its trigger
134
- * a schedule: a cron line, or none for a task once at `next_run_at`), with its newest five runs.
31
+ * A task the agent runs for one contact (`GET /agents/{id}/tasks`): an instance of an automation
32
+ * for contacts (`automation`), or of the platform's instruction (its words in `inputs`), on a
33
+ * schedule (a cron line, or once at `next_run_at`), with its newest five runs.
135
34
  */
136
- export type Task = AutomationView & {
137
- contact_id: string;
138
- /** The conversation it runs in: the one it was set up in. */
139
- conversation_id: string | null;
140
- created_at: string;
141
- };
35
+ export type Task = TaskOut;
36
+ /** A task as the automation card draws it, from what it is: its words its summary, its trigger
37
+ * its schedule, failing when its last finished real run failed. */
38
+ export declare function taskCard(task: Task): AutomationView;
142
39
  //#endregion
143
40
  //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1,36 @@
1
+ //#region src/automation/types.ts
2
+ /** A task as the automation card draws it, from what it is: its words its summary, its trigger
3
+ * its schedule, failing when its last finished real run failed. */
4
+ function taskCard(task) {
5
+ const last = task.runs.filter((run) => run.status !== "running" && run.status !== "waiting").find((run) => !run.dry);
6
+ const words = task.inputs["instruction"];
7
+ return {
8
+ id: task.id,
9
+ agent_id: task.agent_id,
10
+ name: task.automation ?? "instruction",
11
+ title: task.title,
12
+ summary: typeof words === "string" ? words : "",
13
+ trigger: {
14
+ kind: "schedule",
15
+ schedule: task.schedule,
16
+ timezone: task.timezone,
17
+ url: null,
18
+ app: null,
19
+ app_title: null,
20
+ event: null,
21
+ event_title: null,
22
+ icon: null
23
+ },
24
+ on: task.on,
25
+ next_run_at: task.next_run_at,
26
+ steps: [],
27
+ board: null,
28
+ state: !task.on ? "paused" : last?.status === "failed" ? "failing" : "active",
29
+ last_run: task.runs[0] ?? null,
30
+ runs: task.runs
31
+ };
32
+ }
33
+ //#endregion
34
+ export { taskCard };
35
+
36
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","names":[],"sources":["../../src/automation/types.ts"],"sourcesContent":["/**\n * An agent's automations and their runs, as the API gives them (v3/AUTOMATIONS.md): what starts\n * each one, the steps its code names, the table it moves, and every run with its record. The\n * contract's own types (`contract.ts`, generated from the API's); times are ISO 8601 in UTC.\n */\n\nimport type * as C from \"../contract\";\n\n/**\n * What a step uses, as the manifest reads it from the code before any run: an app, the model,\n * the agent, the owner's inbox, a message to a contact on a channel (`sc.send`), a table, the web.\n */\nexport type Use = C.Use;\n\n/** A step of the code (`sc.step(\"…\")`), in the code's order, with what it uses. */\nexport type Step = C.Step;\n\n/** What starts an automation: a time (cron, on a zone's clock), a call to its URL, an app's\n * event, one of the platform's. */\nexport type Trigger = C.TriggerView;\n\n/** The table an automation moves, as counts of its rows per value of one field (biggest first,\n * at most 8). */\nexport type Board = C.Board;\n\nexport type RunStatus = C.RunOut[\"status\"];\n\n/** What started a run: its time, a call, an app's event, the platform, the agent, a test. */\nexport type RunBy = C.RunOut[\"by\"];\n\n/** One start of an automation, whatever started it. */\nexport type Run = C.RunOut;\n\n/** One call of a run's record, in the order it was made. */\nexport type RunStep = C.RunStepOut;\n\n/** A run with its record (`GET /runs/{id}`). */\nexport type RunRecord = C.RunDetailOut;\n\nexport type AutomationState = C.AutomationOut[\"state\"];\n\n/** One automation: `automations/<name>.py` in the agent's folder, with its trigger and its state. */\nexport type Automation = C.AutomationOut;\n\n/** An automation with its newest runs (`GET /agents/{id}/automations/{name}`: 20; in a block: 5). */\nexport type AutomationView = C.AutomationDetailOut;\n\n/**\n * A task the agent runs for one contact (`GET /agents/{id}/tasks`): an instance of an automation\n * for contacts (`automation`), or of the platform's instruction (its words in `inputs`), on a\n * schedule (a cron line, or once at `next_run_at`), with its newest five runs.\n */\nexport type Task = C.TaskOut;\n\n/** A task as the automation card draws it, from what it is: its words its summary, its trigger\n * its schedule, failing when its last finished real run failed. */\nexport function taskCard(task: Task): AutomationView {\n const ended = task.runs.filter((run) => run.status !== \"running\" && run.status !== \"waiting\");\n const last = ended.find((run) => !run.dry);\n const words = task.inputs[\"instruction\"];\n return {\n id: task.id,\n agent_id: task.agent_id,\n name: task.automation ?? \"instruction\",\n title: task.title,\n summary: typeof words === \"string\" ? words : \"\",\n trigger: {\n kind: \"schedule\",\n schedule: task.schedule,\n timezone: task.timezone,\n url: null,\n app: null,\n app_title: null,\n event: null,\n event_title: null,\n icon: null,\n },\n on: task.on,\n next_run_at: task.next_run_at,\n steps: [],\n board: null,\n state: !task.on ? \"paused\" : last?.status === \"failed\" ? \"failing\" : \"active\",\n last_run: task.runs[0] ?? null,\n runs: task.runs,\n };\n}\n"],"mappings":";;;AAwDA,SAAgB,SAAS,MAA4B;CAEnD,MAAM,OADQ,KAAK,KAAK,QAAQ,QAAQ,IAAI,WAAW,aAAa,IAAI,WAAW,SAClE,CAAC,CAAC,MAAM,QAAQ,CAAC,IAAI,GAAG;CACzC,MAAM,QAAQ,KAAK,OAAO;CAC1B,OAAO;EACL,IAAI,KAAK;EACT,UAAU,KAAK;EACf,MAAM,KAAK,cAAc;EACzB,OAAO,KAAK;EACZ,SAAS,OAAO,UAAU,WAAW,QAAQ;EAC7C,SAAS;GACP,MAAM;GACN,UAAU,KAAK;GACf,UAAU,KAAK;GACf,KAAK;GACL,KAAK;GACL,WAAW;GACX,OAAO;GACP,aAAa;GACb,MAAM;EACR;EACA,IAAI,KAAK;EACT,aAAa,KAAK;EAClB,OAAO,CAAC;EACR,OAAO;EACP,OAAO,CAAC,KAAK,KAAK,WAAW,MAAM,WAAW,WAAW,YAAY;EACrE,UAAU,KAAK,KAAK,MAAM;EAC1B,MAAM,KAAK;CACb;AACF"}
@@ -8,7 +8,7 @@ export interface When {
8
8
  text: string;
9
9
  timed: boolean;
10
10
  }
11
- /** A trigger in words: its schedule as a sentence, a webhook's call, an app's event. */
11
+ /** A trigger in words: its schedule as a sentence, a webhook's call, an app's event, a message. */
12
12
  export declare function whenOf(trigger: Trigger, words: AutomationMessages, code: string): When;
13
13
  /** A cron line as a sentence: its days, then its times or how often. */
14
14
  export declare function scheduleOf(line: string, words: AutomationMessages, code: string): When;
@@ -14,7 +14,7 @@ function clockOf(hour, minute, code) {
14
14
  timeZone: "UTC"
15
15
  }).format(new Date(Date.UTC(2026, 0, 1, hour, minute)));
16
16
  }
17
- /** A trigger in words: its schedule as a sentence, a webhook's call, an app's event. */
17
+ /** A trigger in words: its schedule as a sentence, a webhook's call, an app's event, a message. */
18
18
  function whenOf(trigger, words, code) {
19
19
  switch (trigger.kind) {
20
20
  case "schedule": return trigger.schedule === null ? {
@@ -25,6 +25,10 @@ function whenOf(trigger, words, code) {
25
25
  text: words.webhook,
26
26
  timed: false
27
27
  };
28
+ case "platform": return {
29
+ text: words.platform,
30
+ timed: false
31
+ };
28
32
  case "event": return {
29
33
  text: words.event(trigger.app_title ?? trigger.app, trigger.event_title),
30
34
  timed: false
@@ -1 +1 @@
1
- {"version":3,"file":"when.js","names":[],"sources":["../../src/automation/when.ts"],"sourcesContent":["/**\n * When an automation runs, and when one ran, as a person says it: its trigger in a sentence\n * (\"Cada día laborable a las 9:00\"), its next run (\"próxima mañana\"), the moment a run started\n * (\"Ayer 16:12\"), how long it took. The words are the host's (`AutomationMessages`); the locale's\n * code writes the times, the days and the lists. Pure, so it is tested without a page.\n */\n\nimport { parseCron, type Cron } from \"./cron\";\nimport type { AutomationMessages, DayRef } from \"./messages\";\nimport type { Automation, Trigger } from \"./types\";\n\n/** A time of day as the locale writes it (\"9:00\", \"9:00 AM\"), from a cron line's hour and minute. */\nexport function clockOf(hour: number, minute: number, code: string): string {\n return new Intl.DateTimeFormat(code, {\n hour: \"numeric\",\n minute: \"2-digit\",\n timeZone: \"UTC\",\n }).format(new Date(Date.UTC(2026, 0, 1, hour, minute)));\n}\n\n/** A trigger in words, and whether it names the one time of day it runs (so the next needs none). */\nexport interface When {\n text: string;\n timed: boolean;\n}\n\n/** A trigger in words: its schedule as a sentence, a webhook's call, an app's event. */\nexport function whenOf(trigger: Trigger, words: AutomationMessages, code: string): When {\n switch (trigger.kind) {\n case \"schedule\":\n return trigger.schedule === null\n ? { text: words.custom, timed: false }\n : scheduleOf(trigger.schedule, words, code);\n case \"webhook\":\n return { text: words.webhook, timed: false };\n case \"event\":\n return {\n text: words.event(trigger.app_title ?? trigger.app, trigger.event_title),\n timed: false,\n };\n }\n}\n\nconst sorted = (values: ReadonlySet<number>): number[] => [...values].sort((a, b) => a - b);\n\n/** Whether the values are every `step`-th one from `from` up to `to`: 0, 15, 30, 45. */\nfunction stepOf(values: number[]): number | null {\n if (values.length < 2) return null;\n const step = (values[1] ?? 0) - (values[0] ?? 0);\n return values.every((value, at) => value === (values[0] ?? 0) + at * step) ? step : null;\n}\n\n/** Monday first, as a week is read; Sunday is 0 in cron. */\nconst weekOrder = (day: number) => (day + 6) % 7;\n\nfunction weekdayName(day: number, code: string): string {\n // 4 January 2026 is a Sunday.\n return new Intl.DateTimeFormat(code, { weekday: \"long\", timeZone: \"UTC\" }).format(\n new Date(Date.UTC(2026, 0, 4 + day)),\n );\n}\n\n/** The days a line runs on, in words; null for what a sentence can't say. */\nfunction daysOf(cron: Cron, words: AutomationMessages, code: string): string | null {\n if (cron.months.size !== 12) return null;\n if (cron.anyDay && cron.anyWeekday) return words.daily;\n if (cron.anyDay) {\n const days = [...cron.weekdays].sort((a, b) => weekOrder(a) - weekOrder(b));\n const key = days.join(\",\");\n if (days.length === 7) return words.daily;\n if (key === \"1,2,3,4,5\") return words.weekdays;\n if (key === \"6,0\") return words.weekends;\n return words.weekly(days.map((day) => weekdayName(day, code)));\n }\n if (cron.anyWeekday) return words.monthly(sorted(cron.days));\n return null;\n}\n\n/** At most this many times are listed one by one; more read as a frequency. */\nconst LISTED = 4;\n\n/** The times a line runs at in a day: listed (\"a las 9:00\"), or how often (\"cada hora\"). */\nfunction timesOf(\n cron: Cron,\n words: AutomationMessages,\n code: string,\n): { text: string; listed: number } | null {\n const hours = sorted(cron.hours);\n const minutes = sorted(cron.minutes);\n const first = hours[0] ?? 0;\n const last = hours[hours.length - 1] ?? 0;\n if (hours.length * minutes.length <= LISTED) {\n const times = hours.flatMap((hour) => minutes.map((minute) => clockOf(hour, minute, code)));\n return { text: words.at(times), listed: times.length };\n }\n // Once in each of its hours, every few hours; or every few minutes through its hours.\n const minuteStep = minutes.length === 60 ? 1 : stepOf(minutes);\n const hourStep = hours.length === 24 || hours.length === 1 ? 1 : stepOf(hours);\n let often: string;\n if (minutes.length === 1 && hourStep !== null) {\n often = words.every(hourStep, \"hour\");\n } else if (minuteStep !== null && minutes[0] === 0 && 60 % minuteStep === 0 && hourStep === 1) {\n often = words.every(minuteStep, \"minute\");\n } else {\n return null;\n }\n // The whole day (\"cada 2 horas\"), or a part of it (\"cada hora de 8:00 a 18:00\").\n const whole = hourStep !== null && hours.length * hourStep === 24 && first < hourStep;\n if (whole) return { text: often, listed: 0 };\n const from = clockOf(first, minutes[0] ?? 0, code);\n const to = clockOf(last, minutes[minutes.length - 1] ?? 0, code);\n return { text: `${often} ${words.between(from, to)}`, listed: 0 };\n}\n\n/** A cron line as a sentence: its days, then its times or how often. */\nexport function scheduleOf(line: string, words: AutomationMessages, code: string): When {\n const cron = parseCron(line);\n const days = cron && daysOf(cron, words, code);\n const times = cron && timesOf(cron, words, code);\n if (!days || !times) return { text: words.custom, timed: false };\n if (times.listed > 0) return { text: `${days} ${times.text}`, timed: times.listed === 1 };\n return { text: days === words.daily ? times.text : words.on(times.text, days), timed: false };\n}\n\n/** A calendar day where the person is, as a count of days from the epoch's. */\nconst dayNumber = (moment: Date): number =>\n Math.round(\n (new Date(moment.getFullYear(), moment.getMonth(), moment.getDate()).getTime() -\n new Date(1970, 0, 1).getTime()) /\n 86_400_000,\n );\n\n/**\n * A moment's day beside `now`, where the person is: today, yesterday, tomorrow, a weekday within\n * the week, else its date (with its year when not this one).\n */\nexport function dayRefOf(\n moment: Date,\n now: Date,\n code: string,\n weekday: \"short\" | \"long\" = \"short\",\n): DayRef {\n const days = dayNumber(moment) - dayNumber(now);\n if (days === 0) return { kind: \"today\" };\n if (days === -1) return { kind: \"yesterday\" };\n if (days === 1) return { kind: \"tomorrow\" };\n if (Math.abs(days) < 7) {\n return { kind: \"weekday\", name: new Intl.DateTimeFormat(code, { weekday }).format(moment) };\n }\n return {\n kind: \"date\",\n name: new Intl.DateTimeFormat(code, {\n day: \"numeric\",\n month: \"short\",\n ...(moment.getFullYear() === now.getFullYear() ? {} : { year: \"numeric\" }),\n }).format(moment),\n };\n}\n\n/** A moment's time of day where the person is: \"9:00\". */\nexport const timeOf = (moment: Date, code: string): string =>\n new Intl.DateTimeFormat(code, { hour: \"numeric\", minute: \"2-digit\" }).format(moment);\n\n/** When a run started: \"Hoy 9:00\", \"Ayer 16:12\", \"Lun 9:00\", \"3 oct 9:00\". */\nexport function momentOf(at: Date, now: Date, words: AutomationMessages, code: string): string {\n return words.moment(dayRefOf(at, now, code), timeOf(at, code));\n}\n\n/**\n * The line under an automation's title: when it runs and, while it is on and has a next run,\n * when that is (\"Cada día laborable a las 9:00, próxima mañana\"). The next run's time is said\n * when the sentence doesn't already say it, and always when it is today.\n */\nexport function lineOf(\n automation: Pick<Automation, \"trigger\" | \"on\" | \"next_run_at\">,\n now: Date,\n words: AutomationMessages,\n code: string,\n): string {\n const when = whenOf(automation.trigger, words, code);\n if (!automation.on || automation.next_run_at === null) return when.text;\n const next = new Date(automation.next_run_at);\n const day = dayRefOf(next, now, code, \"long\");\n const time = when.timed && day.kind !== \"today\" ? null : timeOf(next, code);\n return `${when.text}, ${words.next(day, time)}`;\n}\n\n/** How long a run took, once it ended. */\nexport function tookOf(\n run: { started_at: string; ended_at: string | null },\n words: AutomationMessages,\n): string | null {\n if (run.ended_at === null) return null;\n return words.took(Math.max(0, Date.parse(run.ended_at) - Date.parse(run.started_at)));\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"when.js","names":[],"sources":["../../src/automation/when.ts"],"sourcesContent":["/**\n * When an automation runs, and when one ran, as a person says it: its trigger in a sentence\n * (\"Cada día laborable a las 9:00\"), its next run (\"próxima mañana\"), the moment a run started\n * (\"Ayer 16:12\"), how long it took. The words are the host's (`AutomationMessages`); the locale's\n * code writes the times, the days and the lists. Pure, so it is tested without a page.\n */\n\nimport { parseCron, type Cron } from \"./cron\";\nimport type { AutomationMessages, DayRef } from \"./messages\";\nimport type { Automation, Trigger } from \"./types\";\n\n/** A time of day as the locale writes it (\"9:00\", \"9:00 AM\"), from a cron line's hour and minute. */\nexport function clockOf(hour: number, minute: number, code: string): string {\n return new Intl.DateTimeFormat(code, {\n hour: \"numeric\",\n minute: \"2-digit\",\n timeZone: \"UTC\",\n }).format(new Date(Date.UTC(2026, 0, 1, hour, minute)));\n}\n\n/** A trigger in words, and whether it names the one time of day it runs (so the next needs none). */\nexport interface When {\n text: string;\n timed: boolean;\n}\n\n/** A trigger in words: its schedule as a sentence, a webhook's call, an app's event, a message. */\nexport function whenOf(trigger: Trigger, words: AutomationMessages, code: string): When {\n switch (trigger.kind) {\n case \"schedule\":\n return trigger.schedule === null\n ? { text: words.custom, timed: false }\n : scheduleOf(trigger.schedule, words, code);\n case \"webhook\":\n return { text: words.webhook, timed: false };\n case \"platform\":\n return { text: words.platform, timed: false };\n case \"event\":\n return {\n text: words.event(trigger.app_title ?? trigger.app, trigger.event_title),\n timed: false,\n };\n }\n}\n\nconst sorted = (values: ReadonlySet<number>): number[] => [...values].sort((a, b) => a - b);\n\n/** Whether the values are every `step`-th one from `from` up to `to`: 0, 15, 30, 45. */\nfunction stepOf(values: number[]): number | null {\n if (values.length < 2) return null;\n const step = (values[1] ?? 0) - (values[0] ?? 0);\n return values.every((value, at) => value === (values[0] ?? 0) + at * step) ? step : null;\n}\n\n/** Monday first, as a week is read; Sunday is 0 in cron. */\nconst weekOrder = (day: number) => (day + 6) % 7;\n\nfunction weekdayName(day: number, code: string): string {\n // 4 January 2026 is a Sunday.\n return new Intl.DateTimeFormat(code, { weekday: \"long\", timeZone: \"UTC\" }).format(\n new Date(Date.UTC(2026, 0, 4 + day)),\n );\n}\n\n/** The days a line runs on, in words; null for what a sentence can't say. */\nfunction daysOf(cron: Cron, words: AutomationMessages, code: string): string | null {\n if (cron.months.size !== 12) return null;\n if (cron.anyDay && cron.anyWeekday) return words.daily;\n if (cron.anyDay) {\n const days = [...cron.weekdays].sort((a, b) => weekOrder(a) - weekOrder(b));\n const key = days.join(\",\");\n if (days.length === 7) return words.daily;\n if (key === \"1,2,3,4,5\") return words.weekdays;\n if (key === \"6,0\") return words.weekends;\n return words.weekly(days.map((day) => weekdayName(day, code)));\n }\n if (cron.anyWeekday) return words.monthly(sorted(cron.days));\n return null;\n}\n\n/** At most this many times are listed one by one; more read as a frequency. */\nconst LISTED = 4;\n\n/** The times a line runs at in a day: listed (\"a las 9:00\"), or how often (\"cada hora\"). */\nfunction timesOf(\n cron: Cron,\n words: AutomationMessages,\n code: string,\n): { text: string; listed: number } | null {\n const hours = sorted(cron.hours);\n const minutes = sorted(cron.minutes);\n const first = hours[0] ?? 0;\n const last = hours[hours.length - 1] ?? 0;\n if (hours.length * minutes.length <= LISTED) {\n const times = hours.flatMap((hour) => minutes.map((minute) => clockOf(hour, minute, code)));\n return { text: words.at(times), listed: times.length };\n }\n // Once in each of its hours, every few hours; or every few minutes through its hours.\n const minuteStep = minutes.length === 60 ? 1 : stepOf(minutes);\n const hourStep = hours.length === 24 || hours.length === 1 ? 1 : stepOf(hours);\n let often: string;\n if (minutes.length === 1 && hourStep !== null) {\n often = words.every(hourStep, \"hour\");\n } else if (minuteStep !== null && minutes[0] === 0 && 60 % minuteStep === 0 && hourStep === 1) {\n often = words.every(minuteStep, \"minute\");\n } else {\n return null;\n }\n // The whole day (\"cada 2 horas\"), or a part of it (\"cada hora de 8:00 a 18:00\").\n const whole = hourStep !== null && hours.length * hourStep === 24 && first < hourStep;\n if (whole) return { text: often, listed: 0 };\n const from = clockOf(first, minutes[0] ?? 0, code);\n const to = clockOf(last, minutes[minutes.length - 1] ?? 0, code);\n return { text: `${often} ${words.between(from, to)}`, listed: 0 };\n}\n\n/** A cron line as a sentence: its days, then its times or how often. */\nexport function scheduleOf(line: string, words: AutomationMessages, code: string): When {\n const cron = parseCron(line);\n const days = cron && daysOf(cron, words, code);\n const times = cron && timesOf(cron, words, code);\n if (!days || !times) return { text: words.custom, timed: false };\n if (times.listed > 0) return { text: `${days} ${times.text}`, timed: times.listed === 1 };\n return { text: days === words.daily ? times.text : words.on(times.text, days), timed: false };\n}\n\n/** A calendar day where the person is, as a count of days from the epoch's. */\nconst dayNumber = (moment: Date): number =>\n Math.round(\n (new Date(moment.getFullYear(), moment.getMonth(), moment.getDate()).getTime() -\n new Date(1970, 0, 1).getTime()) /\n 86_400_000,\n );\n\n/**\n * A moment's day beside `now`, where the person is: today, yesterday, tomorrow, a weekday within\n * the week, else its date (with its year when not this one).\n */\nexport function dayRefOf(\n moment: Date,\n now: Date,\n code: string,\n weekday: \"short\" | \"long\" = \"short\",\n): DayRef {\n const days = dayNumber(moment) - dayNumber(now);\n if (days === 0) return { kind: \"today\" };\n if (days === -1) return { kind: \"yesterday\" };\n if (days === 1) return { kind: \"tomorrow\" };\n if (Math.abs(days) < 7) {\n return { kind: \"weekday\", name: new Intl.DateTimeFormat(code, { weekday }).format(moment) };\n }\n return {\n kind: \"date\",\n name: new Intl.DateTimeFormat(code, {\n day: \"numeric\",\n month: \"short\",\n ...(moment.getFullYear() === now.getFullYear() ? {} : { year: \"numeric\" }),\n }).format(moment),\n };\n}\n\n/** A moment's time of day where the person is: \"9:00\". */\nexport const timeOf = (moment: Date, code: string): string =>\n new Intl.DateTimeFormat(code, { hour: \"numeric\", minute: \"2-digit\" }).format(moment);\n\n/** When a run started: \"Hoy 9:00\", \"Ayer 16:12\", \"Lun 9:00\", \"3 oct 9:00\". */\nexport function momentOf(at: Date, now: Date, words: AutomationMessages, code: string): string {\n return words.moment(dayRefOf(at, now, code), timeOf(at, code));\n}\n\n/**\n * The line under an automation's title: when it runs and, while it is on and has a next run,\n * when that is (\"Cada día laborable a las 9:00, próxima mañana\"). The next run's time is said\n * when the sentence doesn't already say it, and always when it is today.\n */\nexport function lineOf(\n automation: Pick<Automation, \"trigger\" | \"on\" | \"next_run_at\">,\n now: Date,\n words: AutomationMessages,\n code: string,\n): string {\n const when = whenOf(automation.trigger, words, code);\n if (!automation.on || automation.next_run_at === null) return when.text;\n const next = new Date(automation.next_run_at);\n const day = dayRefOf(next, now, code, \"long\");\n const time = when.timed && day.kind !== \"today\" ? null : timeOf(next, code);\n return `${when.text}, ${words.next(day, time)}`;\n}\n\n/** How long a run took, once it ended. */\nexport function tookOf(\n run: { started_at: string; ended_at: string | null },\n words: AutomationMessages,\n): string | null {\n if (run.ended_at === null) return null;\n return words.took(Math.max(0, Date.parse(run.ended_at) - Date.parse(run.started_at)));\n}\n"],"mappings":"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"}
package/dist/blocks.d.ts CHANGED
@@ -81,6 +81,28 @@ export interface AutomationBlock {
81
81
  name: string;
82
82
  view: AutomationView | null;
83
83
  }
84
+ /**
85
+ * How the connection an app's card asked for stands: its name and logo, and where the person
86
+ * authorizes it while it waits for them.
87
+ */
88
+ export interface ConnectView {
89
+ title: string;
90
+ icon: string | null;
91
+ status: "pending" | "active" | "error" | "disconnected";
92
+ url: string | null;
93
+ }
94
+ /**
95
+ * An app for the person to connect, drawn by the platform alone (the agent's `connect_app`,
96
+ * never composed): its name and logo, the line the agent wrote of what it will do with it, and
97
+ * the button that opens its consent page; the API fills in `connection`, and the card changes
98
+ * live as it does (waiting, working, failed). Null while there is none yet.
99
+ */
100
+ export interface ConnectBlock {
101
+ type: "connect";
102
+ app: string;
103
+ purpose: string;
104
+ connection: ConnectView | null;
105
+ }
84
106
  /** One thing the person can do: pressing it sends `action`, or, with `url`, opens that page. */
85
107
  export interface ButtonBlock {
86
108
  type: "button";
@@ -175,7 +197,7 @@ export interface StackBlock {
175
197
  direction: "column" | "row";
176
198
  children: Block[];
177
199
  }
178
- export type Block = CardBlock | StackBlock | DividerBlock | TextBlock | FieldsBlock | TableBlock | ListBlock | StatBlock | ChartBlock | NoteBlock | ImageBlock | AutomationBlock | FileBlock | ButtonBlock | ChoiceBlock | FormBlock;
200
+ export type Block = CardBlock | StackBlock | DividerBlock | TextBlock | FieldsBlock | TableBlock | ListBlock | StatBlock | ChartBlock | NoteBlock | ImageBlock | AutomationBlock | FileBlock | ConnectBlock | ButtonBlock | ChoiceBlock | FormBlock;
179
201
  /** What the agent shows: blocks, and the words a channel without a screen gets instead. */
180
202
  export interface Surface {
181
203
  blocks: Block[];
package/dist/browser.js CHANGED
@@ -27,7 +27,7 @@ function createScreen() {
27
27
  */
28
28
  function handOverWaiting(messages, awaiting) {
29
29
  if (awaiting === null) return null;
30
- return messages.flatMap((message) => message.steps).find((one) => one.id === awaiting)?.action === "browser_hand_over" ? awaiting : null;
30
+ return messages.flatMap((message) => message.steps).find((one) => one.id === awaiting)?.action === HAND_OVER ? awaiting : null;
31
31
  }
32
32
  /** "es.wikipedia.org" and the rest of the address apart, for an address bar; nothing for a blank page. */
33
33
  function siteOf(url) {
@@ -287,6 +287,6 @@ function painterOn(canvas, onPaint) {
287
287
  };
288
288
  }
289
289
  //#endregion
290
- export { HAND_OVER, bytesOf, capture, createScreen, handOverWaiting, keyOf, modifiersOf, painterOn, pointOf, siteOf };
290
+ export { bytesOf, capture, createScreen, handOverWaiting, keyOf, painterOn, pointOf, siteOf };
291
291
 
292
292
  //# sourceMappingURL=browser.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"browser.js","names":[],"sources":["../src/browser.ts"],"sourcesContent":["/**\n * The conversation's browser, apart from how it is drawn: its screen as pictures to watch, the\n * person's pointer and keys as the input it takes, and where it is in a few words.\n */\n\nimport type { BrowserFrame, BrowserInput, Message } from \"./types\";\n\n/** The action that hands the person the browser; the turn waits on it until they give it back. */\nexport const HAND_OVER = \"browser_hand_over\";\n\n/**\n * The browser's screen as it comes: the latest picture, and whoever watches. Kept out of the\n * conversation's state, so a picture repaints a canvas and nothing else.\n */\nexport interface Screen {\n /** A new picture: each watcher hears it. */\n show: (frame: BrowserFrame) => void;\n /** The browser closed: no picture any more. */\n clear: () => void;\n /** Hears each picture from now on, the latest at once; returns how to stop. */\n watch: (listener: (frame: BrowserFrame) => void) => () => void;\n}\n\nexport function createScreen(): Screen {\n let latest: BrowserFrame | null = null;\n const listeners = new Set<(frame: BrowserFrame) => void>();\n return {\n show: (frame) => {\n latest = frame;\n for (const listener of listeners) listener(frame);\n },\n clear: () => {\n latest = null;\n },\n watch: (listener) => {\n listeners.add(listener);\n if (latest) listener(latest);\n return () => {\n listeners.delete(listener);\n };\n },\n };\n}\n\n/**\n * The hand-over the turn waits on (its step's id) when the browser it handed over is no longer\n * there to give back: the person can still go on.\n */\nexport function handOverWaiting(\n messages: readonly Message[],\n awaiting: string | null,\n): string | null {\n if (awaiting === null) return null;\n const step = messages.flatMap((message) => message.steps).find((one) => one.id === awaiting);\n return step?.action === HAND_OVER ? awaiting : null;\n}\n\n/** \"es.wikipedia.org\" and the rest of the address apart, for an address bar; nothing for a blank page. */\nexport function siteOf(url: string): { host: string; rest: string } {\n try {\n const { protocol, host, pathname, search } = new URL(url);\n if (!protocol.startsWith(\"http\")) return { host: \"\", rest: \"\" };\n return { host, rest: pathname === \"/\" && !search ? \"\" : `${pathname}${search}` };\n } catch {\n return { host: \"\", rest: \"\" };\n }\n}\n\nexport interface Size {\n width: number;\n height: number;\n}\n\n/** Where a pointer event is on the page: scaled from the element as shown, kept inside it. */\nexport function pointOf(\n event: { clientX: number; clientY: number },\n box: { left: number; top: number; width: number; height: number },\n size: Size,\n): { x: number; y: number } {\n const along = (at: number, from: number, shown: number, real: number) =>\n shown > 0 ? Math.round(Math.min(Math.max((at - from) / shown, 0), 1) * real) : 0;\n return {\n x: along(event.clientX, box.left, box.width, size.width),\n y: along(event.clientY, box.top, box.height, size.height),\n };\n}\n\n/** DevTools' mask of the keys held with another: Alt 1, Ctrl 2, Meta 4, Shift 8. */\nexport function modifiersOf(event: {\n altKey: boolean;\n ctrlKey: boolean;\n metaKey: boolean;\n shiftKey: boolean;\n}): number {\n return (\n (event.altKey ? 1 : 0) |\n (event.ctrlKey ? 2 : 0) |\n (event.metaKey ? 4 : 0) |\n (event.shiftKey ? 8 : 0)\n );\n}\n\ntype KeyInput = Extract<BrowserInput, { kind: \"key\" }>;\n\n/**\n * A key as the browser takes it; nothing for a key that stays here: Tab (the focus leaves the\n * screen) and paste (the text comes in its own event, from this computer's clipboard).\n */\nexport function keyOf(\n event: Pick<\n KeyboardEvent,\n \"key\" | \"code\" | \"keyCode\" | \"altKey\" | \"ctrlKey\" | \"metaKey\" | \"shiftKey\"\n >,\n action: \"down\" | \"up\",\n): KeyInput | null {\n if (event.key === \"Tab\") return null;\n if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === \"v\") return null;\n return {\n kind: \"key\",\n action,\n key: event.key.slice(0, 32),\n code: event.code.slice(0, 32),\n keyCode: Math.min(Math.max(event.keyCode, 0), 255),\n modifiers: modifiersOf(event),\n };\n}\n\nconst BUTTONS: Readonly<Record<number, \"left\" | \"middle\" | \"right\">> = {\n 0: \"left\",\n 1: \"middle\",\n 2: \"right\",\n};\nconst MOVE_EVERY_MS = 40; // where the pointer is, 25 times a second at most\n// A trackpad fires the wheel 60 times a second, and each one waits on the hosted browser: what\n// turns within this long goes as one, so the page keeps up with the hand instead of trailing it.\nconst WHEEL_EVERY_MS = 50;\nconst LINE_PX = 16; // a wheel that scrolls by lines, in pixels\nconst MAX_DELTA = 10_000;\nconst MAX_PASTE = 4_000;\n\nconst clamp = (value: number, limit: number) => Math.min(Math.max(value, -limit), limit);\n\n/**\n * Sends what the person does on `element` (the screen) to `send`, in the page's pixels; the\n * returned function stops it. The pointer goes as down, move and up (drags and double clicks\n * work as on the page; a drag that leaves the screen still ends), the wheel as wheel (the page\n * around does not scroll; the turns of `WHEEL_EVERY_MS` summed into one), keys while the screen\n * has the focus, and text pasted there as text. A key still held when the focus goes is let go.\n * The context menu is the page's.\n */\nexport function capture(\n element: HTMLElement,\n size: Size,\n send: (input: BrowserInput) => void,\n): () => void {\n const held = new Map<string, KeyInput>();\n let lastMove = Number.NEGATIVE_INFINITY;\n const at = (event: { clientX: number; clientY: number }) =>\n pointOf(event, element.getBoundingClientRect(), size);\n // The wheel's turns not sent yet, where the pointer was, and when the last went.\n const turned = { x: 0, y: 0, dx: 0, dy: 0 };\n let lastWheel = Number.NEGATIVE_INFINITY;\n let wheelTimer: ReturnType<typeof setTimeout> | undefined;\n const flushWheel = () => {\n wheelTimer = undefined;\n lastWheel = performance.now();\n const { x, y, dx, dy } = turned;\n turned.dx = 0;\n turned.dy = 0;\n if (dx !== 0 || dy !== 0) {\n send({ kind: \"wheel\", x, y, dx: clamp(dx, MAX_DELTA), dy: clamp(dy, MAX_DELTA) });\n }\n };\n\n const down = (event: PointerEvent) => {\n const button = BUTTONS[event.button];\n if (!button) return;\n event.preventDefault(); // the page would select the screen, and keep the focus where it was\n element.focus({ preventScroll: true });\n element.setPointerCapture(event.pointerId);\n const clicks = Math.min(Math.max(event.detail, 1), 3);\n send({ kind: \"mouse\", action: \"down\", ...at(event), button, clicks });\n };\n const up = (event: PointerEvent) => {\n const button = BUTTONS[event.button];\n if (!button) return;\n const clicks = Math.min(Math.max(event.detail, 1), 3);\n send({ kind: \"mouse\", action: \"up\", ...at(event), button, clicks });\n };\n const move = (event: PointerEvent) => {\n if (event.timeStamp - lastMove < MOVE_EVERY_MS) return;\n lastMove = event.timeStamp;\n send({ kind: \"mouse\", action: \"move\", ...at(event) });\n };\n const wheel = (event: WheelEvent) => {\n event.preventDefault();\n const unit = event.deltaMode === 1 ? LINE_PX : event.deltaMode === 2 ? size.height : 1;\n Object.assign(turned, at(event));\n turned.dx += event.deltaX * unit;\n turned.dy += event.deltaY * unit;\n if (wheelTimer !== undefined) return;\n // The first turn goes at once; what follows it within the window, together at its end.\n const wait = lastWheel + WHEEL_EVERY_MS - performance.now();\n if (wait <= 0) flushWheel();\n else wheelTimer = setTimeout(flushWheel, wait);\n };\n const keyed = (action: \"down\" | \"up\") => (event: KeyboardEvent) => {\n if (event.isComposing) return;\n const key = keyOf(event, action);\n if (!key) return;\n event.preventDefault();\n if (action === \"down\") held.set(event.code || event.key, { ...key, action: \"up\" });\n else held.delete(event.code || event.key);\n send(key);\n };\n const paste = (event: ClipboardEvent) => {\n const text = event.clipboardData?.getData(\"text/plain\") ?? \"\";\n if (text === \"\") return;\n event.preventDefault();\n send({ kind: \"paste\", text: text.slice(0, MAX_PASTE) });\n };\n const letGo = () => {\n for (const key of held.values()) send(key);\n held.clear();\n };\n const noMenu = (event: Event) => {\n event.preventDefault();\n };\n const keyDown = keyed(\"down\");\n const keyUp = keyed(\"up\");\n\n element.addEventListener(\"pointerdown\", down);\n element.addEventListener(\"pointerup\", up);\n element.addEventListener(\"pointermove\", move);\n element.addEventListener(\"wheel\", wheel, { passive: false });\n element.addEventListener(\"keydown\", keyDown);\n element.addEventListener(\"keyup\", keyUp);\n element.addEventListener(\"paste\", paste);\n element.addEventListener(\"blur\", letGo);\n element.addEventListener(\"contextmenu\", noMenu);\n return () => {\n clearTimeout(wheelTimer);\n element.removeEventListener(\"pointerdown\", down);\n element.removeEventListener(\"pointerup\", up);\n element.removeEventListener(\"pointermove\", move);\n element.removeEventListener(\"wheel\", wheel);\n element.removeEventListener(\"keydown\", keyDown);\n element.removeEventListener(\"keyup\", keyUp);\n element.removeEventListener(\"paste\", paste);\n element.removeEventListener(\"blur\", letGo);\n element.removeEventListener(\"contextmenu\", noMenu);\n };\n}\n\n/**\n * The bytes in a base64 string: the platform's own decoder where it has one, else a plain loop\n * (`Uint8Array.from` with a mapper walks a frame's 100,000 characters through an iterator, a\n * callback each, on the main thread, as the next picture is due).\n */\nexport function bytesOf(base64: string): Uint8Array<ArrayBuffer> {\n const native = (Uint8Array as { fromBase64?: (text: string) => Uint8Array<ArrayBuffer> })\n .fromBase64;\n if (native) return native(base64);\n const text = atob(base64);\n const bytes = new Uint8Array(text.length);\n for (let at = 0; at < text.length; at += 1) bytes[at] = text.charCodeAt(at);\n return bytes;\n}\n\n/** Draws a picture on a canvas, unless a newer one comes while it is still being decoded. */\nexport interface Painter {\n paint: (jpeg: string) => void;\n dispose: () => void;\n}\n\n/**\n * Paints JPEGs (base64) on `canvas`, stretched to its size, v2's way: decoded off the main thread\n * (`createImageBitmap`), one at a time, and drawn on the screen's next frame, so a picture never\n * lands mid-paint. A picture that comes while another is being decoded waits, and a newer one\n * replaces it, as a newer decoded one replaces one not drawn yet: a slow device skips to the\n * present instead of falling behind. The canvas keeps the last picture until the next is ready,\n * so it never flickers; one that does not decode is skipped. `onPaint` hears each one drawn.\n */\nexport function painterOn(canvas: HTMLCanvasElement, onPaint?: () => void): Painter {\n let decoding = false;\n let waiting: string | undefined;\n let ready: ImageBitmap | undefined; // decoded, for the next frame\n let frame = 0; // the animation frame asked for, if one is\n let disposed = false;\n\n const draw = () => {\n frame = 0;\n const bitmap = ready;\n ready = undefined;\n if (!bitmap) return;\n canvas.getContext(\"2d\")?.drawImage(bitmap, 0, 0, canvas.width, canvas.height);\n bitmap.close();\n onPaint?.();\n };\n\n const decode = async (jpeg: string): Promise<void> => {\n decoding = true;\n try {\n const bitmap = await createImageBitmap(new Blob([bytesOf(jpeg)], { type: \"image/jpeg\" }));\n if (disposed) {\n bitmap.close();\n } else {\n ready?.close();\n ready = bitmap;\n if (frame === 0) frame = requestAnimationFrame(draw);\n }\n } catch {\n // Cut off or damaged: the next picture replaces it.\n }\n decoding = false;\n const next = waiting;\n waiting = undefined;\n if (next !== undefined && !disposed) await decode(next);\n };\n\n return {\n paint: (jpeg) => {\n if (decoding) waiting = jpeg;\n else void decode(jpeg);\n },\n dispose: () => {\n disposed = true;\n waiting = undefined;\n if (frame !== 0) cancelAnimationFrame(frame);\n ready?.close();\n ready = undefined;\n },\n };\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"browser.js","names":[],"sources":["../src/browser.ts"],"sourcesContent":["/**\n * The conversation's browser, apart from how it is drawn: its screen as pictures to watch, the\n * person's pointer and keys as the input it takes, and where it is in a few words.\n */\n\nimport type { BrowserFrame, BrowserInput, Message } from \"./types\";\n\n/** The action that hands the person the browser; the turn waits on it until they give it back. */\nconst HAND_OVER = \"browser_hand_over\";\n\n/**\n * The browser's screen as it comes: the latest picture, and whoever watches. Kept out of the\n * conversation's state, so a picture repaints a canvas and nothing else.\n */\nexport interface Screen {\n /** A new picture: each watcher hears it. */\n show: (frame: BrowserFrame) => void;\n /** The browser closed: no picture any more. */\n clear: () => void;\n /** Hears each picture from now on, the latest at once; returns how to stop. */\n watch: (listener: (frame: BrowserFrame) => void) => () => void;\n}\n\nexport function createScreen(): Screen {\n let latest: BrowserFrame | null = null;\n const listeners = new Set<(frame: BrowserFrame) => void>();\n return {\n show: (frame) => {\n latest = frame;\n for (const listener of listeners) listener(frame);\n },\n clear: () => {\n latest = null;\n },\n watch: (listener) => {\n listeners.add(listener);\n if (latest) listener(latest);\n return () => {\n listeners.delete(listener);\n };\n },\n };\n}\n\n/**\n * The hand-over the turn waits on (its step's id) when the browser it handed over is no longer\n * there to give back: the person can still go on.\n */\nexport function handOverWaiting(\n messages: readonly Message[],\n awaiting: string | null,\n): string | null {\n if (awaiting === null) return null;\n const step = messages.flatMap((message) => message.steps).find((one) => one.id === awaiting);\n return step?.action === HAND_OVER ? awaiting : null;\n}\n\n/** \"es.wikipedia.org\" and the rest of the address apart, for an address bar; nothing for a blank page. */\nexport function siteOf(url: string): { host: string; rest: string } {\n try {\n const { protocol, host, pathname, search } = new URL(url);\n if (!protocol.startsWith(\"http\")) return { host: \"\", rest: \"\" };\n return { host, rest: pathname === \"/\" && !search ? \"\" : `${pathname}${search}` };\n } catch {\n return { host: \"\", rest: \"\" };\n }\n}\n\ninterface Size {\n width: number;\n height: number;\n}\n\n/** Where a pointer event is on the page: scaled from the element as shown, kept inside it. */\nexport function pointOf(\n event: { clientX: number; clientY: number },\n box: { left: number; top: number; width: number; height: number },\n size: Size,\n): { x: number; y: number } {\n const along = (at: number, from: number, shown: number, real: number) =>\n shown > 0 ? Math.round(Math.min(Math.max((at - from) / shown, 0), 1) * real) : 0;\n return {\n x: along(event.clientX, box.left, box.width, size.width),\n y: along(event.clientY, box.top, box.height, size.height),\n };\n}\n\n/** DevTools' mask of the keys held with another: Alt 1, Ctrl 2, Meta 4, Shift 8. */\nfunction modifiersOf(event: {\n altKey: boolean;\n ctrlKey: boolean;\n metaKey: boolean;\n shiftKey: boolean;\n}): number {\n return (\n (event.altKey ? 1 : 0) |\n (event.ctrlKey ? 2 : 0) |\n (event.metaKey ? 4 : 0) |\n (event.shiftKey ? 8 : 0)\n );\n}\n\ntype KeyInput = Extract<BrowserInput, { kind: \"key\" }>;\n\n/**\n * A key as the browser takes it; nothing for a key that stays here: Tab (the focus leaves the\n * screen) and paste (the text comes in its own event, from this computer's clipboard).\n */\nexport function keyOf(\n event: Pick<\n KeyboardEvent,\n \"key\" | \"code\" | \"keyCode\" | \"altKey\" | \"ctrlKey\" | \"metaKey\" | \"shiftKey\"\n >,\n action: \"down\" | \"up\",\n): KeyInput | null {\n if (event.key === \"Tab\") return null;\n if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === \"v\") return null;\n return {\n kind: \"key\",\n action,\n key: event.key.slice(0, 32),\n code: event.code.slice(0, 32),\n keyCode: Math.min(Math.max(event.keyCode, 0), 255),\n modifiers: modifiersOf(event),\n };\n}\n\nconst BUTTONS: Readonly<Record<number, \"left\" | \"middle\" | \"right\">> = {\n 0: \"left\",\n 1: \"middle\",\n 2: \"right\",\n};\nconst MOVE_EVERY_MS = 40; // where the pointer is, 25 times a second at most\n// A trackpad fires the wheel 60 times a second, and each one waits on the hosted browser: what\n// turns within this long goes as one, so the page keeps up with the hand instead of trailing it.\nconst WHEEL_EVERY_MS = 50;\nconst LINE_PX = 16; // a wheel that scrolls by lines, in pixels\nconst MAX_DELTA = 10_000;\nconst MAX_PASTE = 4_000;\n\nconst clamp = (value: number, limit: number) => Math.min(Math.max(value, -limit), limit);\n\n/**\n * Sends what the person does on `element` (the screen) to `send`, in the page's pixels; the\n * returned function stops it. The pointer goes as down, move and up (drags and double clicks\n * work as on the page; a drag that leaves the screen still ends), the wheel as wheel (the page\n * around does not scroll; the turns of `WHEEL_EVERY_MS` summed into one), keys while the screen\n * has the focus, and text pasted there as text. A key still held when the focus goes is let go.\n * The context menu is the page's.\n */\nexport function capture(\n element: HTMLElement,\n size: Size,\n send: (input: BrowserInput) => void,\n): () => void {\n const held = new Map<string, KeyInput>();\n let lastMove = Number.NEGATIVE_INFINITY;\n const at = (event: { clientX: number; clientY: number }) =>\n pointOf(event, element.getBoundingClientRect(), size);\n // The wheel's turns not sent yet, where the pointer was, and when the last went.\n const turned = { x: 0, y: 0, dx: 0, dy: 0 };\n let lastWheel = Number.NEGATIVE_INFINITY;\n let wheelTimer: ReturnType<typeof setTimeout> | undefined;\n const flushWheel = () => {\n wheelTimer = undefined;\n lastWheel = performance.now();\n const { x, y, dx, dy } = turned;\n turned.dx = 0;\n turned.dy = 0;\n if (dx !== 0 || dy !== 0) {\n send({ kind: \"wheel\", x, y, dx: clamp(dx, MAX_DELTA), dy: clamp(dy, MAX_DELTA) });\n }\n };\n\n const down = (event: PointerEvent) => {\n const button = BUTTONS[event.button];\n if (!button) return;\n event.preventDefault(); // the page would select the screen, and keep the focus where it was\n element.focus({ preventScroll: true });\n element.setPointerCapture(event.pointerId);\n const clicks = Math.min(Math.max(event.detail, 1), 3);\n send({ kind: \"mouse\", action: \"down\", ...at(event), button, clicks });\n };\n const up = (event: PointerEvent) => {\n const button = BUTTONS[event.button];\n if (!button) return;\n const clicks = Math.min(Math.max(event.detail, 1), 3);\n send({ kind: \"mouse\", action: \"up\", ...at(event), button, clicks });\n };\n const move = (event: PointerEvent) => {\n if (event.timeStamp - lastMove < MOVE_EVERY_MS) return;\n lastMove = event.timeStamp;\n send({ kind: \"mouse\", action: \"move\", ...at(event) });\n };\n const wheel = (event: WheelEvent) => {\n event.preventDefault();\n const unit = event.deltaMode === 1 ? LINE_PX : event.deltaMode === 2 ? size.height : 1;\n Object.assign(turned, at(event));\n turned.dx += event.deltaX * unit;\n turned.dy += event.deltaY * unit;\n if (wheelTimer !== undefined) return;\n // The first turn goes at once; what follows it within the window, together at its end.\n const wait = lastWheel + WHEEL_EVERY_MS - performance.now();\n if (wait <= 0) flushWheel();\n else wheelTimer = setTimeout(flushWheel, wait);\n };\n const keyed = (action: \"down\" | \"up\") => (event: KeyboardEvent) => {\n if (event.isComposing) return;\n const key = keyOf(event, action);\n if (!key) return;\n event.preventDefault();\n if (action === \"down\") held.set(event.code || event.key, { ...key, action: \"up\" });\n else held.delete(event.code || event.key);\n send(key);\n };\n const paste = (event: ClipboardEvent) => {\n const text = event.clipboardData?.getData(\"text/plain\") ?? \"\";\n if (text === \"\") return;\n event.preventDefault();\n send({ kind: \"paste\", text: text.slice(0, MAX_PASTE) });\n };\n const letGo = () => {\n for (const key of held.values()) send(key);\n held.clear();\n };\n const noMenu = (event: Event) => {\n event.preventDefault();\n };\n const keyDown = keyed(\"down\");\n const keyUp = keyed(\"up\");\n\n element.addEventListener(\"pointerdown\", down);\n element.addEventListener(\"pointerup\", up);\n element.addEventListener(\"pointermove\", move);\n element.addEventListener(\"wheel\", wheel, { passive: false });\n element.addEventListener(\"keydown\", keyDown);\n element.addEventListener(\"keyup\", keyUp);\n element.addEventListener(\"paste\", paste);\n element.addEventListener(\"blur\", letGo);\n element.addEventListener(\"contextmenu\", noMenu);\n return () => {\n clearTimeout(wheelTimer);\n element.removeEventListener(\"pointerdown\", down);\n element.removeEventListener(\"pointerup\", up);\n element.removeEventListener(\"pointermove\", move);\n element.removeEventListener(\"wheel\", wheel);\n element.removeEventListener(\"keydown\", keyDown);\n element.removeEventListener(\"keyup\", keyUp);\n element.removeEventListener(\"paste\", paste);\n element.removeEventListener(\"blur\", letGo);\n element.removeEventListener(\"contextmenu\", noMenu);\n };\n}\n\n/**\n * The bytes in a base64 string: the platform's own decoder where it has one, else a plain loop\n * (`Uint8Array.from` with a mapper walks a frame's 100,000 characters through an iterator, a\n * callback each, on the main thread, as the next picture is due).\n */\nexport function bytesOf(base64: string): Uint8Array<ArrayBuffer> {\n const native = (Uint8Array as { fromBase64?: (text: string) => Uint8Array<ArrayBuffer> })\n .fromBase64;\n if (native) return native(base64);\n const text = atob(base64);\n const bytes = new Uint8Array(text.length);\n for (let at = 0; at < text.length; at += 1) bytes[at] = text.charCodeAt(at);\n return bytes;\n}\n\n/** Draws a picture on a canvas, unless a newer one comes while it is still being decoded. */\ninterface Painter {\n paint: (jpeg: string) => void;\n dispose: () => void;\n}\n\n/**\n * Paints JPEGs (base64) on `canvas`, stretched to its size, v2's way: decoded off the main thread\n * (`createImageBitmap`), one at a time, and drawn on the screen's next frame, so a picture never\n * lands mid-paint. A picture that comes while another is being decoded waits, and a newer one\n * replaces it, as a newer decoded one replaces one not drawn yet: a slow device skips to the\n * present instead of falling behind. The canvas keeps the last picture until the next is ready,\n * so it never flickers; one that does not decode is skipped. `onPaint` hears each one drawn.\n */\nexport function painterOn(canvas: HTMLCanvasElement, onPaint?: () => void): Painter {\n let decoding = false;\n let waiting: string | undefined;\n let ready: ImageBitmap | undefined; // decoded, for the next frame\n let frame = 0; // the animation frame asked for, if one is\n let disposed = false;\n\n const draw = () => {\n frame = 0;\n const bitmap = ready;\n ready = undefined;\n if (!bitmap) return;\n canvas.getContext(\"2d\")?.drawImage(bitmap, 0, 0, canvas.width, canvas.height);\n bitmap.close();\n onPaint?.();\n };\n\n const decode = async (jpeg: string): Promise<void> => {\n decoding = true;\n try {\n const bitmap = await createImageBitmap(new Blob([bytesOf(jpeg)], { type: \"image/jpeg\" }));\n if (disposed) {\n bitmap.close();\n } else {\n ready?.close();\n ready = bitmap;\n if (frame === 0) frame = requestAnimationFrame(draw);\n }\n } catch {\n // Cut off or damaged: the next picture replaces it.\n }\n decoding = false;\n const next = waiting;\n waiting = undefined;\n if (next !== undefined && !disposed) await decode(next);\n };\n\n return {\n paint: (jpeg) => {\n if (decoding) waiting = jpeg;\n else void decode(jpeg);\n },\n dispose: () => {\n disposed = true;\n waiting = undefined;\n if (frame !== 0) cancelAnimationFrame(frame);\n ready?.close();\n ready = undefined;\n },\n };\n}\n"],"mappings":"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"}
package/dist/call.d.ts CHANGED
@@ -25,7 +25,7 @@ export type CallProblem =
25
25
  "unsupported" |
26
26
  /** Calls aren't set up where the agent works (the API refused with `no_calls`). */
27
27
  "off" |
28
- /** The agent takes no calls: a squad's builder (`builder`). */
28
+ /** The agent takes no calls: the builder (`builder`). */
29
29
  "builder" |
30
30
  /** The API wouldn't make the call, for any other reason it named. */
31
31
  "refused" |
package/dist/call.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"call.js","names":[],"sources":["../src/call.ts"],"sourcesContent":["/**\n * A voice call with an agent, as the person on it sees it: where it stands, why it ended or\n * never began, and how long it lasted. Pure, so it is tested without a room; `useCall` feeds it\n * what LiveKit says.\n */\n\nimport { ApiError } from \"./client\";\nimport type { CallReason } from \"./types\";\nimport type { OrbState } from \"./voice/orbs\";\n\n/** What the agent is doing, as LiveKit's agents publish it (`lk.agent.state`). */\nexport type AgentActivity = \"initializing\" | \"idle\" | \"listening\" | \"thinking\" | \"speaking\";\n\n/** Why a call that began is over. */\nexport type CallEndedBy =\n /** The person hung up. */\n | \"person\"\n /** The agent did: it said one of its goodbyes. */\n | \"agent\"\n /** It reached its longest time. */\n | \"limit\"\n /** The line went: the network, or the agent's side fell. */\n | \"dropped\";\n\n/** Why a call never began. */\nexport type CallProblem =\n /** The person, or the browser, said no to the microphone. */\n | \"denied\"\n /** There is no microphone to use. */\n | \"nomic\"\n /** Another application holds the microphone. */\n | \"inuse\"\n /** This browser can't call: no microphone access at all (an old one, or a page not on https). */\n | \"unsupported\"\n /** Calls aren't set up where the agent works (the API refused with `no_calls`). */\n | \"off\"\n /** The agent takes no calls: a squad's builder (`builder`). */\n | \"builder\"\n /** The API wouldn't make the call, for any other reason it named. */\n | \"refused\"\n /** The call couldn't be made or joined. */\n | \"failed\"\n /** The room was there, but no agent came to it. */\n | \"unanswered\";\n\n/** How a call ended: why, and how long it lasted from when the agent picked up. */\nexport interface CallEnd {\n /**\n * Why, as far as the room knows: null when the agent's side closed it (its goodbye, or its\n * longest time), which only the API says (`endedByReason`).\n */\n by: CallEndedBy | null;\n seconds: number;\n}\n\n/** Where a call stands. */\nexport interface CallState {\n /** Opening the microphone and the room; ringing until the agent joins; on; over. */\n stage: \"opening\" | \"ringing\" | \"on\" | \"over\";\n /** When the agent joined, in ms; the call's time counts from it. */\n answeredAt: number | null;\n /** What the agent says it is doing; null while it says nothing. */\n agent: AgentActivity | null;\n /** Whether the agent's voice is coming, for an agent that doesn't say what it is doing. */\n speaking: boolean;\n /** The line is being found again; the call goes on once it is. */\n reconnecting: boolean;\n muted: boolean;\n /** The browser holds the agent's voice until the person presses something. */\n silenced: boolean;\n end: CallEnd | null;\n problem: CallProblem | null;\n}\n\nexport const CALL_START: CallState = {\n stage: \"opening\",\n answeredAt: null,\n agent: null,\n speaking: false,\n reconnecting: false,\n muted: false,\n silenced: false,\n end: null,\n problem: null,\n};\n\n/** What moves a call, from the room. */\nexport type CallEvent =\n | { type: \"ringing\" }\n | { type: \"answered\"; at: number }\n | { type: \"agent\"; activity: AgentActivity | null }\n | { type: \"speaking\"; speaking: boolean }\n | { type: \"reconnecting\"; reconnecting: boolean }\n | { type: \"muted\"; muted: boolean }\n | { type: \"silenced\"; silenced: boolean }\n | { type: \"ended\"; by: CallEndedBy | null; at: number }\n | { type: \"failed\"; problem: CallProblem };\n\n/**\n * The call, one event at a time. Once over (ended, or never begun) nothing moves it again: the\n * room's last words after a hang-up (its disconnect, a track going) are not news.\n */\nexport function callReducer(state: CallState, event: CallEvent): CallState {\n if (state.stage === \"over\") return state;\n switch (event.type) {\n case \"ringing\":\n return state.stage === \"opening\" ? { ...state, stage: \"ringing\" } : state;\n case \"answered\":\n return state.answeredAt === null ? { ...state, stage: \"on\", answeredAt: event.at } : state;\n case \"agent\":\n return { ...state, agent: event.activity };\n case \"speaking\":\n return { ...state, speaking: event.speaking };\n case \"reconnecting\":\n return { ...state, reconnecting: event.reconnecting };\n case \"muted\":\n return { ...state, muted: event.muted };\n case \"silenced\":\n return { ...state, silenced: event.silenced };\n case \"ended\": {\n const seconds = state.answeredAt === null ? 0 : secondsBetween(state.answeredAt, event.at);\n return { ...state, stage: \"over\", reconnecting: false, end: { by: event.by, seconds } };\n }\n case \"failed\":\n return { ...state, stage: \"over\", reconnecting: false, problem: event.problem };\n }\n}\n\nconst secondsBetween = (from: number, to: number): number =>\n Math.max(0, Math.floor((to - from) / 1000));\n\n/** What the call shows, in one word: each has its words, its look and its orb. */\nexport type CallLook =\n \"connecting\" | \"listening\" | \"thinking\" | \"speaking\" | \"reconnecting\" | \"ended\" | \"problem\";\n\n/**\n * What the call shows now. Before the agent picks up, and while it starts, it is connecting;\n * then what the agent says it is doing (an agent that says nothing is speaking while its voice\n * comes, listening otherwise).\n */\nexport function lookOf(state: CallState): CallLook {\n if (state.problem !== null) return \"problem\";\n if (state.end !== null) return \"ended\";\n if (state.answeredAt === null || state.agent === \"initializing\") return \"connecting\";\n if (state.reconnecting) return \"reconnecting\";\n if (state.agent === \"thinking\" || state.agent === \"speaking\") return state.agent;\n if (state.agent === null && state.speaking) return \"speaking\";\n return \"listening\";\n}\n\n/**\n * What the orb shows of a call: what the agent is doing once the call is on, moved by whoever is\n * speaking (the agent's voice while it speaks, the person's microphone while it listens); at rest\n * while the line is found (connecting, reconnecting) and once the call is over.\n */\nexport function orbOf(look: CallLook): { state: OrbState; follows: \"agent\" | \"person\" | null } {\n switch (look) {\n case \"speaking\":\n return { state: look, follows: \"agent\" };\n case \"listening\":\n return { state: look, follows: \"person\" };\n case \"thinking\":\n return { state: look, follows: null };\n default:\n return { state: \"idle\", follows: null };\n }\n}\n\n/** The agent's state as LiveKit's agents publish it, or null for anything else. */\nexport function activityOf(value: string | undefined): AgentActivity | null {\n switch (value) {\n case \"initializing\":\n case \"idle\":\n case \"listening\":\n case \"thinking\":\n case \"speaking\":\n return value;\n default:\n return null;\n }\n}\n\n/**\n * Why the room let the person go, as LiveKit says it (`DisconnectReason`'s names): they hung up;\n * the room was closed under them, which is the agent's side ending the call (its goodbye, or its\n * longest time: null, the API says which); anything else, the line went.\n */\nexport function endedBy(reason: string | undefined, hungUp: boolean): CallEndedBy | null {\n if (hungUp || reason === \"CLIENT_INITIATED\") return \"person\";\n if (reason === \"ROOM_DELETED\" || reason === \"ROOM_CLOSED\" || reason === \"PARTICIPANT_REMOVED\") {\n return null;\n }\n return \"dropped\";\n}\n\n/**\n * Why a call ended as the API keeps it (`CallGlance.reason`), as the call says it: the person hung\n * up, the agent said goodbye, it reached its longest time; anything else (it failed, its end never\n * came) is a line that dropped. Null while the API has no end for it yet.\n */\nexport function endedByReason(reason: CallReason | null | undefined): CallEndedBy | null {\n switch (reason) {\n case \"caller\":\n return \"person\";\n case \"agent\":\n return \"agent\";\n case \"max_duration\":\n return \"limit\";\n case null:\n case undefined:\n return null;\n default:\n return \"dropped\";\n }\n}\n\n/**\n * Why a call couldn't be made, from what asking for it threw: the API's refusal, by its code\n * (calls not set up where the agent works, an agent that takes none, any other it named);\n * anything else (no answer, or one that named nothing) is a call that failed.\n */\nexport function refusalOf(error: unknown): CallProblem {\n if (!(error instanceof ApiError) || error.code === null) return \"failed\";\n switch (error.code) {\n case \"no_calls\":\n return \"off\";\n case \"builder\":\n return \"builder\";\n default:\n return \"refused\";\n }\n}\n\n/**\n * Why the microphone couldn't be opened, from the browser's error (`DOMException` names, as\n * LiveKit reads them too); one the browser doesn't name is a call that failed.\n */\nexport function callProblemOf(error: unknown): CallProblem {\n const name = error instanceof Error || isNamed(error) ? error.name : \"\";\n switch (name) {\n case \"NotAllowedError\":\n case \"PermissionDeniedError\":\n case \"SecurityError\":\n return \"denied\";\n case \"NotFoundError\":\n case \"DevicesNotFoundError\":\n case \"OverconstrainedError\":\n return \"nomic\";\n case \"NotReadableError\":\n case \"TrackStartError\":\n case \"AbortError\":\n return \"inuse\";\n default:\n return \"failed\";\n }\n}\n\nconst isNamed = (value: unknown): value is { name: string } =>\n typeof value === \"object\" &&\n value !== null &&\n \"name\" in value &&\n typeof (value as { name: unknown }).name === \"string\";\n\n/** A call's time as a phone shows it: 0:07, 12:34, 1:02:03. */\nexport function durationOf(seconds: number): string {\n const whole = Math.max(0, Math.floor(seconds));\n const hours = Math.floor(whole / 3600);\n const minutes = Math.floor((whole % 3600) / 60);\n const rest = String(whole % 60).padStart(2, \"0\");\n return hours > 0 ? `${hours}:${String(minutes).padStart(2, \"0\")}:${rest}` : `${minutes}:${rest}`;\n}\n\n/**\n * A loudness read off a track (0 to 1, the root mean square of its spectrum as LiveKit measures\n * it) as the orb shows it: speech sits low on that scale, so it is lifted and the floor of a quiet\n * room cut away.\n */\nexport function loudness(volume: number): number {\n const lifted = (volume - FLOOR) / (1 - FLOOR);\n return Math.min(1, Math.max(0, Math.sqrt(Math.max(0, lifted)) * GAIN));\n}\n\nconst FLOOR = 0.04;\nconst GAIN = 1.25;\n\n/**\n * Whether a key belongs to what it was pressed on, not to the call: a field being typed in, or a\n * control of the page's own (outside the call) that Space presses. Elsewhere (the call itself,\n * the page, a dialog it sits in) Space mutes and unmutes.\n */\nexport function keyBelongsElsewhere(target: EventTarget | null, call: Element | null): boolean {\n if (!(target instanceof Element)) return false;\n if (target.matches(EDITABLE)) return true;\n if (call?.contains(target)) return false;\n return target.matches(PRESSABLE);\n}\n\nconst EDITABLE = 'input, textarea, select, [contenteditable]:not([contenteditable=\"false\"])';\nconst PRESSABLE =\n 'button, a[href], summary, [role=\"button\"], [role=\"checkbox\"], [role=\"switch\"], [role=\"radio\"], [role=\"tab\"], [role=\"menuitem\"], [role=\"option\"], [role=\"slider\"]';\n"],"mappings":"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"}
1
+ {"version":3,"file":"call.js","names":[],"sources":["../src/call.ts"],"sourcesContent":["/**\n * A voice call with an agent, as the person on it sees it: where it stands, why it ended or\n * never began, and how long it lasted. Pure, so it is tested without a room; `useCall` feeds it\n * what LiveKit says.\n */\n\nimport { ApiError } from \"./client\";\nimport type { CallReason } from \"./types\";\nimport type { OrbState } from \"./voice/orbs\";\n\n/** What the agent is doing, as LiveKit's agents publish it (`lk.agent.state`). */\nexport type AgentActivity = \"initializing\" | \"idle\" | \"listening\" | \"thinking\" | \"speaking\";\n\n/** Why a call that began is over. */\nexport type CallEndedBy =\n /** The person hung up. */\n | \"person\"\n /** The agent did: it said one of its goodbyes. */\n | \"agent\"\n /** It reached its longest time. */\n | \"limit\"\n /** The line went: the network, or the agent's side fell. */\n | \"dropped\";\n\n/** Why a call never began. */\nexport type CallProblem =\n /** The person, or the browser, said no to the microphone. */\n | \"denied\"\n /** There is no microphone to use. */\n | \"nomic\"\n /** Another application holds the microphone. */\n | \"inuse\"\n /** This browser can't call: no microphone access at all (an old one, or a page not on https). */\n | \"unsupported\"\n /** Calls aren't set up where the agent works (the API refused with `no_calls`). */\n | \"off\"\n /** The agent takes no calls: the builder (`builder`). */\n | \"builder\"\n /** The API wouldn't make the call, for any other reason it named. */\n | \"refused\"\n /** The call couldn't be made or joined. */\n | \"failed\"\n /** The room was there, but no agent came to it. */\n | \"unanswered\";\n\n/** How a call ended: why, and how long it lasted from when the agent picked up. */\nexport interface CallEnd {\n /**\n * Why, as far as the room knows: null when the agent's side closed it (its goodbye, or its\n * longest time), which only the API says (`endedByReason`).\n */\n by: CallEndedBy | null;\n seconds: number;\n}\n\n/** Where a call stands. */\nexport interface CallState {\n /** Opening the microphone and the room; ringing until the agent joins; on; over. */\n stage: \"opening\" | \"ringing\" | \"on\" | \"over\";\n /** When the agent joined, in ms; the call's time counts from it. */\n answeredAt: number | null;\n /** What the agent says it is doing; null while it says nothing. */\n agent: AgentActivity | null;\n /** Whether the agent's voice is coming, for an agent that doesn't say what it is doing. */\n speaking: boolean;\n /** The line is being found again; the call goes on once it is. */\n reconnecting: boolean;\n muted: boolean;\n /** The browser holds the agent's voice until the person presses something. */\n silenced: boolean;\n end: CallEnd | null;\n problem: CallProblem | null;\n}\n\nexport const CALL_START: CallState = {\n stage: \"opening\",\n answeredAt: null,\n agent: null,\n speaking: false,\n reconnecting: false,\n muted: false,\n silenced: false,\n end: null,\n problem: null,\n};\n\n/** What moves a call, from the room. */\nexport type CallEvent =\n | { type: \"ringing\" }\n | { type: \"answered\"; at: number }\n | { type: \"agent\"; activity: AgentActivity | null }\n | { type: \"speaking\"; speaking: boolean }\n | { type: \"reconnecting\"; reconnecting: boolean }\n | { type: \"muted\"; muted: boolean }\n | { type: \"silenced\"; silenced: boolean }\n | { type: \"ended\"; by: CallEndedBy | null; at: number }\n | { type: \"failed\"; problem: CallProblem };\n\n/**\n * The call, one event at a time. Once over (ended, or never begun) nothing moves it again: the\n * room's last words after a hang-up (its disconnect, a track going) are not news.\n */\nexport function callReducer(state: CallState, event: CallEvent): CallState {\n if (state.stage === \"over\") return state;\n switch (event.type) {\n case \"ringing\":\n return state.stage === \"opening\" ? { ...state, stage: \"ringing\" } : state;\n case \"answered\":\n return state.answeredAt === null ? { ...state, stage: \"on\", answeredAt: event.at } : state;\n case \"agent\":\n return { ...state, agent: event.activity };\n case \"speaking\":\n return { ...state, speaking: event.speaking };\n case \"reconnecting\":\n return { ...state, reconnecting: event.reconnecting };\n case \"muted\":\n return { ...state, muted: event.muted };\n case \"silenced\":\n return { ...state, silenced: event.silenced };\n case \"ended\": {\n const seconds = state.answeredAt === null ? 0 : secondsBetween(state.answeredAt, event.at);\n return { ...state, stage: \"over\", reconnecting: false, end: { by: event.by, seconds } };\n }\n case \"failed\":\n return { ...state, stage: \"over\", reconnecting: false, problem: event.problem };\n }\n}\n\nconst secondsBetween = (from: number, to: number): number =>\n Math.max(0, Math.floor((to - from) / 1000));\n\n/** What the call shows, in one word: each has its words, its look and its orb. */\nexport type CallLook =\n \"connecting\" | \"listening\" | \"thinking\" | \"speaking\" | \"reconnecting\" | \"ended\" | \"problem\";\n\n/**\n * What the call shows now. Before the agent picks up, and while it starts, it is connecting;\n * then what the agent says it is doing (an agent that says nothing is speaking while its voice\n * comes, listening otherwise).\n */\nexport function lookOf(state: CallState): CallLook {\n if (state.problem !== null) return \"problem\";\n if (state.end !== null) return \"ended\";\n if (state.answeredAt === null || state.agent === \"initializing\") return \"connecting\";\n if (state.reconnecting) return \"reconnecting\";\n if (state.agent === \"thinking\" || state.agent === \"speaking\") return state.agent;\n if (state.agent === null && state.speaking) return \"speaking\";\n return \"listening\";\n}\n\n/**\n * What the orb shows of a call: what the agent is doing once the call is on, moved by whoever is\n * speaking (the agent's voice while it speaks, the person's microphone while it listens); at rest\n * while the line is found (connecting, reconnecting) and once the call is over.\n */\nexport function orbOf(look: CallLook): { state: OrbState; follows: \"agent\" | \"person\" | null } {\n switch (look) {\n case \"speaking\":\n return { state: look, follows: \"agent\" };\n case \"listening\":\n return { state: look, follows: \"person\" };\n case \"thinking\":\n return { state: look, follows: null };\n default:\n return { state: \"idle\", follows: null };\n }\n}\n\n/** The agent's state as LiveKit's agents publish it, or null for anything else. */\nexport function activityOf(value: string | undefined): AgentActivity | null {\n switch (value) {\n case \"initializing\":\n case \"idle\":\n case \"listening\":\n case \"thinking\":\n case \"speaking\":\n return value;\n default:\n return null;\n }\n}\n\n/**\n * Why the room let the person go, as LiveKit says it (`DisconnectReason`'s names): they hung up;\n * the room was closed under them, which is the agent's side ending the call (its goodbye, or its\n * longest time: null, the API says which); anything else, the line went.\n */\nexport function endedBy(reason: string | undefined, hungUp: boolean): CallEndedBy | null {\n if (hungUp || reason === \"CLIENT_INITIATED\") return \"person\";\n if (reason === \"ROOM_DELETED\" || reason === \"ROOM_CLOSED\" || reason === \"PARTICIPANT_REMOVED\") {\n return null;\n }\n return \"dropped\";\n}\n\n/**\n * Why a call ended as the API keeps it (`CallGlance.reason`), as the call says it: the person hung\n * up, the agent said goodbye, it reached its longest time; anything else (it failed, its end never\n * came) is a line that dropped. Null while the API has no end for it yet.\n */\nexport function endedByReason(reason: CallReason | null | undefined): CallEndedBy | null {\n switch (reason) {\n case \"caller\":\n return \"person\";\n case \"agent\":\n return \"agent\";\n case \"max_duration\":\n return \"limit\";\n case null:\n case undefined:\n return null;\n default:\n return \"dropped\";\n }\n}\n\n/**\n * Why a call couldn't be made, from what asking for it threw: the API's refusal, by its code\n * (calls not set up where the agent works, an agent that takes none, any other it named);\n * anything else (no answer, or one that named nothing) is a call that failed.\n */\nexport function refusalOf(error: unknown): CallProblem {\n if (!(error instanceof ApiError) || error.code === null) return \"failed\";\n switch (error.code) {\n case \"no_calls\":\n return \"off\";\n case \"builder\":\n return \"builder\";\n default:\n return \"refused\";\n }\n}\n\n/**\n * Why the microphone couldn't be opened, from the browser's error (`DOMException` names, as\n * LiveKit reads them too); one the browser doesn't name is a call that failed.\n */\nexport function callProblemOf(error: unknown): CallProblem {\n const name = error instanceof Error || isNamed(error) ? error.name : \"\";\n switch (name) {\n case \"NotAllowedError\":\n case \"PermissionDeniedError\":\n case \"SecurityError\":\n return \"denied\";\n case \"NotFoundError\":\n case \"DevicesNotFoundError\":\n case \"OverconstrainedError\":\n return \"nomic\";\n case \"NotReadableError\":\n case \"TrackStartError\":\n case \"AbortError\":\n return \"inuse\";\n default:\n return \"failed\";\n }\n}\n\nconst isNamed = (value: unknown): value is { name: string } =>\n typeof value === \"object\" &&\n value !== null &&\n \"name\" in value &&\n typeof (value as { name: unknown }).name === \"string\";\n\n/** A call's time as a phone shows it: 0:07, 12:34, 1:02:03. */\nexport function durationOf(seconds: number): string {\n const whole = Math.max(0, Math.floor(seconds));\n const hours = Math.floor(whole / 3600);\n const minutes = Math.floor((whole % 3600) / 60);\n const rest = String(whole % 60).padStart(2, \"0\");\n return hours > 0 ? `${hours}:${String(minutes).padStart(2, \"0\")}:${rest}` : `${minutes}:${rest}`;\n}\n\n/**\n * A loudness read off a track (0 to 1, the root mean square of its spectrum as LiveKit measures\n * it) as the orb shows it: speech sits low on that scale, so it is lifted and the floor of a quiet\n * room cut away.\n */\nexport function loudness(volume: number): number {\n const lifted = (volume - FLOOR) / (1 - FLOOR);\n return Math.min(1, Math.max(0, Math.sqrt(Math.max(0, lifted)) * GAIN));\n}\n\nconst FLOOR = 0.04;\nconst GAIN = 1.25;\n\n/**\n * Whether a key belongs to what it was pressed on, not to the call: a field being typed in, or a\n * control of the page's own (outside the call) that Space presses. Elsewhere (the call itself,\n * the page, a dialog it sits in) Space mutes and unmutes.\n */\nexport function keyBelongsElsewhere(target: EventTarget | null, call: Element | null): boolean {\n if (!(target instanceof Element)) return false;\n if (target.matches(EDITABLE)) return true;\n if (call?.contains(target)) return false;\n return target.matches(PRESSABLE);\n}\n\nconst EDITABLE = 'input, textarea, select, [contenteditable]:not([contenteditable=\"false\"])';\nconst PRESSABLE =\n 'button, a[href], summary, [role=\"button\"], [role=\"checkbox\"], [role=\"switch\"], [role=\"radio\"], [role=\"tab\"], [role=\"menuitem\"], [role=\"option\"], [role=\"slider\"]';\n"],"mappings":"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"}