@komaa/standin-sdk 0.1.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 (269) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +141 -0
  3. package/dist/ambient.d.ts +80 -0
  4. package/dist/ambient.d.ts.map +1 -0
  5. package/dist/ambient.js +281 -0
  6. package/dist/ambient.js.map +1 -0
  7. package/dist/attachments.d.ts +149 -0
  8. package/dist/attachments.d.ts.map +1 -0
  9. package/dist/attachments.js +451 -0
  10. package/dist/attachments.js.map +1 -0
  11. package/dist/audio.d.ts +80 -0
  12. package/dist/audio.d.ts.map +1 -0
  13. package/dist/audio.js +170 -0
  14. package/dist/audio.js.map +1 -0
  15. package/dist/avatar.d.ts +111 -0
  16. package/dist/avatar.d.ts.map +1 -0
  17. package/dist/avatar.js +201 -0
  18. package/dist/avatar.js.map +1 -0
  19. package/dist/callServer.d.ts +151 -0
  20. package/dist/callServer.d.ts.map +1 -0
  21. package/dist/callServer.js +1097 -0
  22. package/dist/callServer.js.map +1 -0
  23. package/dist/callTools.d.ts +154 -0
  24. package/dist/callTools.d.ts.map +1 -0
  25. package/dist/callTools.js +255 -0
  26. package/dist/callTools.js.map +1 -0
  27. package/dist/chat.d.ts +226 -0
  28. package/dist/chat.d.ts.map +1 -0
  29. package/dist/chat.js +574 -0
  30. package/dist/chat.js.map +1 -0
  31. package/dist/config.d.ts +32 -0
  32. package/dist/config.d.ts.map +1 -0
  33. package/dist/config.js +86 -0
  34. package/dist/config.js.map +1 -0
  35. package/dist/consult.d.ts +147 -0
  36. package/dist/consult.d.ts.map +1 -0
  37. package/dist/consult.js +388 -0
  38. package/dist/consult.js.map +1 -0
  39. package/dist/delivery.d.ts +81 -0
  40. package/dist/delivery.d.ts.map +1 -0
  41. package/dist/delivery.js +187 -0
  42. package/dist/delivery.js.map +1 -0
  43. package/dist/echoGuard.d.ts +56 -0
  44. package/dist/echoGuard.d.ts.map +1 -0
  45. package/dist/echoGuard.js +57 -0
  46. package/dist/echoGuard.js.map +1 -0
  47. package/dist/errors.d.ts +5 -0
  48. package/dist/errors.d.ts.map +1 -0
  49. package/dist/errors.js +10 -0
  50. package/dist/errors.js.map +1 -0
  51. package/dist/fetch.d.ts +40 -0
  52. package/dist/fetch.d.ts.map +1 -0
  53. package/dist/fetch.js +240 -0
  54. package/dist/fetch.js.map +1 -0
  55. package/dist/gate.d.ts +80 -0
  56. package/dist/gate.d.ts.map +1 -0
  57. package/dist/gate.js +249 -0
  58. package/dist/gate.js.map +1 -0
  59. package/dist/handler.d.ts +283 -0
  60. package/dist/handler.d.ts.map +1 -0
  61. package/dist/handler.js +4 -0
  62. package/dist/handler.js.map +1 -0
  63. package/dist/hmac.d.ts +32 -0
  64. package/dist/hmac.d.ts.map +1 -0
  65. package/dist/hmac.js +108 -0
  66. package/dist/hmac.js.map +1 -0
  67. package/dist/index.d.ts +59 -0
  68. package/dist/index.d.ts.map +1 -0
  69. package/dist/index.js +60 -0
  70. package/dist/index.js.map +1 -0
  71. package/dist/lane.d.ts +92 -0
  72. package/dist/lane.d.ts.map +1 -0
  73. package/dist/lane.js +269 -0
  74. package/dist/lane.js.map +1 -0
  75. package/dist/lipsync.d.ts +136 -0
  76. package/dist/lipsync.d.ts.map +1 -0
  77. package/dist/lipsync.js +310 -0
  78. package/dist/lipsync.js.map +1 -0
  79. package/dist/log.d.ts +18 -0
  80. package/dist/log.d.ts.map +1 -0
  81. package/dist/log.js +20 -0
  82. package/dist/log.js.map +1 -0
  83. package/dist/media.d.ts +43 -0
  84. package/dist/media.d.ts.map +1 -0
  85. package/dist/media.js +190 -0
  86. package/dist/media.js.map +1 -0
  87. package/dist/minutes.d.ts +398 -0
  88. package/dist/minutes.d.ts.map +1 -0
  89. package/dist/minutes.js +805 -0
  90. package/dist/minutes.js.map +1 -0
  91. package/dist/outbound.d.ts +388 -0
  92. package/dist/outbound.d.ts.map +1 -0
  93. package/dist/outbound.js +933 -0
  94. package/dist/outbound.js.map +1 -0
  95. package/dist/plugins/cartesia/agent.d.ts +58 -0
  96. package/dist/plugins/cartesia/agent.d.ts.map +1 -0
  97. package/dist/plugins/cartesia/agent.js +254 -0
  98. package/dist/plugins/cartesia/agent.js.map +1 -0
  99. package/dist/plugins/cartesia/cli.d.ts +3 -0
  100. package/dist/plugins/cartesia/cli.d.ts.map +1 -0
  101. package/dist/plugins/cartesia/cli.js +10 -0
  102. package/dist/plugins/cartesia/cli.js.map +1 -0
  103. package/dist/plugins/cartesia/config.d.ts +30 -0
  104. package/dist/plugins/cartesia/config.d.ts.map +1 -0
  105. package/dist/plugins/cartesia/config.js +29 -0
  106. package/dist/plugins/cartesia/config.js.map +1 -0
  107. package/dist/plugins/cartesia/handler.d.ts +40 -0
  108. package/dist/plugins/cartesia/handler.d.ts.map +1 -0
  109. package/dist/plugins/cartesia/handler.js +142 -0
  110. package/dist/plugins/cartesia/handler.js.map +1 -0
  111. package/dist/plugins/cartesia/index.d.ts +6 -0
  112. package/dist/plugins/cartesia/index.d.ts.map +1 -0
  113. package/dist/plugins/cartesia/index.js +54 -0
  114. package/dist/plugins/cartesia/index.js.map +1 -0
  115. package/dist/plugins/deepgram/agent.d.ts +60 -0
  116. package/dist/plugins/deepgram/agent.d.ts.map +1 -0
  117. package/dist/plugins/deepgram/agent.js +302 -0
  118. package/dist/plugins/deepgram/agent.js.map +1 -0
  119. package/dist/plugins/deepgram/cli.d.ts +3 -0
  120. package/dist/plugins/deepgram/cli.d.ts.map +1 -0
  121. package/dist/plugins/deepgram/cli.js +10 -0
  122. package/dist/plugins/deepgram/cli.js.map +1 -0
  123. package/dist/plugins/deepgram/config.d.ts +39 -0
  124. package/dist/plugins/deepgram/config.d.ts.map +1 -0
  125. package/dist/plugins/deepgram/config.js +66 -0
  126. package/dist/plugins/deepgram/config.js.map +1 -0
  127. package/dist/plugins/deepgram/handler.d.ts +87 -0
  128. package/dist/plugins/deepgram/handler.d.ts.map +1 -0
  129. package/dist/plugins/deepgram/handler.js +268 -0
  130. package/dist/plugins/deepgram/handler.js.map +1 -0
  131. package/dist/plugins/deepgram/index.d.ts +11 -0
  132. package/dist/plugins/deepgram/index.d.ts.map +1 -0
  133. package/dist/plugins/deepgram/index.js +76 -0
  134. package/dist/plugins/deepgram/index.js.map +1 -0
  135. package/dist/plugins/echo/cli.d.ts +3 -0
  136. package/dist/plugins/echo/cli.d.ts.map +1 -0
  137. package/dist/plugins/echo/cli.js +10 -0
  138. package/dist/plugins/echo/cli.js.map +1 -0
  139. package/dist/plugins/echo/index.d.ts +37 -0
  140. package/dist/plugins/echo/index.d.ts.map +1 -0
  141. package/dist/plugins/echo/index.js +61 -0
  142. package/dist/plugins/echo/index.js.map +1 -0
  143. package/dist/plugins/elevenlabs/agent.d.ts +72 -0
  144. package/dist/plugins/elevenlabs/agent.d.ts.map +1 -0
  145. package/dist/plugins/elevenlabs/agent.js +305 -0
  146. package/dist/plugins/elevenlabs/agent.js.map +1 -0
  147. package/dist/plugins/elevenlabs/cli.d.ts +3 -0
  148. package/dist/plugins/elevenlabs/cli.d.ts.map +1 -0
  149. package/dist/plugins/elevenlabs/cli.js +10 -0
  150. package/dist/plugins/elevenlabs/cli.js.map +1 -0
  151. package/dist/plugins/elevenlabs/config.d.ts +27 -0
  152. package/dist/plugins/elevenlabs/config.d.ts.map +1 -0
  153. package/dist/plugins/elevenlabs/config.js +42 -0
  154. package/dist/plugins/elevenlabs/config.js.map +1 -0
  155. package/dist/plugins/elevenlabs/handler.d.ts +67 -0
  156. package/dist/plugins/elevenlabs/handler.d.ts.map +1 -0
  157. package/dist/plugins/elevenlabs/handler.js +419 -0
  158. package/dist/plugins/elevenlabs/handler.js.map +1 -0
  159. package/dist/plugins/elevenlabs/index.d.ts +11 -0
  160. package/dist/plugins/elevenlabs/index.d.ts.map +1 -0
  161. package/dist/plugins/elevenlabs/index.js +72 -0
  162. package/dist/plugins/elevenlabs/index.js.map +1 -0
  163. package/dist/plugins/livekit/cli.d.ts +3 -0
  164. package/dist/plugins/livekit/cli.d.ts.map +1 -0
  165. package/dist/plugins/livekit/cli.js +10 -0
  166. package/dist/plugins/livekit/cli.js.map +1 -0
  167. package/dist/plugins/livekit/config.d.ts +47 -0
  168. package/dist/plugins/livekit/config.d.ts.map +1 -0
  169. package/dist/plugins/livekit/config.js +46 -0
  170. package/dist/plugins/livekit/config.js.map +1 -0
  171. package/dist/plugins/livekit/handler.d.ts +59 -0
  172. package/dist/plugins/livekit/handler.d.ts.map +1 -0
  173. package/dist/plugins/livekit/handler.js +211 -0
  174. package/dist/plugins/livekit/handler.js.map +1 -0
  175. package/dist/plugins/livekit/index.d.ts +6 -0
  176. package/dist/plugins/livekit/index.d.ts.map +1 -0
  177. package/dist/plugins/livekit/index.js +68 -0
  178. package/dist/plugins/livekit/index.js.map +1 -0
  179. package/dist/plugins/livekit/room.d.ts +117 -0
  180. package/dist/plugins/livekit/room.d.ts.map +1 -0
  181. package/dist/plugins/livekit/room.js +449 -0
  182. package/dist/plugins/livekit/room.js.map +1 -0
  183. package/dist/plugins/openai/agent.d.ts +74 -0
  184. package/dist/plugins/openai/agent.d.ts.map +1 -0
  185. package/dist/plugins/openai/agent.js +264 -0
  186. package/dist/plugins/openai/agent.js.map +1 -0
  187. package/dist/plugins/openai/cli.d.ts +3 -0
  188. package/dist/plugins/openai/cli.d.ts.map +1 -0
  189. package/dist/plugins/openai/cli.js +10 -0
  190. package/dist/plugins/openai/cli.js.map +1 -0
  191. package/dist/plugins/openai/config.d.ts +29 -0
  192. package/dist/plugins/openai/config.d.ts.map +1 -0
  193. package/dist/plugins/openai/config.js +37 -0
  194. package/dist/plugins/openai/config.js.map +1 -0
  195. package/dist/plugins/openai/handler.d.ts +65 -0
  196. package/dist/plugins/openai/handler.d.ts.map +1 -0
  197. package/dist/plugins/openai/handler.js +297 -0
  198. package/dist/plugins/openai/handler.js.map +1 -0
  199. package/dist/plugins/openai/index.d.ts +6 -0
  200. package/dist/plugins/openai/index.d.ts.map +1 -0
  201. package/dist/plugins/openai/index.js +72 -0
  202. package/dist/plugins/openai/index.js.map +1 -0
  203. package/dist/plugins/openclaw/allowlist.d.ts +9 -0
  204. package/dist/plugins/openclaw/allowlist.d.ts.map +1 -0
  205. package/dist/plugins/openclaw/allowlist.js +11 -0
  206. package/dist/plugins/openclaw/allowlist.js.map +1 -0
  207. package/dist/plugins/openclaw/config.d.ts +50 -0
  208. package/dist/plugins/openclaw/config.d.ts.map +1 -0
  209. package/dist/plugins/openclaw/config.js +53 -0
  210. package/dist/plugins/openclaw/config.js.map +1 -0
  211. package/dist/plugins/openclaw/handler.d.ts +90 -0
  212. package/dist/plugins/openclaw/handler.d.ts.map +1 -0
  213. package/dist/plugins/openclaw/handler.js +146 -0
  214. package/dist/plugins/openclaw/handler.js.map +1 -0
  215. package/dist/plugins/openclaw/index.d.ts +55 -0
  216. package/dist/plugins/openclaw/index.d.ts.map +1 -0
  217. package/dist/plugins/openclaw/index.js +89 -0
  218. package/dist/plugins/openclaw/index.js.map +1 -0
  219. package/dist/plugins/openclaw/openclaw.plugin.json +74 -0
  220. package/dist/plugins/openclaw/package.json +14 -0
  221. package/dist/plugins/openclaw/realtime.d.ts +82 -0
  222. package/dist/plugins/openclaw/realtime.d.ts.map +1 -0
  223. package/dist/plugins/openclaw/realtime.js +238 -0
  224. package/dist/plugins/openclaw/realtime.js.map +1 -0
  225. package/dist/plugins/openclaw/runtime.d.ts +19 -0
  226. package/dist/plugins/openclaw/runtime.d.ts.map +1 -0
  227. package/dist/plugins/openclaw/runtime.js +160 -0
  228. package/dist/plugins/openclaw/runtime.js.map +1 -0
  229. package/dist/policy.d.ts +38 -0
  230. package/dist/policy.d.ts.map +1 -0
  231. package/dist/policy.js +95 -0
  232. package/dist/policy.js.map +1 -0
  233. package/dist/protocol.d.ts +44 -0
  234. package/dist/protocol.d.ts.map +1 -0
  235. package/dist/protocol.js +77 -0
  236. package/dist/protocol.js.map +1 -0
  237. package/dist/protocolRuntime.d.ts +19 -0
  238. package/dist/protocolRuntime.d.ts.map +1 -0
  239. package/dist/protocolRuntime.js +78 -0
  240. package/dist/protocolRuntime.js.map +1 -0
  241. package/dist/smoke.d.ts +65 -0
  242. package/dist/smoke.d.ts.map +1 -0
  243. package/dist/smoke.js +293 -0
  244. package/dist/smoke.js.map +1 -0
  245. package/dist/startup.d.ts +74 -0
  246. package/dist/startup.d.ts.map +1 -0
  247. package/dist/startup.js +112 -0
  248. package/dist/startup.js.map +1 -0
  249. package/dist/tile.d.ts +101 -0
  250. package/dist/tile.d.ts.map +1 -0
  251. package/dist/tile.js +195 -0
  252. package/dist/tile.js.map +1 -0
  253. package/dist/version.d.ts +2 -0
  254. package/dist/version.d.ts.map +1 -0
  255. package/dist/version.js +4 -0
  256. package/dist/version.js.map +1 -0
  257. package/dist/vision.d.ts +160 -0
  258. package/dist/vision.d.ts.map +1 -0
  259. package/dist/vision.js +301 -0
  260. package/dist/vision.js.map +1 -0
  261. package/dist/visionTools.d.ts +237 -0
  262. package/dist/visionTools.d.ts.map +1 -0
  263. package/dist/visionTools.js +578 -0
  264. package/dist/visionTools.js.map +1 -0
  265. package/dist/voice.d.ts +143 -0
  266. package/dist/voice.d.ts.map +1 -0
  267. package/dist/voice.js +403 -0
  268. package/dist/voice.js.map +1 -0
  269. package/package.json +129 -0
@@ -0,0 +1,805 @@
1
+ // Copyright (c) 2026 Komaa DigiTech
2
+ // SPDX-License-Identifier: MIT
3
+ /**
4
+ * What the meeting was about, written down after it ends.
5
+ *
6
+ * A recap is the one thing people ask an agent for that it cannot do while the
7
+ * call is happening. It needs the whole conversation, so it happens at the end,
8
+ * and by then the caller has usually gone. That shapes everything here:
9
+ *
10
+ * **The transcript is kept as it goes, and bounded.** A two-hour meeting is a
11
+ * lot of turns, and a call that holds all of them holds them in the memory of a
12
+ * process that is also carrying live audio. {@link Transcript} keeps a rolling
13
+ * window and renders the TAIL, because the end of a meeting is what the minutes
14
+ * are mostly about.
15
+ *
16
+ * **It records what was shown, not just what was said.** Every transcript-first
17
+ * recap tool on the market is blind to the screen share. This one is not,
18
+ * because your agent was on the call and could see it. That is the part worth
19
+ * having.
20
+ *
21
+ * **Nothing here throws.** A recap runs during teardown, and an exception there
22
+ * takes the teardown with it.
23
+ *
24
+ * Delivery is TEXT into the Microsoft Teams chat. The Word document is written
25
+ * to disk beside it, for whoever keeps the record. A meeting chat cannot be sent
26
+ * a file by a bot the way a person can, so a document promised into the chat
27
+ * would be a promise that quietly fails.
28
+ *
29
+ * Identical in shape to the Python SDK's `standin.minutes`.
30
+ */
31
+ import { randomUUID } from "node:crypto";
32
+ import { mkdirSync, writeFileSync } from "node:fs";
33
+ import { join } from "node:path";
34
+ import { deflateRawSync } from "node:zlib";
35
+ import { isMeetingThread } from "./gate.js";
36
+ import { logger } from "./log.js";
37
+ /**
38
+ * Turns kept. A long meeting must not grow without limit inside a process that
39
+ * is also carrying live audio.
40
+ */
41
+ export const MAX_TRANSCRIPT_TURNS = 600;
42
+ /** Things shown. Far fewer than turns, because a screen changes slowly. */
43
+ export const MAX_TRANSCRIPT_VISUALS = 60;
44
+ /**
45
+ * What the summarising model is given. The tail, not the head: the end of a
46
+ * meeting is what the minutes are mostly about.
47
+ */
48
+ export const MAX_TRANSCRIPT_CHARS = 12_000;
49
+ /**
50
+ * How long one entry may grow before the next fragment starts a new one.
51
+ *
52
+ * Streaming transcripts arrive as fragments and {@link Transcript.add} joins
53
+ * them back up. Without this cap, one long same-speaker run - an hour of a
54
+ * group call heard as a single stream - becomes one ever-growing entry that the
55
+ * entry count can never trim.
56
+ */
57
+ export const MAX_TRANSCRIPT_ENTRY_CHARS = 1000;
58
+ /**
59
+ * Entries a recap is written from. {@link MAX_TRANSCRIPT_TURNS} is the hard
60
+ * bound on what is HELD; this is the window that reaches the model, and
61
+ * {@link Transcript.render} applies it.
62
+ */
63
+ export const MAX_TRANSCRIPT_ENTRIES = 40;
64
+ /**
65
+ * Entries below which a recap is not worth running: under four turns there is
66
+ * no meeting to summarise, only a greeting.
67
+ */
68
+ export const RECAP_MIN_TURNS = 4;
69
+ /** How many of the visual observations reach the prompt. */
70
+ const VISUALS_IN_PROMPT = 30;
71
+ /**
72
+ * What was said, and what was shown, in the order it happened.
73
+ *
74
+ * The audio track records who SAID what. The visual track records who SHOWED
75
+ * what, and it is the half a transcript-first recap structurally cannot have:
76
+ * the agent was on the call and looked at the screen.
77
+ *
78
+ * Both are bounded. Feed it as the call runs:
79
+ *
80
+ * ```ts
81
+ * transcript.add(callerName, "we should push the launch to March");
82
+ * transcript.addVisual("Sara's shared screen: the Q3 revenue dashboard");
83
+ * ```
84
+ */
85
+ export class Transcript {
86
+ #turns = [];
87
+ #visuals = [];
88
+ #maxEntries;
89
+ constructor(options = {}) {
90
+ this.#maxEntries = options.maxEntries ?? MAX_TRANSCRIPT_TURNS;
91
+ }
92
+ /** What was said, oldest first. */
93
+ get turns() {
94
+ return this.#turns;
95
+ }
96
+ /** What was shown, oldest first. */
97
+ get visuals() {
98
+ return this.#visuals;
99
+ }
100
+ /**
101
+ * Record one turn. Empty text is ignored rather than recorded blank.
102
+ *
103
+ * A fragment continues the entry before it when the SAME speaker is still
104
+ * talking and the entry has room. Speech arrives in pieces, and a model fed
105
+ * half-sentences as separate turns writes minutes that read like a stutter.
106
+ *
107
+ * Merging across speakers is the case worth being careful about: every later
108
+ * person's words would be filed under the first speaker's name, which is
109
+ * worse than no attribution because it is confidently wrong. The role is
110
+ * checked with it, so the agent's own words never continue a caller's entry
111
+ * even on a call where both are recorded under one name.
112
+ */
113
+ add(speaker, text, role = "caller") {
114
+ const said = (text ?? "").trim();
115
+ if (said === "")
116
+ return;
117
+ const who = speaker || "Caller";
118
+ const last = this.#turns.length - 1;
119
+ const previous = this.#turns[last];
120
+ if (previous !== undefined &&
121
+ previous.speaker === who &&
122
+ (previous.role ?? "caller") === role) {
123
+ const merged = `${previous.text} ${said}`.trim();
124
+ if (merged.length < MAX_TRANSCRIPT_ENTRY_CHARS) {
125
+ this.#turns[last] = { speaker: who, text: merged, role };
126
+ return;
127
+ }
128
+ }
129
+ this.#turns.push({ speaker: who, text: said, role });
130
+ if (this.#turns.length > this.#maxEntries) {
131
+ // From the front: the end of a meeting is what minutes are mostly about.
132
+ this.#turns.splice(0, this.#turns.length - this.#maxEntries);
133
+ }
134
+ }
135
+ /**
136
+ * Record something shown, for example a slide or a shared screen.
137
+ *
138
+ * Consecutive repeats are collapsed. The vision lane describes whatever is on
139
+ * screen each time it is asked, and a screen that has not changed would
140
+ * otherwise fill the record with the same line.
141
+ */
142
+ addVisual(what) {
143
+ const shown = (what ?? "").trim();
144
+ if (shown === "" || this.#visuals[this.#visuals.length - 1] === shown)
145
+ return;
146
+ this.#visuals.push(shown);
147
+ if (this.#visuals.length > MAX_TRANSCRIPT_VISUALS) {
148
+ this.#visuals.splice(0, this.#visuals.length - MAX_TRANSCRIPT_VISUALS);
149
+ }
150
+ }
151
+ get empty() {
152
+ return this.#turns.length === 0 && this.#visuals.length === 0;
153
+ }
154
+ /**
155
+ * The transcript as the summarising model sees it.
156
+ *
157
+ * The last `maxEntries` entries, tailed again to `maxChars`. Both ends are
158
+ * deliberate: the recap window is small because a summary is mostly about how
159
+ * the meeting ENDED, and the character tail is what stops one long entry
160
+ * crowding out everything before it.
161
+ */
162
+ render(maxChars = MAX_TRANSCRIPT_CHARS, maxEntries = MAX_TRANSCRIPT_ENTRIES) {
163
+ const recent = maxEntries > 0 ? this.#turns.slice(-maxEntries) : this.#turns;
164
+ let body = recent.map((turn) => `${turn.speaker}: ${turn.text}`).join("\n");
165
+ if (this.#visuals.length > 0) {
166
+ const shown = this.#visuals
167
+ .slice(-VISUALS_IN_PROMPT)
168
+ .map((item) => `- ${item}`)
169
+ .join("\n");
170
+ body += `\n\n[Shared on screen during the call]\n${shown}`;
171
+ }
172
+ return body.length > maxChars ? body.slice(-maxChars) : body;
173
+ }
174
+ }
175
+ /**
176
+ * Whether somebody just asked for the meeting to be written up.
177
+ *
178
+ * Both halves are needed. "Summarise" alone is asked about a document, an email,
179
+ * or a page the agent is looking at; only paired with a word for the meeting
180
+ * itself does it mean minutes.
181
+ */
182
+ export function isSummaryRequest(text) {
183
+ const lowered = (text ?? "").toLowerCase();
184
+ const askedToWrite = [
185
+ "summarize",
186
+ "summarise",
187
+ "minutes",
188
+ "recap",
189
+ "notes",
190
+ ].some((word) => lowered.includes(word));
191
+ const aboutTheMeeting = [
192
+ "meeting",
193
+ "call",
194
+ "conversation",
195
+ "discussion",
196
+ ].some((word) => lowered.includes(word));
197
+ return askedToWrite && aboutTheMeeting;
198
+ }
199
+ /**
200
+ * Ask a model for minutes, and only minutes.
201
+ *
202
+ * The instruction not to infer what was on screen is the load-bearing one. A
203
+ * model handed "Sara shared a dashboard" will happily invent the numbers on it,
204
+ * and minutes that invent numbers are worse than minutes with a gap.
205
+ */
206
+ export function minutesPrompt(transcript) {
207
+ return ("Summarize the transcript of this Microsoft Teams meeting into concise minutes with " +
208
+ "these sections: Key Points, Decisions, Action Items (name owners where stated), and, " +
209
+ "when the transcript includes a [Shared on screen during the call] block, Presented. " +
210
+ "In Presented, list only what that block states; never infer what was on screen. " +
211
+ `Output only the minutes, briefly and factually.\n\nTranscript:\n${transcript}`);
212
+ }
213
+ /**
214
+ * The tool a model calls to write the meeting up mid-call. Registered by a
215
+ * plugin that has somewhere to post it, which is why it is not a built-in: an
216
+ * agent on a one-to-one call has no chat to post minutes into.
217
+ */
218
+ export const MINUTES_TOOL = {
219
+ name: "post_meeting_minutes",
220
+ description: "Write up the meeting so far and post the minutes to the Microsoft Teams chat. " +
221
+ "Use it when somebody asks for a summary, minutes, notes or a recap of the call.",
222
+ };
223
+ /**
224
+ * Decide where a recap should be posted, before a single token is generated.
225
+ *
226
+ * ```ts
227
+ * const target = resolveMinutesTarget({
228
+ * threadId: session.threadId,
229
+ * humanCount: session.humanCount,
230
+ * callerAadId: session.caller.aadId,
231
+ * callerChat: chats.forCaller({ callerAadId, tenantId }),
232
+ * sessionTenantId: session.tenantId,
233
+ * });
234
+ * ```
235
+ *
236
+ * A group call is minuted into the meeting it summarises. Two signals say it is
237
+ * one, and either will do: a human count of two or more, and a meeting thread
238
+ * id. The count only arrives on topologies that send a participants frame - on
239
+ * a hosted worker it stays pinned at 1 - so a count-only test sent every
240
+ * MEETING recap to the caller's private chat instead, which is the minutes of a
241
+ * group call landing in one attendee's DM. The thread id is on `session.start`
242
+ * already and needs no roster.
243
+ *
244
+ * Anything else is a 1:1 call, and the target is the caller's own chat with
245
+ * this bot, which is admitted by `PersonalChats.forCaller()` and its four
246
+ * narrowing rules. The first of those is worth restating here: a chat counts as
247
+ * personal because its SCOPE says so, never because of how its conversation id
248
+ * is spelled. A bot's personal chat is addressed `a:1...`, while `19:...` is
249
+ * precisely the group and channel shape the rule exists to exclude, so an
250
+ * id-prefix test admits nothing at all.
251
+ *
252
+ * The tenant is taken from `session.start`, then from configuration, then from
253
+ * the remembered chat's sender. All three describe the tenant this worker is
254
+ * bound to. The caller's own tenant id is deliberately not one of them and is
255
+ * not even accepted here: it describes whoever is on the phone, and for a guest
256
+ * it is foreign or absent, so addressing a conversation with it reaches into an
257
+ * organisation this worker was never bound to. It is the one plausible-looking
258
+ * source that is actively wrong.
259
+ *
260
+ * Returns undefined when there is nowhere safe to post, which is a real answer:
261
+ * a call that identifies nobody and has no thread gets no minutes rather than
262
+ * minutes in a stranger's chat.
263
+ *
264
+ * One target comes back, the best one. When a caller keeps more than one
265
+ * admissible target - the thread first, the caller's chat behind it - the rule
266
+ * for walking to the next is: advance on an HTTP 404 and on nothing else. A
267
+ * gateway posts through a stored conversation reference and holds one only for
268
+ * conversations it has seen an activity from, so a meeting joined over the
269
+ * calling path answers 404 while the caller's own chat is perfectly reachable.
270
+ * A 401 is our signing and a 5xx is the gateway, and both would fail the same
271
+ * way at the next target; 404 is also the only status that proves nothing was
272
+ * delivered, so it is the only one where trying again cannot double-post.
273
+ */
274
+ export function resolveMinutesTarget(options) {
275
+ const threadId = (options.threadId ?? "").trim();
276
+ const group = ((options.humanCount ?? 0) >= 2 || isMeetingThread(threadId)) &&
277
+ threadId !== "";
278
+ const chat = options.callerChat;
279
+ // The tenant this WORKER is bound to, in descending order of authority. The
280
+ // remembered sender's tenant is last and only ever confirms what the worker is
281
+ // already bound to.
282
+ const tenantId = (options.sessionTenantId ?? "").trim() ||
283
+ (options.configTenantId ?? "").trim() ||
284
+ (chat?.tenantId ?? "").trim();
285
+ if (group)
286
+ return { kind: "thread", conversationId: threadId, tenantId };
287
+ if (chat === undefined)
288
+ return undefined;
289
+ const callerAadId = (options.callerAadId ?? "").trim();
290
+ // The chat was admitted for one person and the call names another, so this
291
+ // refuses rather than posting one caller's minutes into another one's chat.
292
+ // The two ids arrive from different places and have to agree.
293
+ if (callerAadId !== "" && chat.aadId !== "" && callerAadId !== chat.aadId)
294
+ return undefined;
295
+ return { kind: "caller-dm", conversationId: chat.conversationId, tenantId };
296
+ }
297
+ /**
298
+ * Said in the message when a document was written but could not ride along.
299
+ *
300
+ * A chat reply carries text and cards, not files. Somebody who was told the
301
+ * minutes were coming with a document, and gets text with no explanation,
302
+ * assumes the attachment was lost in transit and goes looking for it.
303
+ */
304
+ export const DOCUMENT_NOT_ATTACHED = "(Minutes document is not attached on a StandIn managed connection - the text " +
305
+ "above is the full record.)";
306
+ /**
307
+ * Write the meeting up and post it. Never throws.
308
+ *
309
+ * This normally runs during teardown, where an exception takes the whole
310
+ * teardown with it, so every failure here comes back as a sentence instead.
311
+ *
312
+ * A call with nowhere to post is told apart from a call with nothing to say.
313
+ * Conflating them tells people their conversation did not count when it did.
314
+ *
315
+ * The target comes in already resolved, by {@link resolveMinutesTarget}, and is
316
+ * passed on to the delivery unchanged. A 1:1 call is no longer a refusal: it
317
+ * has a caller with their own chat, and that chat is a real target. Only an
318
+ * undefined target, which means nowhere safe was found, still says so out loud.
319
+ *
320
+ * Pass several targets, best first, when more than one conversation is
321
+ * admissible. The next is tried ONLY when the gateway answers 404.
322
+ */
323
+ export async function postMinutes(summarise, transcript, target, deliver, options = {}) {
324
+ if (transcript.empty) {
325
+ return {
326
+ spoken: "There was not enough of a conversation to summarize.",
327
+ minutes: "",
328
+ delivered: false,
329
+ };
330
+ }
331
+ const targets = asTargets(target);
332
+ if (targets.length === 0) {
333
+ logger.info("standin: no minutes posted; this call has no Microsoft Teams chat");
334
+ return {
335
+ spoken: "I can summarize this call, but it has no Microsoft Teams chat for me to post " +
336
+ "the minutes to.",
337
+ minutes: "",
338
+ delivered: false,
339
+ };
340
+ }
341
+ let minutes;
342
+ try {
343
+ minutes = (await summarise(minutesPrompt(transcript.render()))).trim();
344
+ }
345
+ catch (err) {
346
+ logger.warn(`standin: summarising the meeting failed: ${String(err)}`);
347
+ return {
348
+ spoken: "I could not summarize the meeting.",
349
+ minutes: "",
350
+ delivered: false,
351
+ };
352
+ }
353
+ if (minutes === "") {
354
+ return {
355
+ spoken: "I could not summarize the meeting.",
356
+ minutes: "",
357
+ delivered: false,
358
+ };
359
+ }
360
+ const document = saveDocument(minutes, transcript, options);
361
+ let body = `Meeting minutes\n\n${minutes}`;
362
+ if (document !== undefined)
363
+ body += `\n\n${DOCUMENT_NOT_ATTACHED}`;
364
+ const landed = await deliverToFirstReachable(targets, body, deliver);
365
+ return {
366
+ spoken: landed !== undefined
367
+ ? "I have posted the minutes to your Microsoft Teams chat."
368
+ : "I summarized the meeting but could not post it to the chat.",
369
+ minutes,
370
+ document,
371
+ delivered: landed !== undefined,
372
+ target: landed,
373
+ };
374
+ }
375
+ /** One target or several, as one list. */
376
+ function asTargets(target) {
377
+ if (target === undefined)
378
+ return [];
379
+ if (!Array.isArray(target))
380
+ return [target];
381
+ // A caller assembling the list from two lookups leaves a hole in it whenever
382
+ // one of them found nothing, and a hole must not reach a teardown as a thrown
383
+ // property access.
384
+ return target.filter((candidate) => candidate !== undefined && candidate !== null);
385
+ }
386
+ /**
387
+ * Whatever the send returned, in one shape.
388
+ *
389
+ * Only a real boolean is read as one, and an outcome object is never tested for
390
+ * truth: every object is truthy, so a post the gateway rejected with a 404 came
391
+ * back as delivered, and the log line written to catch exactly that said the
392
+ * minutes had been posted. An object that carries its own `ok` is asked for it
393
+ * instead, and anything else did not land.
394
+ */
395
+ function asOutcome(result) {
396
+ if (typeof result === "boolean")
397
+ return { ok: result };
398
+ const ok = result?.ok;
399
+ const status = result?.status;
400
+ return {
401
+ ok: ok === true,
402
+ status: typeof status === "number" ? status : undefined,
403
+ };
404
+ }
405
+ /**
406
+ * Post to the best target, and on a 404 only, to the next one.
407
+ *
408
+ * A gateway posts through a stored conversation reference and holds one only
409
+ * for conversations it has seen an activity from, so a meeting joined over the
410
+ * calling path answers 404 while the caller's own chat is perfectly reachable:
411
+ * stopping at the thread meant every in-meeting post failed with a good
412
+ * fallback sitting unused. A 401 is our own signing and a 5xx is the gateway,
413
+ * and both would fail identically at the next target. 404 is also the only
414
+ * answer that proves nothing was delivered, so it is the only one where trying
415
+ * again cannot post the same minutes twice.
416
+ *
417
+ * Walking the list changes WHICH already-permitted conversation receives, never
418
+ * WHO may receive: every entry was admitted by the resolver before any of this
419
+ * ran.
420
+ */
421
+ async function deliverToFirstReachable(targets, text, deliver) {
422
+ const last = targets.length - 1;
423
+ for (let index = 0; index <= last; index += 1) {
424
+ const candidate = targets[index];
425
+ let outcome;
426
+ try {
427
+ outcome = asOutcome(await deliver(candidate, text));
428
+ }
429
+ catch (err) {
430
+ logger.warn(`standin: posting the minutes failed: ${String(err)}`);
431
+ return undefined;
432
+ }
433
+ if (outcome.ok)
434
+ return candidate;
435
+ if (outcome.status !== 404 || index === last) {
436
+ logger.warn(`standin: the minutes were not posted to ${candidate.conversationId} ` +
437
+ `(status ${outcome.status ?? "unknown"})`);
438
+ return undefined;
439
+ }
440
+ logger.info(`standin: ${candidate.conversationId} cannot be reached; trying the next delivery target`);
441
+ }
442
+ return undefined;
443
+ }
444
+ /** Keep a Word copy, if somewhere was named. Never fails the recap. */
445
+ function saveDocument(minutes, transcript, options) {
446
+ const documentDir = options.documentDir;
447
+ if (documentDir === undefined)
448
+ return undefined;
449
+ try {
450
+ mkdirSync(documentDir, { recursive: true });
451
+ const path = join(documentDir, `minutes-${randomUUID().slice(0, 8)}.docx`);
452
+ writeMinutesDocx("Meeting minutes", minutes, path, {
453
+ subtitle: options.subtitle,
454
+ // The model supplies the prose and code supplies the file, so the same
455
+ // minutes always yield the same document.
456
+ sections: parseMinutesSections(minutes),
457
+ transcript: transcript.turns,
458
+ assistantLabel: options.assistantLabel,
459
+ callerLabel: options.callerLabel,
460
+ });
461
+ logger.info(`standin: minutes document saved to ${path}`);
462
+ return path;
463
+ }
464
+ catch (err) {
465
+ logger.warn(`standin: the minutes document could not be written: ${String(err)}`);
466
+ return undefined;
467
+ }
468
+ }
469
+ /**
470
+ * A heading, form one: one to six hashes FOLLOWED BY whitespace. A model writes
471
+ * "#launch" as a tag and "# Launch" as a heading, and the space is the only
472
+ * thing that tells the two apart.
473
+ */
474
+ const HEADING_HASHES = /^#{1,6}\s+(.*\S)\s*$/;
475
+ /** A heading, form two: a line that is bold end to end, colon optional. */
476
+ const HEADING_BOLD = /^\*\*(.+?)\*\*:?\s*$/;
477
+ /** Every bullet marker a model reaches for, in one expression. */
478
+ const BULLET = /^(?:[-*•]|\d+[.)])\s+(.*\S)\s*$/;
479
+ /** A marker with nothing after it. Not an item, and not a heading either. */
480
+ const BARE_MARKER = /^(?:[-*•]|\d+[.)])$/;
481
+ /** Where content that arrived before any heading is filed. */
482
+ const SYNTHETIC_HEADING = "Summary";
483
+ /**
484
+ * Read a model's markdown minutes into sections, for the document writer.
485
+ *
486
+ * Pure and total: every line of the input reaches the output, no line is
487
+ * dropped silently, and nothing here reads or writes anything.
488
+ *
489
+ * ```ts
490
+ * parseMinutesSections("### Decisions\n- the launch moves to March");
491
+ * // [{ heading: "Decisions", items: ["the launch moves to March"] }]
492
+ * ```
493
+ *
494
+ * Every form a summarising model actually emits is accepted. Asked for
495
+ * "### Key points" it returns "## Key points", "# Key points" or
496
+ * "**Key points:**" depending on the model and the day, and accepting one form
497
+ * only produced a single unheaded blob: the document still built, with every
498
+ * section break gone and nothing raised anywhere.
499
+ *
500
+ * The same goes for bullets. Models mix "- ", "* ", "• " and "1. " inside one
501
+ * answer, and often write a whole section as one prose paragraph with no bullet
502
+ * at all, so a line under a heading that carries no marker is kept as written
503
+ * rather than discarded.
504
+ *
505
+ * Content that arrives before any heading opens a section called "Summary",
506
+ * because a model that ignores the format instruction and answers in one
507
+ * paragraph would otherwise parse to nothing and produce a document with a
508
+ * title and no body.
509
+ *
510
+ * Sections with no items survive here on purpose. Omitting them is the
511
+ * DOCUMENT's job ({@link writeMinutesDocx}), which keeps this function
512
+ * round-trippable and leaves one place that decides what is worth printing.
513
+ */
514
+ export function parseMinutesSections(text) {
515
+ const sections = [];
516
+ let heading;
517
+ let items = [];
518
+ const open = (next) => {
519
+ // A repeated heading opens a second section rather than merging into the
520
+ // first: source order is the only order a reader can check against.
521
+ if (heading !== undefined)
522
+ sections.push({ heading, items });
523
+ heading = next;
524
+ items = [];
525
+ };
526
+ for (const raw of (text ?? "").split("\n")) {
527
+ const line = raw.trim();
528
+ if (line === "")
529
+ continue;
530
+ const found = HEADING_HASHES.exec(line) ?? HEADING_BOLD.exec(line);
531
+ const title = found?.[1];
532
+ if (title !== undefined) {
533
+ open(title.replace(/:$/, "").trim());
534
+ continue;
535
+ }
536
+ if (BARE_MARKER.test(line))
537
+ continue;
538
+ const item = (BULLET.exec(line)?.[1] ?? line).trim();
539
+ if (item === "")
540
+ continue;
541
+ if (heading === undefined)
542
+ open(SYNTHETIC_HEADING);
543
+ items.push(item);
544
+ }
545
+ if (heading !== undefined)
546
+ sections.push({ heading, items });
547
+ return sections;
548
+ }
549
+ /**
550
+ * Speaker attribution the text already carries: a name, a colon and a space.
551
+ *
552
+ * ```ts
553
+ * hasSpeakerPrefix("Sara: we should ship on Friday"); // true
554
+ * ```
555
+ *
556
+ * Only for the compatibility case where a caller hands in turns with the name
557
+ * baked into the text. A {@link Turn} carries its speaker in its own field,
558
+ * which is better, and needs no test.
559
+ *
560
+ * A leading colon and a leading space are both rejected, so ": ok" and
561
+ * " Sara: ok" are not mistaken for attribution.
562
+ */
563
+ export function hasSpeakerPrefix(text) {
564
+ return /^[^\s:][^:]*:\s/.test(text ?? "");
565
+ }
566
+ // ------------------------------------------------------------------ the .docx
567
+ const CONTENT_TYPES = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
568
+ '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">' +
569
+ '<Default Extension="rels" ' +
570
+ 'ContentType="application/vnd.openxmlformats-package.relationships+xml"/>' +
571
+ '<Default Extension="xml" ContentType="application/xml"/>' +
572
+ '<Override PartName="/word/document.xml" ' +
573
+ 'ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.' +
574
+ 'document.main+xml"/>' +
575
+ "</Types>";
576
+ const RELATIONSHIPS = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
577
+ '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
578
+ '<Relationship Id="rId1" ' +
579
+ 'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" ' +
580
+ 'Target="word/document.xml"/></Relationships>';
581
+ function escapeXml(text) {
582
+ // The ampersand goes first, or the four replacements after it are escaped a
583
+ // second time and the document reads "&amp;lt;".
584
+ return text
585
+ .replaceAll("&", "&amp;")
586
+ .replaceAll("<", "&lt;")
587
+ .replaceAll(">", "&gt;")
588
+ .replaceAll('"', "&quot;")
589
+ .replaceAll("'", "&apos;");
590
+ }
591
+ /**
592
+ * The document's own relationships part, with no relationships in it.
593
+ *
594
+ * Nothing in these minutes points at anything - no images, no hyperlinks, no
595
+ * styles part - but validators refuse a part that has no rels part at all, and
596
+ * a document Word repairs on open is a document nobody trusts again.
597
+ */
598
+ const DOCUMENT_RELATIONSHIPS = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
599
+ '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"/>';
600
+ /**
601
+ * A4, with margins a person would recognise.
602
+ *
603
+ * Without it Word opens the file at Letter with no margins, which is the first
604
+ * thing anyone notices about a document they were asked to keep.
605
+ */
606
+ const A4_SECTION = "<w:sectPr>" +
607
+ '<w:pgSz w:w="11906" w:h="16838"/>' +
608
+ '<w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440" ' +
609
+ 'w:header="708" w:footer="708" w:gutter="0"/>' +
610
+ "</w:sectPr>";
611
+ /** No numbering part is worth carrying for one glyph. */
612
+ const BULLET_PREFIX = "• ";
613
+ /** The transcript's own heading in the document. */
614
+ const TRANSCRIPT_HEADING = "Attributed transcript";
615
+ /**
616
+ * One paragraph. `xml:space` is on every run, or Word collapses the bullet's
617
+ * own space and every indent with it.
618
+ */
619
+ function paragraph(text, spacing = "", runProperties = "") {
620
+ const pPr = spacing === "" ? "" : `<w:pPr>${spacing}</w:pPr>`;
621
+ const rPr = runProperties === "" ? "" : `<w:rPr>${runProperties}</w:rPr>`;
622
+ return `<w:p>${pPr}<w:r>${rPr}<w:t xml:space="preserve">${escapeXml(text)}</w:t></w:r></w:p>`;
623
+ }
624
+ function titleParagraph(text) {
625
+ return paragraph(text, '<w:spacing w:after="120"/>', '<w:b/><w:sz w:val="40"/>');
626
+ }
627
+ function headingParagraph(text) {
628
+ return paragraph(text, '<w:spacing w:before="200" w:after="80"/>', '<w:b/><w:sz w:val="28"/>');
629
+ }
630
+ function bodyParagraph(text) {
631
+ return paragraph(text);
632
+ }
633
+ /**
634
+ * Write minutes to a Word-openable document, with no dependencies.
635
+ *
636
+ * ```ts
637
+ * writeMinutesDocx("Meeting minutes", minutes, path, {
638
+ * subtitle: "Call with Dana - ~12 min, 3 human participants.",
639
+ * sections: parseMinutesSections(minutes),
640
+ * transcript: transcript.turns,
641
+ * });
642
+ * ```
643
+ *
644
+ * A .docx is a zip of four XML parts, and emitting them directly is a few dozen
645
+ * lines. A document format library would be a dependency every install pays for
646
+ * so that the small fraction who ask for minutes get a file, which is the wrong
647
+ * trade for an SDK.
648
+ *
649
+ * With no options it behaves as it always has: markdown emphasis around a whole
650
+ * line becomes a bold heading, because that is what a model reaches for.
651
+ *
652
+ * A section whose items are all blank is left out entirely, heading and all. A
653
+ * bare "Decisions" over white space reads as a section the agent failed to
654
+ * fill, rather than one that had nothing in it.
655
+ */
656
+ export function writeMinutesDocx(title, minutes, path, options = {}) {
657
+ const paragraphs = [titleParagraph(title)];
658
+ if (options.subtitle)
659
+ paragraphs.push(bodyParagraph(options.subtitle));
660
+ if (options.sections === undefined) {
661
+ for (const raw of minutes.split("\n")) {
662
+ const line = raw.trim();
663
+ if (line === "")
664
+ continue;
665
+ // A line that is bold end to end is the heading a model reaches for, and
666
+ // it is set as one: a document whose headings are sized on one path and
667
+ // not on the other is two documents.
668
+ const heading = line.startsWith("**") && line.endsWith("**") && line.length > 4;
669
+ const text = line.replaceAll(/^\*+|\*+$/g, "").trim();
670
+ paragraphs.push(heading ? headingParagraph(text) : bodyParagraph(text));
671
+ }
672
+ }
673
+ else {
674
+ for (const section of options.sections) {
675
+ const items = section.items
676
+ .map((item) => (item ?? "").trim())
677
+ .filter((item) => item !== "");
678
+ if (items.length === 0)
679
+ continue;
680
+ paragraphs.push(headingParagraph(section.heading));
681
+ for (const item of items)
682
+ paragraphs.push(bodyParagraph(`${BULLET_PREFIX}${item}`));
683
+ }
684
+ }
685
+ const said = attributedTranscript(options.transcript, options.assistantLabel ?? "Assistant", options.callerLabel ?? "Caller");
686
+ if (said.length > 0) {
687
+ paragraphs.push(headingParagraph(TRANSCRIPT_HEADING));
688
+ for (const line of said)
689
+ paragraphs.push(bodyParagraph(line));
690
+ }
691
+ const document = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
692
+ '<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">' +
693
+ `<w:body>${paragraphs.join("")}${A4_SECTION}</w:body></w:document>`;
694
+ writeFileSync(path, zip([
695
+ ["[Content_Types].xml", CONTENT_TYPES],
696
+ ["_rels/.rels", RELATIONSHIPS],
697
+ ["word/document.xml", document],
698
+ ["word/_rels/document.xml.rels", DOCUMENT_RELATIONSHIPS],
699
+ ]));
700
+ }
701
+ /**
702
+ * Who said what, one line per turn.
703
+ *
704
+ * This is the half a transcript-only recap tool cannot produce: unmixed audio
705
+ * gave a real speaker per utterance, so the document can say who spoke. A turn
706
+ * that already carries its own "Name: " prefix is written exactly as it came,
707
+ * because re-labelling it would destroy the attribution and prefixing it again
708
+ * ("Caller: Sara: ...") reads as a transcription error.
709
+ */
710
+ function attributedTranscript(turns, assistantLabel, callerLabel) {
711
+ if (turns === undefined)
712
+ return [];
713
+ const lines = [];
714
+ for (const turn of turns) {
715
+ const said = (turn.text ?? "").trim();
716
+ if (said === "")
717
+ continue;
718
+ const speaker = (turn.speaker ?? "").trim();
719
+ if (turn.role === "assistant") {
720
+ lines.push(`${assistantLabel}: ${said}`);
721
+ }
722
+ else if (hasSpeakerPrefix(said)) {
723
+ lines.push(said);
724
+ }
725
+ else {
726
+ lines.push(`${speaker === "" ? callerLabel : speaker}: ${said}`);
727
+ }
728
+ }
729
+ return lines;
730
+ }
731
+ /**
732
+ * The smallest zip that Word will open: deflated entries, local headers, a
733
+ * central directory, an end record.
734
+ *
735
+ * Written out rather than reached for, because Node ships deflate but no zip
736
+ * container, and the alternative is a dependency in the base install for the
737
+ * sake of one optional document.
738
+ */
739
+ function zip(entries) {
740
+ const locals = [];
741
+ const central = [];
742
+ let offset = 0;
743
+ for (const [name, content] of entries) {
744
+ const nameBytes = Buffer.from(name, "utf8");
745
+ const raw = Buffer.from(content, "utf8");
746
+ const deflated = deflateRawSync(raw);
747
+ const crc = crc32(raw);
748
+ const local = Buffer.alloc(30);
749
+ local.writeUInt32LE(0x04034b50, 0); // local file header
750
+ local.writeUInt16LE(20, 4); // version needed
751
+ local.writeUInt16LE(0, 6); // flags
752
+ local.writeUInt16LE(8, 8); // deflate
753
+ local.writeUInt16LE(0, 10); // time: fixed, so the same minutes zip byte for byte
754
+ local.writeUInt16LE(33, 12); // date: 1980-01-01, the zip epoch
755
+ local.writeUInt32LE(crc, 14);
756
+ local.writeUInt32LE(deflated.length, 18);
757
+ local.writeUInt32LE(raw.length, 22);
758
+ local.writeUInt16LE(nameBytes.length, 26);
759
+ local.writeUInt16LE(0, 28); // no extra field
760
+ locals.push(local, nameBytes, deflated);
761
+ const entry = Buffer.alloc(46);
762
+ entry.writeUInt32LE(0x02014b50, 0); // central directory header
763
+ entry.writeUInt16LE(20, 4); // version made by
764
+ entry.writeUInt16LE(20, 6); // version needed
765
+ entry.writeUInt16LE(0, 8);
766
+ entry.writeUInt16LE(8, 10);
767
+ entry.writeUInt16LE(0, 12);
768
+ entry.writeUInt16LE(33, 14);
769
+ entry.writeUInt32LE(crc, 16);
770
+ entry.writeUInt32LE(deflated.length, 20);
771
+ entry.writeUInt32LE(raw.length, 24);
772
+ entry.writeUInt16LE(nameBytes.length, 28);
773
+ entry.writeUInt16LE(0, 30); // extra
774
+ entry.writeUInt16LE(0, 32); // comment
775
+ entry.writeUInt16LE(0, 34); // disk
776
+ entry.writeUInt16LE(0, 36); // internal attributes
777
+ entry.writeUInt32LE(0, 38); // external attributes
778
+ entry.writeUInt32LE(offset, 42);
779
+ central.push(entry, nameBytes);
780
+ offset += local.length + nameBytes.length + deflated.length;
781
+ }
782
+ const directory = Buffer.concat(central);
783
+ const end = Buffer.alloc(22);
784
+ end.writeUInt32LE(0x06054b50, 0); // end of central directory
785
+ end.writeUInt16LE(0, 4); // this disk
786
+ end.writeUInt16LE(0, 6); // directory's disk
787
+ end.writeUInt16LE(entries.length, 8);
788
+ end.writeUInt16LE(entries.length, 10);
789
+ end.writeUInt32LE(directory.length, 12);
790
+ end.writeUInt32LE(offset, 16);
791
+ end.writeUInt16LE(0, 20); // no comment
792
+ return Buffer.concat([...locals, directory, end]);
793
+ }
794
+ /** The zip checksum. A table would be faster; four small parts do not need it. */
795
+ function crc32(data) {
796
+ let crc = 0xffffffff;
797
+ for (const byte of data) {
798
+ crc ^= byte;
799
+ for (let bit = 0; bit < 8; bit += 1) {
800
+ crc = crc & 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1;
801
+ }
802
+ }
803
+ return (crc ^ 0xffffffff) >>> 0;
804
+ }
805
+ //# sourceMappingURL=minutes.js.map