@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.
- package/LICENSE +21 -0
- package/README.md +141 -0
- package/dist/ambient.d.ts +80 -0
- package/dist/ambient.d.ts.map +1 -0
- package/dist/ambient.js +281 -0
- package/dist/ambient.js.map +1 -0
- package/dist/attachments.d.ts +149 -0
- package/dist/attachments.d.ts.map +1 -0
- package/dist/attachments.js +451 -0
- package/dist/attachments.js.map +1 -0
- package/dist/audio.d.ts +80 -0
- package/dist/audio.d.ts.map +1 -0
- package/dist/audio.js +170 -0
- package/dist/audio.js.map +1 -0
- package/dist/avatar.d.ts +111 -0
- package/dist/avatar.d.ts.map +1 -0
- package/dist/avatar.js +201 -0
- package/dist/avatar.js.map +1 -0
- package/dist/callServer.d.ts +151 -0
- package/dist/callServer.d.ts.map +1 -0
- package/dist/callServer.js +1097 -0
- package/dist/callServer.js.map +1 -0
- package/dist/callTools.d.ts +154 -0
- package/dist/callTools.d.ts.map +1 -0
- package/dist/callTools.js +255 -0
- package/dist/callTools.js.map +1 -0
- package/dist/chat.d.ts +226 -0
- package/dist/chat.d.ts.map +1 -0
- package/dist/chat.js +574 -0
- package/dist/chat.js.map +1 -0
- package/dist/config.d.ts +32 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +86 -0
- package/dist/config.js.map +1 -0
- package/dist/consult.d.ts +147 -0
- package/dist/consult.d.ts.map +1 -0
- package/dist/consult.js +388 -0
- package/dist/consult.js.map +1 -0
- package/dist/delivery.d.ts +81 -0
- package/dist/delivery.d.ts.map +1 -0
- package/dist/delivery.js +187 -0
- package/dist/delivery.js.map +1 -0
- package/dist/echoGuard.d.ts +56 -0
- package/dist/echoGuard.d.ts.map +1 -0
- package/dist/echoGuard.js +57 -0
- package/dist/echoGuard.js.map +1 -0
- package/dist/errors.d.ts +5 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +10 -0
- package/dist/errors.js.map +1 -0
- package/dist/fetch.d.ts +40 -0
- package/dist/fetch.d.ts.map +1 -0
- package/dist/fetch.js +240 -0
- package/dist/fetch.js.map +1 -0
- package/dist/gate.d.ts +80 -0
- package/dist/gate.d.ts.map +1 -0
- package/dist/gate.js +249 -0
- package/dist/gate.js.map +1 -0
- package/dist/handler.d.ts +283 -0
- package/dist/handler.d.ts.map +1 -0
- package/dist/handler.js +4 -0
- package/dist/handler.js.map +1 -0
- package/dist/hmac.d.ts +32 -0
- package/dist/hmac.d.ts.map +1 -0
- package/dist/hmac.js +108 -0
- package/dist/hmac.js.map +1 -0
- package/dist/index.d.ts +59 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +60 -0
- package/dist/index.js.map +1 -0
- package/dist/lane.d.ts +92 -0
- package/dist/lane.d.ts.map +1 -0
- package/dist/lane.js +269 -0
- package/dist/lane.js.map +1 -0
- package/dist/lipsync.d.ts +136 -0
- package/dist/lipsync.d.ts.map +1 -0
- package/dist/lipsync.js +310 -0
- package/dist/lipsync.js.map +1 -0
- package/dist/log.d.ts +18 -0
- package/dist/log.d.ts.map +1 -0
- package/dist/log.js +20 -0
- package/dist/log.js.map +1 -0
- package/dist/media.d.ts +43 -0
- package/dist/media.d.ts.map +1 -0
- package/dist/media.js +190 -0
- package/dist/media.js.map +1 -0
- package/dist/minutes.d.ts +398 -0
- package/dist/minutes.d.ts.map +1 -0
- package/dist/minutes.js +805 -0
- package/dist/minutes.js.map +1 -0
- package/dist/outbound.d.ts +388 -0
- package/dist/outbound.d.ts.map +1 -0
- package/dist/outbound.js +933 -0
- package/dist/outbound.js.map +1 -0
- package/dist/plugins/cartesia/agent.d.ts +58 -0
- package/dist/plugins/cartesia/agent.d.ts.map +1 -0
- package/dist/plugins/cartesia/agent.js +254 -0
- package/dist/plugins/cartesia/agent.js.map +1 -0
- package/dist/plugins/cartesia/cli.d.ts +3 -0
- package/dist/plugins/cartesia/cli.d.ts.map +1 -0
- package/dist/plugins/cartesia/cli.js +10 -0
- package/dist/plugins/cartesia/cli.js.map +1 -0
- package/dist/plugins/cartesia/config.d.ts +30 -0
- package/dist/plugins/cartesia/config.d.ts.map +1 -0
- package/dist/plugins/cartesia/config.js +29 -0
- package/dist/plugins/cartesia/config.js.map +1 -0
- package/dist/plugins/cartesia/handler.d.ts +40 -0
- package/dist/plugins/cartesia/handler.d.ts.map +1 -0
- package/dist/plugins/cartesia/handler.js +142 -0
- package/dist/plugins/cartesia/handler.js.map +1 -0
- package/dist/plugins/cartesia/index.d.ts +6 -0
- package/dist/plugins/cartesia/index.d.ts.map +1 -0
- package/dist/plugins/cartesia/index.js +54 -0
- package/dist/plugins/cartesia/index.js.map +1 -0
- package/dist/plugins/deepgram/agent.d.ts +60 -0
- package/dist/plugins/deepgram/agent.d.ts.map +1 -0
- package/dist/plugins/deepgram/agent.js +302 -0
- package/dist/plugins/deepgram/agent.js.map +1 -0
- package/dist/plugins/deepgram/cli.d.ts +3 -0
- package/dist/plugins/deepgram/cli.d.ts.map +1 -0
- package/dist/plugins/deepgram/cli.js +10 -0
- package/dist/plugins/deepgram/cli.js.map +1 -0
- package/dist/plugins/deepgram/config.d.ts +39 -0
- package/dist/plugins/deepgram/config.d.ts.map +1 -0
- package/dist/plugins/deepgram/config.js +66 -0
- package/dist/plugins/deepgram/config.js.map +1 -0
- package/dist/plugins/deepgram/handler.d.ts +87 -0
- package/dist/plugins/deepgram/handler.d.ts.map +1 -0
- package/dist/plugins/deepgram/handler.js +268 -0
- package/dist/plugins/deepgram/handler.js.map +1 -0
- package/dist/plugins/deepgram/index.d.ts +11 -0
- package/dist/plugins/deepgram/index.d.ts.map +1 -0
- package/dist/plugins/deepgram/index.js +76 -0
- package/dist/plugins/deepgram/index.js.map +1 -0
- package/dist/plugins/echo/cli.d.ts +3 -0
- package/dist/plugins/echo/cli.d.ts.map +1 -0
- package/dist/plugins/echo/cli.js +10 -0
- package/dist/plugins/echo/cli.js.map +1 -0
- package/dist/plugins/echo/index.d.ts +37 -0
- package/dist/plugins/echo/index.d.ts.map +1 -0
- package/dist/plugins/echo/index.js +61 -0
- package/dist/plugins/echo/index.js.map +1 -0
- package/dist/plugins/elevenlabs/agent.d.ts +72 -0
- package/dist/plugins/elevenlabs/agent.d.ts.map +1 -0
- package/dist/plugins/elevenlabs/agent.js +305 -0
- package/dist/plugins/elevenlabs/agent.js.map +1 -0
- package/dist/plugins/elevenlabs/cli.d.ts +3 -0
- package/dist/plugins/elevenlabs/cli.d.ts.map +1 -0
- package/dist/plugins/elevenlabs/cli.js +10 -0
- package/dist/plugins/elevenlabs/cli.js.map +1 -0
- package/dist/plugins/elevenlabs/config.d.ts +27 -0
- package/dist/plugins/elevenlabs/config.d.ts.map +1 -0
- package/dist/plugins/elevenlabs/config.js +42 -0
- package/dist/plugins/elevenlabs/config.js.map +1 -0
- package/dist/plugins/elevenlabs/handler.d.ts +67 -0
- package/dist/plugins/elevenlabs/handler.d.ts.map +1 -0
- package/dist/plugins/elevenlabs/handler.js +419 -0
- package/dist/plugins/elevenlabs/handler.js.map +1 -0
- package/dist/plugins/elevenlabs/index.d.ts +11 -0
- package/dist/plugins/elevenlabs/index.d.ts.map +1 -0
- package/dist/plugins/elevenlabs/index.js +72 -0
- package/dist/plugins/elevenlabs/index.js.map +1 -0
- package/dist/plugins/livekit/cli.d.ts +3 -0
- package/dist/plugins/livekit/cli.d.ts.map +1 -0
- package/dist/plugins/livekit/cli.js +10 -0
- package/dist/plugins/livekit/cli.js.map +1 -0
- package/dist/plugins/livekit/config.d.ts +47 -0
- package/dist/plugins/livekit/config.d.ts.map +1 -0
- package/dist/plugins/livekit/config.js +46 -0
- package/dist/plugins/livekit/config.js.map +1 -0
- package/dist/plugins/livekit/handler.d.ts +59 -0
- package/dist/plugins/livekit/handler.d.ts.map +1 -0
- package/dist/plugins/livekit/handler.js +211 -0
- package/dist/plugins/livekit/handler.js.map +1 -0
- package/dist/plugins/livekit/index.d.ts +6 -0
- package/dist/plugins/livekit/index.d.ts.map +1 -0
- package/dist/plugins/livekit/index.js +68 -0
- package/dist/plugins/livekit/index.js.map +1 -0
- package/dist/plugins/livekit/room.d.ts +117 -0
- package/dist/plugins/livekit/room.d.ts.map +1 -0
- package/dist/plugins/livekit/room.js +449 -0
- package/dist/plugins/livekit/room.js.map +1 -0
- package/dist/plugins/openai/agent.d.ts +74 -0
- package/dist/plugins/openai/agent.d.ts.map +1 -0
- package/dist/plugins/openai/agent.js +264 -0
- package/dist/plugins/openai/agent.js.map +1 -0
- package/dist/plugins/openai/cli.d.ts +3 -0
- package/dist/plugins/openai/cli.d.ts.map +1 -0
- package/dist/plugins/openai/cli.js +10 -0
- package/dist/plugins/openai/cli.js.map +1 -0
- package/dist/plugins/openai/config.d.ts +29 -0
- package/dist/plugins/openai/config.d.ts.map +1 -0
- package/dist/plugins/openai/config.js +37 -0
- package/dist/plugins/openai/config.js.map +1 -0
- package/dist/plugins/openai/handler.d.ts +65 -0
- package/dist/plugins/openai/handler.d.ts.map +1 -0
- package/dist/plugins/openai/handler.js +297 -0
- package/dist/plugins/openai/handler.js.map +1 -0
- package/dist/plugins/openai/index.d.ts +6 -0
- package/dist/plugins/openai/index.d.ts.map +1 -0
- package/dist/plugins/openai/index.js +72 -0
- package/dist/plugins/openai/index.js.map +1 -0
- package/dist/plugins/openclaw/allowlist.d.ts +9 -0
- package/dist/plugins/openclaw/allowlist.d.ts.map +1 -0
- package/dist/plugins/openclaw/allowlist.js +11 -0
- package/dist/plugins/openclaw/allowlist.js.map +1 -0
- package/dist/plugins/openclaw/config.d.ts +50 -0
- package/dist/plugins/openclaw/config.d.ts.map +1 -0
- package/dist/plugins/openclaw/config.js +53 -0
- package/dist/plugins/openclaw/config.js.map +1 -0
- package/dist/plugins/openclaw/handler.d.ts +90 -0
- package/dist/plugins/openclaw/handler.d.ts.map +1 -0
- package/dist/plugins/openclaw/handler.js +146 -0
- package/dist/plugins/openclaw/handler.js.map +1 -0
- package/dist/plugins/openclaw/index.d.ts +55 -0
- package/dist/plugins/openclaw/index.d.ts.map +1 -0
- package/dist/plugins/openclaw/index.js +89 -0
- package/dist/plugins/openclaw/index.js.map +1 -0
- package/dist/plugins/openclaw/openclaw.plugin.json +74 -0
- package/dist/plugins/openclaw/package.json +14 -0
- package/dist/plugins/openclaw/realtime.d.ts +82 -0
- package/dist/plugins/openclaw/realtime.d.ts.map +1 -0
- package/dist/plugins/openclaw/realtime.js +238 -0
- package/dist/plugins/openclaw/realtime.js.map +1 -0
- package/dist/plugins/openclaw/runtime.d.ts +19 -0
- package/dist/plugins/openclaw/runtime.d.ts.map +1 -0
- package/dist/plugins/openclaw/runtime.js +160 -0
- package/dist/plugins/openclaw/runtime.js.map +1 -0
- package/dist/policy.d.ts +38 -0
- package/dist/policy.d.ts.map +1 -0
- package/dist/policy.js +95 -0
- package/dist/policy.js.map +1 -0
- package/dist/protocol.d.ts +44 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +77 -0
- package/dist/protocol.js.map +1 -0
- package/dist/protocolRuntime.d.ts +19 -0
- package/dist/protocolRuntime.d.ts.map +1 -0
- package/dist/protocolRuntime.js +78 -0
- package/dist/protocolRuntime.js.map +1 -0
- package/dist/smoke.d.ts +65 -0
- package/dist/smoke.d.ts.map +1 -0
- package/dist/smoke.js +293 -0
- package/dist/smoke.js.map +1 -0
- package/dist/startup.d.ts +74 -0
- package/dist/startup.d.ts.map +1 -0
- package/dist/startup.js +112 -0
- package/dist/startup.js.map +1 -0
- package/dist/tile.d.ts +101 -0
- package/dist/tile.d.ts.map +1 -0
- package/dist/tile.js +195 -0
- package/dist/tile.js.map +1 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +4 -0
- package/dist/version.js.map +1 -0
- package/dist/vision.d.ts +160 -0
- package/dist/vision.d.ts.map +1 -0
- package/dist/vision.js +301 -0
- package/dist/vision.js.map +1 -0
- package/dist/visionTools.d.ts +237 -0
- package/dist/visionTools.d.ts.map +1 -0
- package/dist/visionTools.js +578 -0
- package/dist/visionTools.js.map +1 -0
- package/dist/voice.d.ts +143 -0
- package/dist/voice.d.ts.map +1 -0
- package/dist/voice.js +403 -0
- package/dist/voice.js.map +1 -0
- package/package.json +129 -0
package/dist/minutes.js
ADDED
|
@@ -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 "&lt;".
|
|
584
|
+
return text
|
|
585
|
+
.replaceAll("&", "&")
|
|
586
|
+
.replaceAll("<", "<")
|
|
587
|
+
.replaceAll(">", ">")
|
|
588
|
+
.replaceAll('"', """)
|
|
589
|
+
.replaceAll("'", "'");
|
|
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
|