thursday-agent 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/build-manifest.json +3 -3
  3. package/.next/prerender-manifest.json +3 -3
  4. package/.next/server/app/_global-error.html +1 -1
  5. package/.next/server/app/_global-error.rsc +1 -1
  6. package/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  7. package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  8. package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  9. package/.next/server/app/_not-found/page.js.nft.json +1 -1
  10. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  11. package/.next/server/app/_not-found.html +1 -1
  12. package/.next/server/app/_not-found.rsc +2 -2
  13. package/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
  14. package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
  15. package/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  16. package/.next/server/app/api/artifact/route.js +3 -3
  17. package/.next/server/app/api/artifact/route.js.nft.json +1 -1
  18. package/.next/server/app/api/bot/memory/files/route.js.nft.json +1 -1
  19. package/.next/server/app/api/bot/memory/route.js.nft.json +1 -1
  20. package/.next/server/app/api/bot/route.js.nft.json +1 -1
  21. package/.next/server/app/api/bot/thread/[id]/route.js.nft.json +1 -1
  22. package/.next/server/app/api/bot/thread/route.js.nft.json +1 -1
  23. package/.next/server/app/api/file/[...path]/route.js.nft.json +1 -1
  24. package/.next/server/app/api/memory/edit/route.js +1 -1
  25. package/.next/server/app/api/memory/edit/route.js.nft.json +1 -1
  26. package/.next/server/app/api/skills/[source]/[name]/route.js.nft.json +1 -1
  27. package/.next/server/app/api/skills/route.js.nft.json +1 -1
  28. package/.next/server/app/api/thursday/call/route.js.nft.json +1 -1
  29. package/.next/server/app/api/thursday/skills/route.js.nft.json +1 -1
  30. package/.next/server/app/api/thursday/tool-call/route.js +1 -1
  31. package/.next/server/app/api/thursday/tool-call/route.js.nft.json +1 -1
  32. package/.next/server/app/api/workspace/missing/route.js +2 -2
  33. package/.next/server/app/api/workspace/missing/route.js.nft.json +1 -1
  34. package/.next/server/app/api/workspace/route.js.nft.json +1 -1
  35. package/.next/server/app/artifact/[...path]/page/server-reference-manifest.json +1 -1
  36. package/.next/server/app/artifact/[...path]/page.js.nft.json +1 -1
  37. package/.next/server/app/artifact/[...path]/page_client-reference-manifest.js +1 -1
  38. package/.next/server/app/page/server-reference-manifest.json +42 -42
  39. package/.next/server/app/page.js +5 -4
  40. package/.next/server/app/page.js.nft.json +1 -1
  41. package/.next/server/app/page_client-reference-manifest.js +1 -1
  42. package/.next/server/chunks/[root-of-the-server]__0-06t3f._.js +8 -0
  43. package/.next/server/chunks/{[root-of-the-server]__049f521._.js → [root-of-the-server]__06l05n_._.js} +1 -1
  44. package/.next/server/chunks/[root-of-the-server]__0_ue8oe._.js +1 -1
  45. package/.next/server/chunks/{[root-of-the-server]__0ye3da2._.js → [root-of-the-server]__0btqrct._.js} +1 -1
  46. package/.next/server/chunks/[root-of-the-server]__0c9fgca._.js +1 -1
  47. package/.next/server/chunks/[root-of-the-server]__0zp35ul._.js +1 -1
  48. package/.next/server/chunks/[root-of-the-server]__1kv8p3e._.js +1 -1
  49. package/.next/server/chunks/_02szwk8._.js +1 -1
  50. package/.next/server/chunks/_06c9kie._.js +24 -24
  51. package/.next/server/chunks/_0i55en5._.js +6 -6
  52. package/.next/server/chunks/_0p56fn0._.js +1 -1
  53. package/.next/server/chunks/_11ujpya._.js +1 -1
  54. package/.next/server/chunks/_1dbixxz._.js +1 -1
  55. package/.next/server/chunks/_1dc_d1q._.js +16 -16
  56. package/.next/server/chunks/_1gnjfli._.js +3 -0
  57. package/.next/server/chunks/_1klbcft._.js +1 -1
  58. package/.next/server/chunks/features_0-cnt0l._.js +1 -1
  59. package/.next/server/chunks/features_0hdaci0._.js +1 -1
  60. package/.next/server/chunks/ssr/[root-of-the-server]__0-e011f._.js +3 -0
  61. package/.next/server/chunks/ssr/[root-of-the-server]__0pd8yr7._.js +3 -0
  62. package/.next/server/chunks/ssr/[root-of-the-server]__1nygfik._.js +1 -1
  63. package/.next/server/chunks/ssr/[root-of-the-server]__1sdphb0._.js +1 -1
  64. package/.next/server/chunks/ssr/{[root-of-the-server]__1fll8dc._.js → [root-of-the-server]__1vdmfbe._.js} +2 -2
  65. package/.next/server/chunks/ssr/[root-of-the-server]__1waa3rc._.js +34 -49
  66. package/.next/server/chunks/ssr/{_1anqots._.js → _0-2qsxo._.js} +2 -2
  67. package/.next/server/chunks/ssr/_02oc_h5._.js +9 -9
  68. package/.next/server/chunks/ssr/_05yghx6._.js +1 -1
  69. package/.next/server/chunks/ssr/_07nbwap._.js +3 -0
  70. package/.next/server/chunks/ssr/_08c4z-9._.js +1 -1
  71. package/.next/server/chunks/ssr/_0_qfdlw._.js +1 -1
  72. package/.next/server/chunks/ssr/_0b6_jv-._.js +3 -0
  73. package/.next/server/chunks/ssr/_0hxt2ul._.js +1 -1
  74. package/.next/server/chunks/ssr/_0i_6q1w._.js +1 -1
  75. package/.next/server/chunks/ssr/{_18xqmny._.js → _0no7i1o._.js} +2 -2
  76. package/.next/server/chunks/ssr/_0tzg--b._.js +3 -0
  77. package/.next/server/chunks/ssr/{_1ztk7p6._.js → _114ylv7._.js} +3 -3
  78. package/.next/server/chunks/ssr/{_1k0spz7._.js → _12c-iwh._.js} +2 -2
  79. package/.next/server/chunks/ssr/_12w42gi._.js +1 -1
  80. package/.next/server/chunks/ssr/{_0eaog6_._.js → _167hchk._.js} +2 -2
  81. package/.next/server/chunks/ssr/{_1x9pskj._.js → _1_x5spz._.js} +3 -3
  82. package/.next/server/chunks/ssr/{_0dr-ddg._.js → _1a61fc9._.js} +2 -2
  83. package/.next/server/chunks/ssr/_1jmz33w._.js +1 -1
  84. package/.next/server/chunks/ssr/{_1y6b5mr._.js → _1w_t7h7._.js} +2 -2
  85. package/.next/server/chunks/ssr/{_0dh2e1j._.js → _1wyfaq_._.js} +7 -7
  86. package/.next/server/chunks/ssr/{[root-of-the-server]__0bxsfyn._.js → _200gt-m._.js} +4 -3
  87. package/.next/server/chunks/ssr/features_config_components_config-setting_tsx_0xfwqdg._.js +1 -1
  88. package/.next/server/chunks/ssr/features_connectors_components_mcp-setting_tsx_0jmbgvp._.js +1 -1
  89. package/.next/server/chunks/ssr/{_0c9e5w6._.js → features_workspace_00jz2hu._.js} +3 -4
  90. package/.next/server/chunks/ssr/{node_modules_0746rzi._.js → node_modules_02ai7br._.js} +1 -1
  91. package/.next/server/chunks/ssr/{node_modules_0vsxbzp._.js → node_modules_02deexj._.js} +1 -1
  92. package/.next/server/chunks/ssr/{node_modules_1g1eihe._.js → node_modules_07za262._.js} +1 -1
  93. package/.next/server/chunks/ssr/{node_modules_0332gw4._.js → node_modules_0_s9get._.js} +1 -1
  94. package/.next/server/chunks/ssr/{node_modules_1gpednh._.js → node_modules_0n3c7h0._.js} +1 -1
  95. package/.next/server/chunks/ssr/{node_modules_0t-9nup._.js → node_modules_0od6jwr._.js} +1 -1
  96. package/.next/server/chunks/ssr/{node_modules_0mxvg8h._.js → node_modules_0rdt7pk._.js} +1 -1
  97. package/.next/server/chunks/ssr/{node_modules_0vv0-fg._.js → node_modules_0w1z7kz._.js} +1 -1
  98. package/.next/server/chunks/ssr/{node_modules_1d53yiq._.js → node_modules_0wdupfz._.js} +1 -1
  99. package/.next/server/chunks/ssr/{node_modules_1u6w1pb._.js → node_modules_1720w0a._.js} +1 -1
  100. package/.next/server/chunks/ssr/{node_modules_1gu1pph._.js → node_modules_18lsg7d._.js} +1 -1
  101. package/.next/server/chunks/ssr/{node_modules_1he1w8_._.js → node_modules_1b94fxf._.js} +1 -1
  102. package/.next/server/chunks/ssr/{node_modules_0qbfyjh._.js → node_modules_1ecdo1s._.js} +1 -1
  103. package/.next/server/chunks/ssr/{node_modules_0qapgjr._.js → node_modules_1fa9ag7._.js} +1 -1
  104. package/.next/server/chunks/ssr/{node_modules_0d5uzof._.js → node_modules_1fv-_si._.js} +1 -1
  105. package/.next/server/chunks/ssr/{node_modules_1lnjapm._.js → node_modules_1hf461g._.js} +1 -1
  106. package/.next/server/chunks/ssr/{node_modules_1-urho9._.js → node_modules_1m71ta-._.js} +1 -1
  107. package/.next/server/chunks/ssr/{node_modules_1lynj9m._.js → node_modules_1n93n-g._.js} +1 -1
  108. package/.next/server/chunks/ssr/{node_modules_0fr-ml8._.js → node_modules_1n9lqu9._.js} +1 -1
  109. package/.next/server/chunks/ssr/{node_modules_04vxchl._.js → node_modules_1npnsvd._.js} +1 -1
  110. package/.next/server/chunks/ssr/{node_modules_0exkwcm._.js → node_modules_1rn2r0n._.js} +1 -1
  111. package/.next/server/chunks/ssr/{node_modules_022sipm._.js → node_modules_1vq-mfk._.js} +1 -1
  112. package/.next/server/chunks/ssr/{node_modules_00_51_v._.js → node_modules_1vzyw1t._.js} +1 -1
  113. package/.next/server/chunks/ssr/{node_modules_0wsvp00._.js → node_modules_1w359w4._.js} +1 -1
  114. package/.next/server/chunks/ssr/{node_modules_1xn6saw._.js → node_modules_1yajbl6._.js} +1 -1
  115. package/.next/server/chunks/ssr/{node_modules_16pi_dd._.js → node_modules_20eqhy8._.js} +1 -1
  116. package/.next/server/chunks/ssr/{node_modules_@mermaid-js_parser_dist_chunks_mermaid-parser_core_067_exa._.js → node_modules_@mermaid-js_parser_dist_chunks_mermaid-parser_core_019sbis._.js} +1 -1
  117. package/.next/server/chunks/ssr/{node_modules_@mermaid-js_parser_dist_chunks_mermaid-parser_core_0nrrdaq._.js → node_modules_@mermaid-js_parser_dist_chunks_mermaid-parser_core_0s6piq3._.js} +1 -1
  118. package/.next/server/chunks/ssr/{node_modules_@vercel_oidc_dist_21a5haa._.js → node_modules_@vercel_oidc_dist_05itbb1._.js} +1 -1
  119. package/.next/server/middleware-build-manifest.js +3 -3
  120. package/.next/server/pages/404.html +1 -1
  121. package/.next/server/pages/500.html +1 -1
  122. package/.next/server/server-reference-manifest.js +1 -1
  123. package/.next/server/server-reference-manifest.json +43 -43
  124. package/.next/static/chunks/{0dr16v67vj4n7.js → 07vqbk_9t4em4.js} +2 -2
  125. package/.next/static/chunks/{2nvepds1fu2ux.js → 08krja1zx7nqg.js} +1 -1
  126. package/.next/static/chunks/{42wcg3wfg_m_p.js → 0qcuwdk132jjt.js} +1 -1
  127. package/.next/static/chunks/{2_pnccjp6j1ca.js → 0z69gvysmave7.js} +1 -1
  128. package/.next/static/chunks/12fvz1w5ows75.js +1 -0
  129. package/.next/static/chunks/14t03ned286iu.js +1 -0
  130. package/.next/static/chunks/{2jlz8zp4jax_m.js → 156xxbkrz5lzu.js} +1 -1
  131. package/.next/static/chunks/1fz14reod4lft.js +1 -0
  132. package/.next/static/chunks/{0joan-69kbspd.js → 1g23dbabl-vkr.js} +1 -1
  133. package/.next/static/chunks/{18mkhstgiqcbt.js → 1qy_rc3oxfxok.js} +1 -1
  134. package/.next/static/chunks/1zea7qdl-7365.js +1 -0
  135. package/.next/static/chunks/1zvqumkg7kmo0.css +1 -0
  136. package/.next/static/chunks/26hpqt3y6bogl.js +12 -0
  137. package/.next/static/chunks/{3zk6q3-3f9swq.js → 2_j5mqm6d-z18.js} +1 -1
  138. package/.next/static/chunks/{1vj_5bnbxx84e.js → 2dt6tu7a22svs.js} +1 -1
  139. package/.next/static/chunks/2h5ls3d86kya6.js +1 -0
  140. package/.next/static/chunks/2wc6y1ht6dfuy.js +1 -0
  141. package/.next/static/chunks/{1i-u2wzub_to1.js → 2xr0zpll7thyi.js} +6 -6
  142. package/.next/static/chunks/{26ql4y-43xwhp.js → 31jztc-yq30m7.js} +1 -1
  143. package/.next/static/chunks/391af0i8eljzk.js +1 -0
  144. package/.next/static/chunks/{1kp5nfp06nj9h.js → 3p2s2cjokx1aa.js} +2 -2
  145. package/.next/static/chunks/{1wlo979mx-y3w.js → 41s3fml5oivsa.js} +1 -1
  146. package/README.md +60 -19
  147. package/docs/how-it-works.md +2 -0
  148. package/docs/live-calls.md +38 -33
  149. package/features/ai/load-tools.ts +4 -2
  150. package/features/ai/prompts/bot.prompt.ts +5 -5
  151. package/features/ai/prompts/live.prompt.ts +36 -132
  152. package/features/ai/prompts/prompt-helper.ts +5 -4
  153. package/features/ai/prompts/thursday.prompt.ts +75 -54
  154. package/features/ai/tools/call.tool.ts +32 -4
  155. package/features/ai/tools/tool-name.ts +1 -0
  156. package/features/bot/bot.runner.ts +5 -5
  157. package/features/thursday/ascii.const.ts +74 -0
  158. package/features/thursday/components/ascii-orb.tsx +173 -30
  159. package/features/thursday/components/face.tsx +6 -1
  160. package/features/thursday/components/side-captions.tsx +273 -0
  161. package/features/thursday/components/tab-state.tsx +11 -3
  162. package/features/thursday/components/thursday-setting.tsx +6 -5
  163. package/features/thursday/components/thursday.tsx +227 -251
  164. package/features/thursday/face.store.ts +5 -0
  165. package/features/thursday/thursday.action.ts +4 -6
  166. package/features/thursday/thursday.schema.ts +10 -5
  167. package/features/thursday/thursday.store.ts +3 -0
  168. package/features/thursday/use-thursday.ts +461 -392
  169. package/features/workspace/file-kind.ts +9 -0
  170. package/hooks/use-hotkey.ts +3 -2
  171. package/lib/live/live.schema.ts +0 -21
  172. package/lib/live/live.server.ts +1 -3
  173. package/package.json +1 -1
  174. package/scripts/artifact-paths.test.mts +13 -0
  175. package/scripts/live-session.test.mts +56 -66
  176. package/.next/server/chunks/[root-of-the-server]__0g-ueei._.js +0 -8
  177. package/.next/server/chunks/node_modules_consola_dist_chunks_prompt_mjs_1kipdeo._.js +0 -3
  178. package/.next/server/chunks/ssr/[root-of-the-server]__172cq4x._.js +0 -3
  179. package/.next/server/chunks/ssr/_03_928f._.js +0 -3
  180. package/.next/server/chunks/ssr/_0g_rlog._.js +0 -3
  181. package/.next/server/chunks/ssr/_123ttsf._.js +0 -3
  182. package/.next/static/chunks/10k2xvap03z20.css +0 -1
  183. package/.next/static/chunks/1t9qms5pwwa3n.js +0 -1
  184. package/.next/static/chunks/2a51-ywz9b2a3.js +0 -1
  185. package/.next/static/chunks/2fvdgxih7x7hr.js +0 -1
  186. package/.next/static/chunks/3addj3i4-izma.js +0 -1
  187. package/.next/static/chunks/3gfh7nxwusbt0.js +0 -1
  188. package/.next/static/chunks/3gwa44cvz4qoi.js +0 -1
  189. package/.next/static/chunks/3zkg5iup3v4a2.js +0 -12
  190. package/.next/static/chunks/405m0bqtoihev.js +0 -1
  191. /package/.next/static/{0qzGoKn2wTERVZtblX1tp → yyRVBc0xxYQsh3GpbQX9b}/_buildManifest.js +0 -0
  192. /package/.next/static/{0qzGoKn2wTERVZtblX1tp → yyRVBc0xxYQsh3GpbQX9b}/_clientMiddlewareManifest.js +0 -0
  193. /package/.next/static/{0qzGoKn2wTERVZtblX1tp → yyRVBc0xxYQsh3GpbQX9b}/_ssgManifest.js +0 -0
@@ -1,10 +1,10 @@
1
1
  "use client";
2
2
 
3
- import { useCallback, useEffect, useRef, useState } from "react";
3
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
4
  import { useAppEvent } from "@/app/api/events/app-event.client";
5
5
  import { queryKey } from "@/app/api/query-key";
6
6
  import { toast } from "@/components/ui/toast";
7
- import { CALL_END, CALL_IDLE, CALL_RELAY } from "@/config";
7
+ import { CALL_BACK, CALL_END, CALL_IDLE, CALL_RELAY } from "@/config";
8
8
  import { TOOL_NAMES } from "@/features/ai/tools/tool-name";
9
9
  import { acceptThreadRelaysAction } from "@/features/bot/bot.action";
10
10
  import { type Bot, isAppStop, type Thread } from "@/features/bot/bot.schema";
@@ -33,20 +33,27 @@ import {
33
33
  } from "@/lib/protocol/use-server-route";
34
34
  import { createOutbox, type Outbox } from "@/lib/queue";
35
35
  import { errorToString } from "@/lib/utils";
36
+ import { FACE_WORD_MAX, undrawable } from "./ascii.const";
36
37
  import {
37
38
  endCallAction,
38
39
  openCallAction,
39
40
  saveThoughtAction,
40
41
  saveTurnsAction,
41
42
  } from "./thursday.action";
42
- import type { CallMessage, CallStatus, LiveStatus } from "./thursday.schema";
43
+ import type {
44
+ CallBack,
45
+ CallMessage,
46
+ CallStatus,
47
+ FaceWord,
48
+ LiveStatus,
49
+ } from "./thursday.schema";
43
50
  import { thursdaySettings, useThursdayStore } from "./thursday.store";
44
51
  import { toolBot, toolLine } from "./tool-line";
45
52
 
46
53
  /**
47
54
  * One live call, plus the thread inbox the app watches even with no call open.
48
55
  * The server opens the Live session (openCallAction) and runs the tools
49
- * (tool-call); the page itself only hangs up.
56
+ * (tool-call); the page itself only hangs up and puts a word on the face.
50
57
  */
51
58
 
52
59
  /** Plays when the line opens. */
@@ -97,16 +104,6 @@ const THREAD_POLL_FALLBACK_MS = 30_000;
97
104
  /** Failed saves of one kind before the call stops saving that kind and says so, once. */
98
105
  const SAVE_FAILURE_LIMIT = 3;
99
106
 
100
- /**
101
- * Put in as trusted behaviour just ahead of the first relay of a call. The relay
102
- * itself carries facts only, since the backend reads relays too and routes answers
103
- * by them.
104
- */
105
- const RELAY_NOTE =
106
- "An update from background work follows. If you have already told the user what it says, it need not be said again.";
107
-
108
- const IDLE_LINE = `The user has said nothing for ${Math.round(CALL_IDLE.hangUpMs / 1000)} seconds. Say a one-line goodbye and end the call.`;
109
-
110
107
  /**
111
108
  * What the activity line draws: a tool the model is using, or a relay. `line` is
112
109
  * the human-readable sentence (tool-line), null when none exists; `name` is the
@@ -124,6 +121,22 @@ export type ActivityLine = {
124
121
  bot?: string | null;
125
122
  };
126
123
 
124
+ /** A call-back ringing, as the screen names it. */
125
+ export type Ringing = {
126
+ /** The bot whose work rang: the one asking, else the thread's own. */
127
+ bot: string;
128
+ kind: "question" | "done" | "stopped";
129
+ label: string;
130
+ /** Other threads ringing with it. */
131
+ more: number;
132
+ };
133
+
134
+ /**
135
+ * Why a call ended without the user hanging up: the line was quiet (CALL_IDLE),
136
+ * she hung up (`end_call`), Live closed the session, or the connection dropped.
137
+ */
138
+ export type CallEnd = "quiet" | "hungUp" | "closed" | "dropped";
139
+
127
140
  export function useThursday() {
128
141
  const session = useRef<LiveSession | null>(null);
129
142
  // created on the first call: `new Audio()` cannot run during SSR
@@ -148,8 +161,14 @@ export function useThursday() {
148
161
  const [since, setSince] = useState<number | null>(null);
149
162
  /** Seconds until idle hang-up; set only inside CALL_IDLE.warnMs. */
150
163
  const [idleLeft, setIdleLeft] = useState<number | null>(null);
164
+ /** Why the last call ended, when the user did not end it; cleared by the next call. */
165
+ const [ended, setEnded] = useState<CallEnd | null>(null);
151
166
  /** When the backend picked this turn up (ms); the activity line counts from it. */
152
167
  const [thinkingSince, setThinkingSince] = useState<number | null>(null);
168
+ /** The title of the backend's latest reasoning summary in this stretch of work. */
169
+ const [thinkingTitle, setThinkingTitle] = useState<string | null>(null);
170
+ /** The word `emote` last put on the face. */
171
+ const [faceWord, setFaceWord] = useState<FaceWord | null>(null);
153
172
  /** The same value where callbacks can read it, and the timer that ends it. */
154
173
  const thinking = useRef<number | null>(null);
155
174
  const thinkTail = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -164,16 +183,8 @@ export function useThursday() {
164
183
  }>({ on: false, spoke: false, giveUp: null });
165
184
  /** What `session.closed` confirmed for the call being ended; recorded on its row. */
166
185
  const finalized = useRef<LiveClose | null>(null);
167
- /**
168
- * Last activity time, whether the goodbye was requested, and when an answer
169
- * started being owed (null once she answers or works).
170
- */
171
- const idle = useRef<{
172
- since: number;
173
- asked: boolean;
174
- owed: number | null;
175
- grace: ReturnType<typeof setTimeout> | null;
176
- }>({ since: 0, asked: false, owed: null, grace: null });
186
+ /** When the line last had new words from the user, her voice or backend work: the idle clock. */
187
+ const stirred = useRef(0);
177
188
  const linger = useRef<ReturnType<typeof setTimeout> | null>(null);
178
189
  /** Whether the line holds a relay, so only a relay is finished by her voice. */
179
190
  const relayOpen = useRef(false);
@@ -188,6 +199,14 @@ export function useThursday() {
188
199
  * asks or ends again is new work.
189
200
  */
190
201
  const told = useRef(new Set<string>());
202
+ /**
203
+ * The call-back rings only for threads that changed after this: the last call's
204
+ * end, the last ring, or the page opening.
205
+ */
206
+ const ringAfter = useRef(Date.now());
207
+ /** The threads a call-back is ringing for, until it is answered, declined or rings out. */
208
+ const [ringingFor, setRingingFor] = useState<string[]>([]);
209
+ const isRinging = ringingFor.length > 0;
191
210
  /**
192
211
  * Keys put in this call that her voice has not carried yet: rejected, cut short
193
212
  * by a hang-up, or never spoken. Hanging up takes them out of `told`, so the
@@ -196,13 +215,8 @@ export function useThursday() {
196
215
  const unvoiced = useRef(new Set<string>());
197
216
  /** The keys of the update on the line now, carried once her voice starts and stops on it. */
198
217
  const onLine = useRef<string[]>([]);
199
- /**
200
- * When her voice was last heard: for letting a goodbye finish (CALL_END), and for
201
- * telling words she already answered from words still owed an answer.
202
- */
218
+ /** When her voice was last heard, for letting a goodbye finish (CALL_END). */
203
219
  const voiced = useRef(0);
204
- /** The relay note went in on this call (RELAY_NOTE). */
205
- const relayNoted = useRef(false);
206
220
  /** The hang-up waiting on her goodbye once the backend called end_call. */
207
221
  const leaving = useRef<ReturnType<typeof setInterval> | null>(null);
208
222
  /** When either side's words were last transcribed: the quiet clock for relays. */
@@ -307,6 +321,8 @@ export function useThursday() {
307
321
  const setThinking = useCallback((at: number | null) => {
308
322
  thinking.current = at;
309
323
  setThinkingSince(at);
324
+ // a stretch of work that starts or ends has not said what it is about yet
325
+ setThinkingTitle(null);
310
326
  }, []);
311
327
 
312
328
  /**
@@ -357,7 +373,7 @@ export function useThursday() {
357
373
  const live = session.current;
358
374
  const threads = latest.current;
359
375
  if (!live || !threads || reading.current.on || acting.current) return;
360
- if (leaving.current || idle.current.asked) return;
376
+ if (leaving.current) return;
361
377
  // On a call-back, why she called goes in once the opening is voiced: it is the
362
378
  // first thing they ask, and a quiet line would come too late
363
379
  const rung = rang.current;
@@ -383,11 +399,6 @@ export function useThursday() {
383
399
  onLine.current = due.map((item) => item.key);
384
400
  // On the line before it goes out, so it runs there for the whole wait
385
401
  showRelay((due.at(-1) ?? first).show);
386
- // Appends go out in order, so the note is in her context before the first update
387
- if (!relayNoted.current) {
388
- relayNoted.current = true;
389
- void live.append("instructions", RELAY_NOTE);
390
- }
391
402
  void live
392
403
  .append(
393
404
  "commentary",
@@ -524,60 +535,63 @@ export function useThursday() {
524
535
  [outbox],
525
536
  );
526
537
 
527
- /** Closes the session and resets state. Turns were saved during the call. */
528
- const hangUp = useCallback(async () => {
529
- if (ending.current) return;
530
- ending.current = true;
531
- if (leaving.current) clearInterval(leaving.current);
532
- leaving.current = null;
533
- attempt.current += 1;
534
- const live = session.current;
535
- const call = callId.current;
536
-
537
- session.current = null;
538
- callId.current = null;
539
- calling.current = false;
540
- opening.current = false;
541
- relayNoted.current = false;
542
- rang.current = false;
543
- // What she did not voice goes in again next call; unsent context goes with the session
544
- for (const key of unvoiced.current) told.current.delete(key);
545
- unvoiced.current.clear();
546
- outbox.close();
547
- outbox.clear();
548
- working.current?.abort();
549
- working.current = null;
550
- doneReading();
551
- if (linger.current) clearTimeout(linger.current);
552
- // a pending face timer must not fire after the call
553
- restFace();
554
- if (idle.current.grace) clearTimeout(idle.current.grace);
555
- idle.current = { since: 0, asked: false, owed: null, grace: null };
556
- setIdleLeft(null);
557
- setSince(null);
558
- setStatus(live ? "ending" : "idle");
559
- setMessages([]);
560
- setTool(null);
561
- setThinking(null);
562
- if (thinkTail.current) clearTimeout(thinkTail.current);
563
- thinkTail.current = null;
564
- relayOpen.current = false;
565
- // the room is not cleared: threads outlive the call
538
+ /** Closes the session and resets state. Turns were saved during the call. `why` is null when the user hung up. */
539
+ const hangUp = useCallback(
540
+ async (why: CallEnd | null = null) => {
541
+ if (ending.current) return;
542
+ ending.current = true;
543
+ if (leaving.current) clearInterval(leaving.current);
544
+ leaving.current = null;
545
+ attempt.current += 1;
546
+ const live = session.current;
547
+ const call = callId.current;
566
548
 
567
- // Waits (bounded) for session.closed, which saves the last turns and says
568
- // what was billed; the row is ended with that, after the saves it queued
569
- await live?.close();
570
- const close = finalized.current;
571
- finalized.current = null;
572
- ending.current = false;
573
- setStatus("idle");
574
- if (call) void endCallAction(call, close);
575
- // only when there was a line to close
576
- if (live && farewell.current) {
577
- farewell.current.currentTime = 0;
578
- void farewell.current.play().catch(() => {});
579
- }
580
- }, [outbox, restFace, doneReading, setThinking]);
549
+ session.current = null;
550
+ callId.current = null;
551
+ calling.current = false;
552
+ opening.current = false;
553
+ rang.current = false;
554
+ // What she did not voice goes in again next call; unsent context goes with the session
555
+ for (const key of unvoiced.current) told.current.delete(key);
556
+ unvoiced.current.clear();
557
+ outbox.close();
558
+ outbox.clear();
559
+ working.current?.abort();
560
+ working.current = null;
561
+ doneReading();
562
+ if (linger.current) clearTimeout(linger.current);
563
+ // a pending face timer must not fire after the call
564
+ restFace();
565
+ // What came up on this call was this call's to tell; the call-back rings only for what comes after
566
+ ringAfter.current = Date.now();
567
+ setIdleLeft(null);
568
+ setSince(null);
569
+ setStatus(live ? "ending" : "idle");
570
+ setMessages([]);
571
+ setTool(null);
572
+ setThinking(null);
573
+ if (thinkTail.current) clearTimeout(thinkTail.current);
574
+ thinkTail.current = null;
575
+ relayOpen.current = false;
576
+ // the room is not cleared: threads outlive the call
577
+
578
+ // Waits (bounded) for session.closed, which saves the last turns and says
579
+ // what was billed; the row is ended with that, after the saves it queued
580
+ await live?.close();
581
+ const close = finalized.current;
582
+ finalized.current = null;
583
+ ending.current = false;
584
+ setStatus("idle");
585
+ if (live) setEnded(why);
586
+ if (call) void endCallAction(call, close);
587
+ // only when there was a line to close
588
+ if (live && farewell.current) {
589
+ farewell.current.currentTime = 0;
590
+ void farewell.current.play().catch(() => {});
591
+ }
592
+ },
593
+ [outbox, restFace, doneReading, setThinking],
594
+ );
581
595
 
582
596
  // Unmount during a call must release the mic and stop tools
583
597
  useEffect(() => {
@@ -599,7 +613,6 @@ export function useThursday() {
599
613
  if (faceIn.current) clearTimeout(faceIn.current);
600
614
  if (reading.current.giveUp) clearTimeout(reading.current.giveUp);
601
615
  if (thinkTail.current) clearTimeout(thinkTail.current);
602
- if (idle.current.grace) clearTimeout(idle.current.grace);
603
616
  if (failedFor.current) clearTimeout(failedFor.current);
604
617
  if (leaving.current) clearInterval(leaving.current);
605
618
  };
@@ -620,62 +633,25 @@ export function useThursday() {
620
633
  const over = said
621
634
  ? now - Math.max(voiced.current, asked) >= CALL_END.quietMs
622
635
  : now - asked >= CALL_END.unsaidMs;
623
- if (over || now - asked >= CALL_END.maxMs) void hangUp();
636
+ if (over || now - asked >= CALL_END.maxMs) void hangUp("hungUp");
624
637
  }, 100);
625
638
  }, [hangUp]);
626
639
 
627
- /**
628
- * Rewinds the idle clock. `user` is new words transcribed from them, first seen at
629
- * `said`: an answer is owed from then, and a requested goodbye is off. Transcripts
630
- * lag the audio, so her voice heard since `said` already answered them. `agent` is
631
- * her voice or backend work, which settles what is owed but cannot cancel its own
632
- * goodbye.
633
- */
634
- const stir = useCallback((who: "user" | "agent", said = 0) => {
635
- const now = Date.now();
636
- idle.current.since = now;
637
- if (who !== "user") {
638
- idle.current.owed = null;
639
- return;
640
- }
641
- if (voiced.current < said) idle.current.owed ??= now;
642
- if (idle.current.grace) clearTimeout(idle.current.grace);
643
- idle.current.grace = null;
644
- idle.current.asked = false;
645
- }, []);
646
-
647
- // Idle clock: shows the countdown, then asks for a goodbye and grants a grace period
640
+ // Idle clock: shows the countdown, then hangs up. The page ends a quiet line itself;
641
+ // a goodbye would leave the ending to the model
648
642
  useEffect(() => {
649
643
  if (!onCall) return;
650
- stir("agent");
644
+ stirred.current = Date.now();
651
645
  const tick = setInterval(() => {
652
- const live = session.current;
653
- if (!live) return;
654
- const owed = idle.current.owed;
655
- if (owed !== null && Date.now() - owed >= CALL_IDLE.agentSilentMs) {
656
- // Nothing to say goodbye with; the model is what would have said it.
657
- toast.add({
658
- type: "error",
659
- title: "Call ended",
660
- description: "The model stopped answering.",
661
- });
662
- void hangUp();
663
- return;
664
- }
665
- const left = CALL_IDLE.hangUpMs - (Date.now() - idle.current.since);
646
+ if (!session.current) return;
647
+ const left = CALL_IDLE.hangUpMs - (Date.now() - stirred.current);
666
648
  setIdleLeft(
667
- left <= CALL_IDLE.warnMs && !idle.current.asked
668
- ? Math.max(0, Math.ceil(left / 1000))
669
- : null,
649
+ left <= CALL_IDLE.warnMs ? Math.max(0, Math.ceil(left / 1000)) : null,
670
650
  );
671
- if (left > 0 || idle.current.asked) return;
672
-
673
- idle.current.asked = true;
674
- void live.append("instructions", IDLE_LINE);
675
- idle.current.grace = setTimeout(() => void hangUp(), CALL_IDLE.graceMs);
651
+ if (left <= 0) void hangUp("quiet");
676
652
  }, 1000);
677
653
  return () => clearInterval(tick);
678
- }, [onCall, stir, hangUp]);
654
+ }, [onCall, hangUp]);
679
655
 
680
656
  // The relay clock: open work goes in only while the line is quiet
681
657
  useEffect(() => {
@@ -684,278 +660,317 @@ export function useThursday() {
684
660
  return () => clearInterval(tick);
685
661
  }, [onCall, relayOpenWork]);
686
662
 
687
- const call = useCallback(
688
- async (placedBy?: "callBack") => {
689
- // Compared exactly: a face tap hands its click event in here
690
- const calledBack = placedBy === "callBack";
691
- // Guard with a ref, not `status`: three entry points (face, wake word,
692
- // hotkey) can fire in one frame and both see a stale "idle", opening two sessions
693
- if (opening.current || ending.current) return;
694
- // with a line open this press hangs up; `calling` covers the gap before re-render
695
- if (calling.current || status !== "idle") return hangUp();
696
-
697
- setStatus("connecting");
698
- showFailed(false);
699
- // from here on this is a call; the outbox holds updates until the session exists
700
- opening.current = true;
701
- calling.current = true;
702
- finalized.current = null;
703
- relayNoted.current = false;
704
- outbox.clear();
705
- attempt.current += 1;
706
- const mine = attempt.current;
707
- const current = () => calling.current && attempt.current === mine;
708
- try {
709
- // Inside the gesture, before anything awaits: an AudioContext created later
710
- // starts suspended. Calls from the wake word or a call-back have no gesture;
711
- // armAudioUnlock handles those
712
- tap.current ??= createAudioTap();
713
- armAudioUnlock(tap.current.open().context);
714
- const chime = new Audio(CONNECTED_SOUND);
715
- farewell.current ??= new Audio(HUNG_UP_SOUND);
716
-
717
- const settings = thursdaySettings();
718
- const stop = new AbortController();
719
- working.current = stop;
720
- const saving = { failures: 0 };
721
- const thinkingSaves = { failures: 0 };
722
- /** Filled in by the handshake, read by callbacks that only run after it. */
723
- const line = { callId: "", opening: null as string | null };
724
-
725
- // one turn per id; the session reports display groups one at a time
726
- const turns = new Map<string, CallMessage & { seq: number }>();
727
- /** When the page first saw each of the user's display groups. */
728
- const userSaid = new Map<string, number>();
729
-
730
- const live = await openLiveSession({
731
- initialize: async (sdp) => {
732
- const handshake = unwrapResult(
733
- await openCallAction(settings, sdp, calledBack),
663
+ const call = useCallback(async () => {
664
+ // Every way in answers a ringing call-back: the face, the wake word, the hotkey
665
+ const calledBack = isRinging;
666
+ // Guard with a ref, not `status`: three entry points (face, wake word,
667
+ // hotkey) can fire in one frame and both see a stale "idle", opening two sessions
668
+ if (opening.current || ending.current) return;
669
+ // with a line open this press hangs up; `calling` covers the gap before re-render
670
+ if (calling.current || status !== "idle") return hangUp();
671
+ setRingingFor([]);
672
+
673
+ setStatus("connecting");
674
+ setEnded(null);
675
+ showFailed(false);
676
+ // from here on this is a call; the outbox holds updates until the session exists
677
+ opening.current = true;
678
+ calling.current = true;
679
+ finalized.current = null;
680
+ outbox.clear();
681
+ attempt.current += 1;
682
+ const mine = attempt.current;
683
+ const current = () => calling.current && attempt.current === mine;
684
+ try {
685
+ // Inside the gesture, before anything awaits: an AudioContext created later
686
+ // starts suspended. Calls from the wake word or a call-back have no gesture;
687
+ // armAudioUnlock handles those
688
+ tap.current ??= createAudioTap();
689
+ armAudioUnlock(tap.current.open().context);
690
+ const chime = new Audio(CONNECTED_SOUND);
691
+ farewell.current ??= new Audio(HUNG_UP_SOUND);
692
+
693
+ const settings = thursdaySettings();
694
+ const stop = new AbortController();
695
+ working.current = stop;
696
+ const saving = { failures: 0 };
697
+ const thinkingSaves = { failures: 0 };
698
+ /** Filled in by the handshake, read by callbacks that only run after it. */
699
+ const line = { callId: "", opening: null as string | null };
700
+
701
+ // one turn per id; the session reports display groups one at a time
702
+ const turns = new Map<string, CallMessage & { seq: number }>();
703
+
704
+ const live = await openLiveSession({
705
+ initialize: async (sdp) => {
706
+ const handshake = unwrapResult(
707
+ await openCallAction(settings, sdp, calledBack),
708
+ );
709
+ if (!current()) {
710
+ void endCallAction(handshake.callId);
711
+ throw new Error("The call closed during startup.");
712
+ }
713
+ callId.current = handshake.callId;
714
+ line.callId = handshake.callId;
715
+ line.opening = handshake.opening;
716
+ return handshake.sdp;
717
+ },
718
+ audio: tap.current,
719
+ on: {
720
+ // the backend calls tools; the page forwards them to the server
721
+ runTool: async (call) => {
722
+ // end_call and emote are the page's own tools. The line goes down once
723
+ // her goodbye is over (leave), so this reply reaches the model first
724
+ if (call.name === TOOL_NAMES.end_call) {
725
+ leave();
726
+ return "Ending the call.";
727
+ }
728
+ // the face draws the word; nothing runs anywhere else
729
+ if (call.name === TOOL_NAMES.emote) {
730
+ const { word, reply } = readFaceWord(call.arguments);
731
+ if (word) setFaceWord({ text: word, at: Date.now() });
732
+ return reply;
733
+ }
734
+ showTool(call);
735
+ try {
736
+ return await runRemoteTool(line.callId, call, stop.signal);
737
+ } finally {
738
+ hideTool(call.id);
739
+ }
740
+ },
741
+ reasoning: (part) => {
742
+ // A summary part opens with its title in bold; the activity line says what the work is about
743
+ const title = /^\s*\*\*(.+?)\*\*/.exec(part.text)?.[1]?.trim();
744
+ if (title) setThinkingTitle(title);
745
+ persist(
746
+ thinkingSaves,
747
+ () =>
748
+ saveThoughtAction(line.callId, {
749
+ ...part,
750
+ seq: Math.max(0, Math.round(part.seq)),
751
+ }),
752
+ "The backend's thinking is not being saved",
734
753
  );
735
- if (!current()) {
736
- void endCallAction(handshake.callId);
737
- throw new Error("The call closed during startup.");
754
+ },
755
+ turn: (turn) => {
756
+ // New words, not a checkpoint of the same ones. A cough or a sigh is not words:
757
+ // it neither holds the line open nor holds an update back
758
+ const words = turn.role === "tool" ? "" : spokenWords(turn.text);
759
+ const fresh =
760
+ Boolean(words) &&
761
+ words !== spokenWords(turns.get(turn.id)?.text ?? "");
762
+ // New words from either side restart the relay's quiet clock
763
+ if (fresh) heard.current = Date.now();
764
+ // Her voice and backend work rewind the idle clock from activity, where an
765
+ // update she reads out is told apart; transcripts lag the audio and cannot
766
+ if (fresh && turn.role === "user") stirred.current = Date.now();
767
+ // Tool turns are saved but not shown. Hanging up clears the screen
768
+ // before `close()` checkpoints open groups, so only the live call draws.
769
+ if (turn.role !== "tool" && callId.current === line.callId) {
770
+ turns.set(turn.id, {
771
+ seq: turn.seq,
772
+ id: turn.id,
773
+ role: turn.role,
774
+ text: turn.text,
775
+ });
776
+ setMessages(
777
+ [...turns.values()]
778
+ .sort((a, b) => a.seq - b.seq)
779
+ .slice(-KEEP_MESSAGES),
780
+ );
781
+ }
782
+ if (turn.done) {
783
+ persist(saving, () =>
784
+ saveTurnsAction(line.callId, [
785
+ {
786
+ id: turn.id,
787
+ role: turn.role,
788
+ tool: turn.tool,
789
+ text: turn.text,
790
+ // Live orders by audio milliseconds; the row keeps a whole number
791
+ seq: Math.max(0, Math.round(turn.seq)),
792
+ fragments: turn.fragments ?? null,
793
+ },
794
+ ]),
795
+ );
796
+ }
797
+ },
798
+ // session facts drive the face through showFace; the tool line is drawn by the tool itself
799
+ activity: (activity) => {
800
+ showFace(statusOf(activity));
801
+ const busy = activity.working || activity.tools.length > 0;
802
+ acting.current = busy;
803
+ // From the moment the backend picks the turn up until her first word
804
+ holdThinking(busy, activity.speaking);
805
+ if (activity.speaking) voiced.current = Date.now();
806
+ // Sound on the mic alone does not rewind the clock: a noisy room would
807
+ // keep a call open forever. Her words and backend work do, except an
808
+ // update she voices on her own: waiting results must not hold a call open.
809
+ if (
810
+ (activity.speaking && !reading.current.on) ||
811
+ activity.working ||
812
+ activity.tools.length
813
+ )
814
+ stirred.current = Date.now();
815
+ // An update counts as voiced once her voice has started and stopped
816
+ if (reading.current.on) {
817
+ if (activity.speaking) reading.current.spoke = true;
818
+ else if (reading.current.spoke) doneReading();
738
819
  }
739
- callId.current = handshake.callId;
740
- line.callId = handshake.callId;
741
- line.opening = handshake.opening;
742
- return handshake.sdp;
743
820
  },
744
- audio: tap.current,
745
- on: {
746
- // the backend calls tools; the page forwards them to the server
747
- runTool: async (call) => {
748
- // end_call is the page's only tool. The line goes down once her
749
- // goodbye is over (leave), so this reply reaches the model first
750
- if (call.name === TOOL_NAMES.end_call) {
751
- leave();
752
- return "Ending the call.";
753
- }
754
- showTool(call);
755
- try {
756
- return await runRemoteTool(line.callId, call, stop.signal);
757
- } finally {
758
- hideTool(call.id);
759
- }
760
- },
761
- reasoning: (part) =>
762
- persist(
763
- thinkingSaves,
764
- () =>
765
- saveThoughtAction(line.callId, {
766
- ...part,
767
- seq: Math.max(0, Math.round(part.seq)),
768
- }),
769
- "The backend's thinking is not being saved",
770
- ),
771
- turn: (turn) => {
772
- // New words, not a checkpoint of the same ones: a blank fragment is not words
773
- const fresh =
774
- turn.role !== "tool" &&
775
- Boolean(turn.text.trim()) &&
776
- turn.text !== turns.get(turn.id)?.text;
777
- // New words from either side restart the relay's quiet clock
778
- if (fresh) heard.current = Date.now();
779
- // Her voice and backend work rewind the idle clock from activity, where an
780
- // update she reads out is told apart; transcripts lag the audio and cannot
781
- if (fresh && turn.role === "user") {
782
- const said = userSaid.get(turn.id) ?? Date.now();
783
- userSaid.set(turn.id, said);
784
- stir("user", said);
785
- }
786
- // Tool turns are saved but not shown. Hanging up clears the screen
787
- // before `close()` checkpoints open groups, so only the live call draws.
788
- if (turn.role !== "tool" && callId.current === line.callId) {
789
- turns.set(turn.id, {
790
- seq: turn.seq,
791
- id: turn.id,
792
- role: turn.role,
793
- text: turn.text,
794
- });
795
- setMessages(
796
- [...turns.values()]
797
- .sort((a, b) => a.seq - b.seq)
798
- .slice(-KEEP_MESSAGES),
799
- );
800
- }
801
- if (turn.done) {
802
- persist(saving, () =>
803
- saveTurnsAction(line.callId, [
804
- {
805
- id: turn.id,
806
- role: turn.role,
807
- tool: turn.tool,
808
- text: turn.text,
809
- // Live orders by audio milliseconds; the row keeps a whole number
810
- seq: Math.max(0, Math.round(turn.seq)),
811
- fragments: turn.fragments ?? null,
812
- },
813
- ]),
814
- );
815
- }
816
- },
817
- // session facts drive the face through showFace; the tool line is drawn by the tool itself
818
- activity: (activity) => {
819
- showFace(statusOf(activity));
820
- const busy = activity.working || activity.tools.length > 0;
821
- acting.current = busy;
822
- // From the moment the backend picks the turn up until her first word
823
- holdThinking(busy, activity.speaking);
824
- if (activity.speaking) voiced.current = Date.now();
825
- // Sound on the mic alone does not rewind the clock: a noisy room would
826
- // keep a call open forever. Her words and backend work do, except an
827
- // update she voices on her own: waiting results must not hold a call open.
828
- if (
829
- (activity.speaking && !reading.current.on) ||
830
- activity.working ||
831
- activity.tools.length
832
- )
833
- stir("agent");
834
- // An update counts as voiced once her voice has started and stopped
835
- if (reading.current.on) {
836
- if (activity.speaking) reading.current.spoke = true;
837
- else if (reading.current.spoke) doneReading();
838
- }
839
- },
840
- finalized: (close) => {
841
- finalized.current = close;
842
- },
843
- warn: (description) =>
844
- toast.add({
845
- type: "warning",
846
- title: "Call warning",
847
- description,
848
- }),
849
- failed: (description) => {
850
- toast.add({ type: "error", title: "Call failed", description });
851
- showFailed(true);
852
- void hangUp();
853
- },
821
+ finalized: (close) => {
822
+ finalized.current = close;
854
823
  },
855
- });
824
+ warn: (description) =>
825
+ toast.add({
826
+ type: "warning",
827
+ title: "Call warning",
828
+ description,
829
+ }),
830
+ failed: (description) => {
831
+ toast.add({ type: "error", title: "Call failed", description });
832
+ showFailed(true);
833
+ // Live says session.closed (finalized) when it closes the call itself
834
+ void hangUp(finalized.current ? "closed" : "dropped");
835
+ },
836
+ },
837
+ });
856
838
 
857
- if (!current()) {
858
- // Hung up while the line was going up; hangUp already ended the row
859
- await live.close();
860
- return;
861
- }
839
+ if (!current()) {
840
+ // Hung up while the line was going up; hangUp already ended the row
841
+ await live.close();
842
+ return;
843
+ }
862
844
 
863
- /** An opening that never landed leaves nothing to wait for. */
864
- const unless = (delivered: boolean) => {
865
- if (!delivered) doneReading();
866
- };
867
-
868
- session.current = live;
869
- opening.current = false;
870
- rang.current = calledBack;
871
- // Context is not gated: held lines first, then each as it comes
872
- outbox.open((text) => void live.append("thinking", text));
873
- // The quiet clock starts with the line, so nothing is put to her the moment it opens
874
- heard.current = Date.now();
875
-
876
- if (line.opening) {
877
- // The greeting goes first; open work waits until she has said it
878
- readAloud();
879
- void live.append("instructions", line.opening).then(unless);
880
- }
845
+ /** An opening that never landed leaves nothing to wait for. */
846
+ const unless = (delivered: boolean) => {
847
+ if (!delivered) doneReading();
848
+ };
881
849
 
882
- wearFace("listening");
883
- setSince(Date.now());
884
- // a rejected chime is not worth a message
885
- void chime.play().catch(() => {});
886
- } catch (cause) {
887
- // Hanging up while the line was going up is not a failure to report
888
- if (current()) {
889
- toast.add({
890
- type: "error",
891
- title: "Could not start the call",
892
- description: errorToString(cause),
893
- });
894
- showFailed(true);
895
- }
896
- if (attempt.current !== mine) return;
897
- // a call that never opened still has a row; close it
898
- if (callId.current) void endCallAction(callId.current);
899
- callId.current = null;
900
- opening.current = false;
901
- calling.current = false;
902
- working.current = null;
903
- outbox.clear();
904
- setStatus("idle");
850
+ session.current = live;
851
+ opening.current = false;
852
+ rang.current = calledBack;
853
+ // Context is not gated: held lines first, then each as it comes
854
+ outbox.open((text) => void live.append("thinking", text));
855
+ // The quiet clock starts with the line, so nothing is put to her the moment it opens
856
+ heard.current = Date.now();
857
+
858
+ if (line.opening) {
859
+ // The greeting goes first; open work waits until she has said it
860
+ readAloud();
861
+ void live.append("instructions", line.opening).then(unless);
905
862
  }
906
- },
907
- [
908
- status,
909
- hangUp,
910
- showTool,
911
- hideTool,
912
- showFace,
913
- wearFace,
914
- stir,
915
- outbox,
916
- readAloud,
917
- doneReading,
918
- holdThinking,
919
- showFailed,
920
- leave,
921
- ],
922
- );
863
+
864
+ wearFace("listening");
865
+ setSince(Date.now());
866
+ // a rejected chime is not worth a message
867
+ void chime.play().catch(() => {});
868
+ } catch (cause) {
869
+ // Hanging up while the line was going up is not a failure to report
870
+ if (current()) {
871
+ toast.add({
872
+ type: "error",
873
+ title: "Could not start the call",
874
+ description: errorToString(cause),
875
+ });
876
+ showFailed(true);
877
+ }
878
+ if (attempt.current !== mine) return;
879
+ // a call that never opened still has a row; close it
880
+ if (callId.current) void endCallAction(callId.current);
881
+ callId.current = null;
882
+ opening.current = false;
883
+ calling.current = false;
884
+ working.current = null;
885
+ outbox.clear();
886
+ setStatus("idle");
887
+ }
888
+ }, [
889
+ status,
890
+ hangUp,
891
+ showTool,
892
+ hideTool,
893
+ showFace,
894
+ wearFace,
895
+ outbox,
896
+ readAloud,
897
+ doneReading,
898
+ holdThinking,
899
+ showFailed,
900
+ leave,
901
+ isRinging,
902
+ ]);
923
903
 
924
904
  /**
925
- * Call-back: opens a call when a thread is waiting on the user and no line is
926
- * open. Tried once per thread (`woke`), or a failed open would redial forever;
927
- * threads present at app start count as already tried.
905
+ * Call-back: rings when a thread is waiting on the user and no line is open,
906
+ * only for threads that changed after `ringAfter`. What came up during a call
907
+ * was that call's to tell, and the next call tells what it left unsaid. Ringing
908
+ * never opens the line; answering does (`call`). A thread handled meanwhile, on
909
+ * screen or by the setting going off, stops ringing for itself.
928
910
  */
929
911
  const callBack = useThursdayStore((state) => state.callBack);
930
- const woke = useRef<Set<string> | null>(null);
931
912
  useEffect(() => {
932
913
  if (!threads) return;
933
- const wants = threads.filter(
914
+ const wanted = new Set(
915
+ threads
916
+ .filter((thread) => ringsFor(thread, callBack))
917
+ .map((thread) => thread.id),
918
+ );
919
+ setRingingFor((ids) =>
920
+ ids.every((id) => wanted.has(id))
921
+ ? ids
922
+ : ids.filter((id) => wanted.has(id)),
923
+ );
924
+ if (status !== "idle") return;
925
+ const after = ringAfter.current;
926
+ const fresh = threads.filter(
934
927
  (thread) =>
935
- !thread.seen &&
936
- !thread.ask?.auto &&
937
- (asksSomething(thread) ||
938
- (callBack === "any" && thread.status !== "running")),
928
+ wanted.has(thread.id) && toDate(thread.updatedAt).getTime() > after,
939
929
  );
930
+ if (!fresh.length) return;
940
931
 
941
- if (!woke.current) {
942
- woke.current = new Set(wants.map((thread) => thread.id));
943
- return;
944
- }
945
-
946
- // forget threads that stopped waiting; a later stop counts as fresh
947
- const open = new Set(wants.map((thread) => thread.id));
948
- for (const id of woke.current) {
949
- if (!open.has(id)) woke.current.delete(id);
950
- }
932
+ ringAfter.current = Date.now();
933
+ setRingingFor((ids) => [
934
+ ...ids,
935
+ ...fresh.map((thread) => thread.id).filter((id) => !ids.includes(id)),
936
+ ]);
937
+ }, [threads, callBack, status]);
951
938
 
952
- if (callBack === "off" || status !== "idle") return;
953
- const fresh = wants.filter((thread) => !woke.current?.has(thread.id));
954
- if (!fresh.length) return;
939
+ // While ringing: it stops by itself after CALL_BACK.ringMs, and Esc declines.
940
+ // A thread added to a ring already going does not restart the clock
941
+ useEffect(() => {
942
+ if (!isRinging) return;
943
+ const out = setTimeout(() => setRingingFor([]), CALL_BACK.ringMs);
944
+ const onKeyDown = (event: KeyboardEvent) => {
945
+ if (event.key === "Escape" && !event.defaultPrevented) setRingingFor([]);
946
+ };
947
+ window.addEventListener("keydown", onKeyDown);
948
+ return () => {
949
+ clearTimeout(out);
950
+ window.removeEventListener("keydown", onKeyDown);
951
+ };
952
+ }, [isRinging]);
955
953
 
956
- for (const thread of wants) woke.current.add(thread.id);
957
- void call("callBack");
958
- }, [threads, callBack, status, call]);
954
+ const decline = useCallback(() => setRingingFor([]), []);
955
+ /** What the ringing screen names: the first thread that rang, and how many rang with it. */
956
+ const ringing = useMemo((): Ringing | null => {
957
+ const rung = ringingFor.flatMap(
958
+ (id) => threads?.find((thread) => thread.id === id) ?? [],
959
+ );
960
+ const first = rung[0];
961
+ if (!first) return null;
962
+ const question = first.room.questions[0];
963
+ return {
964
+ bot: question?.bot ?? first.bot,
965
+ kind: question
966
+ ? "question"
967
+ : first.status === "done"
968
+ ? "done"
969
+ : "stopped",
970
+ label: first.label,
971
+ more: rung.length - 1,
972
+ };
973
+ }, [ringingFor, threads]);
959
974
 
960
975
  /** Read by the face once per animation frame, outside React state. */
961
976
  const getSpectrum = useCallback(() => tap.current?.read() ?? EMPTY_BANDS, []);
@@ -993,13 +1008,23 @@ export function useThursday() {
993
1008
  failed,
994
1009
  messages,
995
1010
  tool,
1011
+ /** Why the last call ended, when the user did not end it; null during a call. */
1012
+ ended,
996
1013
  /** When the backend picked the turn up (ms); null when it is not working. */
997
1014
  thinkingSince,
1015
+ /** What the backend's latest reasoning summary says it is doing; null before one arrives. */
1016
+ thinkingTitle,
1017
+ /** The word `emote` last put on the face; null before one. */
1018
+ faceWord,
998
1019
  /** Seconds until idle hang-up; null outside the warning window. */
999
1020
  idleLeft,
1000
1021
  /** When the line opened (ms); null without a call. */
1001
1022
  since,
1002
1023
  call,
1024
+ /** A call-back ringing; null when none is. Any way of placing a call answers it. */
1025
+ ringing,
1026
+ /** Stops the ringing without answering. */
1027
+ decline,
1003
1028
  getSpectrum,
1004
1029
  getMicSpectrum,
1005
1030
  /** null when the tap is the only entry point. */
@@ -1011,6 +1036,36 @@ export function useThursday() {
1011
1036
 
1012
1037
  const EMPTY_BANDS = new Array<number>(SPECTRUM_BANDS).fill(0);
1013
1038
 
1039
+ /** What `emote` asked the face to show, and the line the model reads back; `word` is null when nothing is shown. */
1040
+ function readFaceWord(args: string): { word: string | null; reply: string } {
1041
+ let text = "";
1042
+ try {
1043
+ const parsed: unknown = JSON.parse(args);
1044
+ if (parsed && typeof parsed === "object" && "text" in parsed) {
1045
+ text = String(parsed.text ?? "").trim();
1046
+ }
1047
+ } catch {
1048
+ text = "";
1049
+ }
1050
+ const length = Array.from(text).length;
1051
+ if (!length)
1052
+ return { word: null, reply: "Nothing was shown: text is empty." };
1053
+ if (length > FACE_WORD_MAX) {
1054
+ return {
1055
+ word: null,
1056
+ reply: `Too long: ${length} characters, ${FACE_WORD_MAX} at most. Nothing was shown.`,
1057
+ };
1058
+ }
1059
+ const bad = undrawable(text);
1060
+ if (bad) {
1061
+ return {
1062
+ word: null,
1063
+ reply: `Cannot draw "${bad}"; use only the characters \`text\` lists. Nothing was shown.`,
1064
+ };
1065
+ }
1066
+ return { word: text.toUpperCase(), reply: "Shown." };
1067
+ }
1068
+
1014
1069
  /**
1015
1070
  * Calls opened without a gesture (wake word, call-back) get a suspended
1016
1071
  * AudioContext. Resume on the next pointer or key event and say so on screen.
@@ -1052,6 +1107,15 @@ function statusOf(activity: LiveActivity): LiveStatus {
1052
1107
  return "listening";
1053
1108
  }
1054
1109
 
1110
+ /**
1111
+ * The words in a transcript, without the sounds Live writes in brackets ("[clear throat]",
1112
+ * "[sigh]"); a bracket may close in the next group ("] okay"). Empty when nothing was said.
1113
+ */
1114
+ function spokenWords(text: string) {
1115
+ const words = text.replace(/\[[^\]]*\]?|\]/g, "").trim();
1116
+ return /[\p{L}\p{N}]/u.test(words) ? words : "";
1117
+ }
1118
+
1055
1119
  /** Fire-and-forget save; after SAVE_FAILURE_LIMIT failures of this kind it stops and says so once. */
1056
1120
  function persist(
1057
1121
  saving: { failures: number },
@@ -1073,10 +1137,15 @@ function persist(
1073
1137
  }
1074
1138
 
1075
1139
  /**
1076
- * Waiting on a real question. A job the app stopped waits in the inbox
1077
- * instead of ringing.
1140
+ * Whether a thread is one the call-back rings for: a real question, or with
1141
+ * "any" every ending. A job the app stops and picks up by itself never rings.
1078
1142
  */
1079
- const asksSomething = (thread: Thread) => thread.room.questions.length > 0;
1143
+ const ringsFor = (thread: Thread, callBack: CallBack) =>
1144
+ callBack !== "off" &&
1145
+ !thread.seen &&
1146
+ !thread.ask?.auto &&
1147
+ (thread.room.questions.length > 0 ||
1148
+ (callBack === "any" && thread.status !== "running"));
1080
1149
 
1081
1150
  /** One piece of background work waiting on the user, as the relay clock puts it to her. */
1082
1151
  type OpenWork = {