@remnic/core 9.12.0 → 9.13.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/dist/access-cli.js +1 -1
- package/dist/access-operations.d.ts +3 -3
- package/dist/access-schema.d.ts +76 -76
- package/dist/{chunk-3VBXP5HS.js → chunk-5RZHHANR.js} +195 -14
- package/dist/chunk-5RZHHANR.js.map +1 -0
- package/dist/index.d.ts +509 -405
- package/dist/index.js +9 -1
- package/dist/orchestrator.js +1 -1
- package/dist/schemas.d.ts +76 -76
- package/dist/shared-context/manager.d.ts +8 -8
- package/dist/transfer/types.d.ts +66 -66
- package/package.json +2 -2
- package/src/index.ts +1 -1
- package/src/meetings/detect.test.ts +366 -0
- package/src/meetings/detect.ts +287 -0
- package/src/meetings/index.ts +8 -0
- package/src/meetings/types.ts +76 -0
- package/dist/chunk-3VBXP5HS.js.map +0 -1
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
|
|
4
|
+
import { DEFAULT_MEETINGS_DETECTION_CONFIG, detectMeetings, meetingId } from "./detect.js";
|
|
5
|
+
import type { MeetingAppSpan, MeetingAudioWindow, MeetingsDetectionInput } from "./types.js";
|
|
6
|
+
|
|
7
|
+
const DATE = "2026-03-10";
|
|
8
|
+
|
|
9
|
+
function span(app: string, startUtc: string, endUtc: string): MeetingAppSpan {
|
|
10
|
+
return { app, startUtc, endUtc };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function audio(overrides: Partial<MeetingAudioWindow> & { startUtc: string; endUtc: string }): MeetingAudioWindow {
|
|
14
|
+
return { source: "desktop", distinctNonWearerSpeakers: 2, ...overrides };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function input(overrides: Partial<MeetingsDetectionInput> = {}): MeetingsDetectionInput {
|
|
18
|
+
return { date: DATE, appSpans: [], audioWindows: [], ...overrides };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
test("app+audio: an app span overlapping a conversation yields one meeting", () => {
|
|
22
|
+
const meetings = detectMeetings(
|
|
23
|
+
input({
|
|
24
|
+
appSpans: [span("Zoom", "2026-03-10T14:00:00.000Z", "2026-03-10T15:00:00.000Z")],
|
|
25
|
+
audioWindows: [audio({ source: "desktop", startUtc: "2026-03-10T14:01:00.000Z", endUtc: "2026-03-10T14:55:00.000Z" })],
|
|
26
|
+
}),
|
|
27
|
+
);
|
|
28
|
+
assert.equal(meetings.length, 1);
|
|
29
|
+
assert.equal(meetings[0]?.detectionSource, "app+audio");
|
|
30
|
+
assert.equal(meetings[0]?.app, "Zoom");
|
|
31
|
+
assert.deepEqual(meetings[0]?.sources, ["desktop"]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("activity only (app span, zero audio) → NO meeting (watching a recording)", () => {
|
|
35
|
+
const meetings = detectMeetings(
|
|
36
|
+
input({ appSpans: [span("Zoom", "2026-03-10T14:00:00.000Z", "2026-03-10T15:00:00.000Z")] }),
|
|
37
|
+
);
|
|
38
|
+
assert.equal(meetings.length, 0);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("audio only: a long multi-speaker conversation with no app span is a meeting", () => {
|
|
42
|
+
const meetings = detectMeetings(
|
|
43
|
+
input({
|
|
44
|
+
audioWindows: [
|
|
45
|
+
audio({ source: "limitless", startUtc: "2026-03-10T09:00:00.000Z", endUtc: "2026-03-10T09:20:00.000Z", distinctNonWearerSpeakers: 3 }),
|
|
46
|
+
],
|
|
47
|
+
}),
|
|
48
|
+
);
|
|
49
|
+
assert.equal(meetings.length, 1);
|
|
50
|
+
assert.equal(meetings[0]?.detectionSource, "audio");
|
|
51
|
+
assert.equal(meetings[0]?.app, undefined);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("audio only: too short OR too few speakers is NOT a meeting", () => {
|
|
55
|
+
const short = detectMeetings(
|
|
56
|
+
input({ audioWindows: [audio({ startUtc: "2026-03-10T09:00:00.000Z", endUtc: "2026-03-10T09:05:00.000Z", distinctNonWearerSpeakers: 3 })] }),
|
|
57
|
+
);
|
|
58
|
+
assert.equal(short.length, 0);
|
|
59
|
+
const solo = detectMeetings(
|
|
60
|
+
input({ audioWindows: [audio({ startUtc: "2026-03-10T09:00:00.000Z", endUtc: "2026-03-10T09:30:00.000Z", distinctNonWearerSpeakers: 1 })] }),
|
|
61
|
+
);
|
|
62
|
+
assert.equal(solo.length, 0);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("provider meeting is detected from its own boundaries without an app span", () => {
|
|
66
|
+
const meetings = detectMeetings(
|
|
67
|
+
input({
|
|
68
|
+
audioWindows: [
|
|
69
|
+
audio({ source: "granola", startUtc: "2026-03-10T16:00:00.000Z", endUtc: "2026-03-10T16:30:00.000Z", providerMeeting: true, title: "Roadmap", distinctNonWearerSpeakers: 0 }),
|
|
70
|
+
],
|
|
71
|
+
}),
|
|
72
|
+
);
|
|
73
|
+
assert.equal(meetings.length, 1);
|
|
74
|
+
assert.equal(meetings[0]?.detectionSource, "provider");
|
|
75
|
+
assert.equal(meetings[0]?.title, "Roadmap");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("rejoin within the merge gap collapses into ONE meeting; a 10-min gap stays TWO", () => {
|
|
79
|
+
const rejoin = detectMeetings(
|
|
80
|
+
input({
|
|
81
|
+
appSpans: [
|
|
82
|
+
span("Zoom", "2026-03-10T14:00:00.000Z", "2026-03-10T14:20:00.000Z"),
|
|
83
|
+
span("Zoom", "2026-03-10T14:21:00.000Z", "2026-03-10T14:40:00.000Z"),
|
|
84
|
+
],
|
|
85
|
+
audioWindows: [
|
|
86
|
+
audio({ startUtc: "2026-03-10T14:00:00.000Z", endUtc: "2026-03-10T14:19:00.000Z" }),
|
|
87
|
+
audio({ startUtc: "2026-03-10T14:20:30.000Z", endUtc: "2026-03-10T14:39:00.000Z" }),
|
|
88
|
+
],
|
|
89
|
+
}),
|
|
90
|
+
);
|
|
91
|
+
assert.equal(rejoin.length, 1);
|
|
92
|
+
|
|
93
|
+
const twoMeetings = detectMeetings(
|
|
94
|
+
input({
|
|
95
|
+
appSpans: [
|
|
96
|
+
span("Zoom", "2026-03-10T14:00:00.000Z", "2026-03-10T14:20:00.000Z"),
|
|
97
|
+
span("Zoom", "2026-03-10T14:30:00.000Z", "2026-03-10T14:50:00.000Z"),
|
|
98
|
+
],
|
|
99
|
+
audioWindows: [
|
|
100
|
+
audio({ startUtc: "2026-03-10T14:00:00.000Z", endUtc: "2026-03-10T14:19:00.000Z" }),
|
|
101
|
+
audio({ startUtc: "2026-03-10T14:30:00.000Z", endUtc: "2026-03-10T14:49:00.000Z" }),
|
|
102
|
+
],
|
|
103
|
+
}),
|
|
104
|
+
);
|
|
105
|
+
assert.equal(twoMeetings.length, 2);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("multiple audio sources over the same window fuse into one meeting with both sources", () => {
|
|
109
|
+
const meetings = detectMeetings(
|
|
110
|
+
input({
|
|
111
|
+
appSpans: [span("Zoom", "2026-03-10T14:00:00.000Z", "2026-03-10T15:00:00.000Z")],
|
|
112
|
+
audioWindows: [
|
|
113
|
+
audio({ source: "desktop", startUtc: "2026-03-10T14:00:00.000Z", endUtc: "2026-03-10T14:55:00.000Z" }),
|
|
114
|
+
audio({ source: "limitless", startUtc: "2026-03-10T14:02:00.000Z", endUtc: "2026-03-10T14:58:00.000Z" }),
|
|
115
|
+
],
|
|
116
|
+
}),
|
|
117
|
+
);
|
|
118
|
+
assert.equal(meetings.length, 1);
|
|
119
|
+
assert.deepEqual(meetings[0]?.sources, ["desktop", "limitless"]);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("detected meetings never overlap and stay ordered after merge", () => {
|
|
123
|
+
const meetings = detectMeetings(
|
|
124
|
+
input({
|
|
125
|
+
appSpans: [span("Zoom", "2026-03-10T14:00:00.000Z", "2026-03-10T15:30:00.000Z")],
|
|
126
|
+
audioWindows: [
|
|
127
|
+
audio({ startUtc: "2026-03-10T14:00:00.000Z", endUtc: "2026-03-10T14:40:00.000Z" }),
|
|
128
|
+
audio({ startUtc: "2026-03-10T14:30:00.000Z", endUtc: "2026-03-10T15:10:00.000Z" }),
|
|
129
|
+
// A second, well-separated meeting so the ordering assertion is non-vacuous.
|
|
130
|
+
audio({
|
|
131
|
+
source: "granola",
|
|
132
|
+
startUtc: "2026-03-10T18:00:00.000Z",
|
|
133
|
+
endUtc: "2026-03-10T18:30:00.000Z",
|
|
134
|
+
providerMeeting: true,
|
|
135
|
+
title: "Sync",
|
|
136
|
+
}),
|
|
137
|
+
],
|
|
138
|
+
}),
|
|
139
|
+
);
|
|
140
|
+
assert.equal(meetings.length, 2);
|
|
141
|
+
for (let i = 1; i < meetings.length; i++) {
|
|
142
|
+
assert.ok((meetings[i - 1]?.endUtc ?? "") <= (meetings[i]?.startUtc ?? ""), "meetings must not overlap");
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("sub-threshold overlap does not pair an app span with a conversation", () => {
|
|
147
|
+
// Only 1 minute of overlap; minOverlapMinutes default is 2 → audio-only rules apply.
|
|
148
|
+
const meetings = detectMeetings(
|
|
149
|
+
input({
|
|
150
|
+
appSpans: [span("Zoom", "2026-03-10T14:00:00.000Z", "2026-03-10T14:10:00.000Z")],
|
|
151
|
+
audioWindows: [audio({ startUtc: "2026-03-10T14:09:00.000Z", endUtc: "2026-03-10T14:16:00.000Z", distinctNonWearerSpeakers: 2 })],
|
|
152
|
+
}),
|
|
153
|
+
);
|
|
154
|
+
// 7-min conversation < 15-min audio-only floor and no qualifying app overlap → no meeting.
|
|
155
|
+
assert.equal(meetings.length, 0);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("ids are stable across a re-run with 10% more fixture data appended", () => {
|
|
159
|
+
const base = input({
|
|
160
|
+
appSpans: [span("Zoom", "2026-03-10T14:00:00.000Z", "2026-03-10T15:00:00.000Z")],
|
|
161
|
+
audioWindows: [audio({ startUtc: "2026-03-10T14:00:00.000Z", endUtc: "2026-03-10T14:55:00.000Z" })],
|
|
162
|
+
});
|
|
163
|
+
const first = detectMeetings(base);
|
|
164
|
+
// Re-run with an extra, later meeting appended (more data, same early meeting).
|
|
165
|
+
const more = detectMeetings(
|
|
166
|
+
input({
|
|
167
|
+
appSpans: [...base.appSpans, span("Zoom", "2026-03-10T17:00:00.000Z", "2026-03-10T17:30:00.000Z")],
|
|
168
|
+
audioWindows: [...base.audioWindows, audio({ startUtc: "2026-03-10T17:01:00.000Z", endUtc: "2026-03-10T17:28:00.000Z" })],
|
|
169
|
+
}),
|
|
170
|
+
);
|
|
171
|
+
const firstId = first[0]?.id;
|
|
172
|
+
const sameId = more.find((m) => m.startUtc === first[0]?.startUtc)?.id;
|
|
173
|
+
assert.ok(firstId);
|
|
174
|
+
assert.equal(sameId, firstId, "the earlier meeting keeps its id when later data is added");
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("meetingId is deterministic and anchored on the exact start instant", () => {
|
|
178
|
+
const a = meetingId(DATE, "2026-03-10T14:00:05.000Z");
|
|
179
|
+
assert.equal(a, meetingId(DATE, "2026-03-10T14:00:05.000Z")); // same start instant → same id
|
|
180
|
+
assert.match(a, /^mtg-2026-03-10-[0-9a-f]{8}$/);
|
|
181
|
+
// Distinct start instants — even within the same minute — get distinct ids.
|
|
182
|
+
assert.notEqual(a, meetingId(DATE, "2026-03-10T14:00:06.000Z"));
|
|
183
|
+
assert.notEqual(a, meetingId(DATE, "2026-03-10T14:01:00.000Z"));
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("two short provider meetings in the same minute get distinct ids", () => {
|
|
187
|
+
const meetings = detectMeetings(
|
|
188
|
+
input({
|
|
189
|
+
audioWindows: [
|
|
190
|
+
audio({ source: "granola", startUtc: "2026-03-10T14:00:05.000Z", endUtc: "2026-03-10T14:00:20.000Z", providerMeeting: true, title: "A", distinctNonWearerSpeakers: 0 }),
|
|
191
|
+
audio({ source: "granola", startUtc: "2026-03-10T14:00:40.000Z", endUtc: "2026-03-10T14:00:55.000Z", providerMeeting: true, title: "B", distinctNonWearerSpeakers: 0 }),
|
|
192
|
+
],
|
|
193
|
+
}),
|
|
194
|
+
);
|
|
195
|
+
// Provider candidates bypass the duration floors and don't merge (no shared
|
|
196
|
+
// app, disjoint), so both survive — and must not collide on id.
|
|
197
|
+
assert.equal(meetings.length, 2);
|
|
198
|
+
assert.notEqual(meetings[0]?.id, meetings[1]?.id);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("a rolled-over calendar timestamp is dropped, not silently shifted", () => {
|
|
202
|
+
const meetings = detectMeetings(
|
|
203
|
+
input({
|
|
204
|
+
audioWindows: [
|
|
205
|
+
audio({ startUtc: "2026-02-30T14:00:00.000Z", endUtc: "2026-02-30T14:30:00.000Z", distinctNonWearerSpeakers: 3 }),
|
|
206
|
+
audio({ startUtc: "2026-03-10T10:00:00.000Z", endUtc: "2026-03-10T10:20:00.000Z", distinctNonWearerSpeakers: 3 }),
|
|
207
|
+
],
|
|
208
|
+
}),
|
|
209
|
+
);
|
|
210
|
+
// Feb 30 is invalid → dropped, never rolled into Mar 2.
|
|
211
|
+
assert.equal(meetings.length, 1);
|
|
212
|
+
assert.equal(meetings[0]?.startUtc, "2026-03-10T10:00:00.000Z");
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("meeting id is unchanged when a late source extends the end (resync stability)", () => {
|
|
216
|
+
const first = detectMeetings(
|
|
217
|
+
input({
|
|
218
|
+
audioWindows: [
|
|
219
|
+
audio({ startUtc: "2026-03-10T14:00:00.000Z", endUtc: "2026-03-10T14:20:00.000Z", distinctNonWearerSpeakers: 3 }),
|
|
220
|
+
],
|
|
221
|
+
}),
|
|
222
|
+
);
|
|
223
|
+
const extended = detectMeetings(
|
|
224
|
+
input({
|
|
225
|
+
audioWindows: [
|
|
226
|
+
audio({ source: "desktop", startUtc: "2026-03-10T14:00:00.000Z", endUtc: "2026-03-10T14:20:00.000Z", distinctNonWearerSpeakers: 3 }),
|
|
227
|
+
audio({ source: "limitless", startUtc: "2026-03-10T14:18:00.000Z", endUtc: "2026-03-10T14:45:00.000Z", distinctNonWearerSpeakers: 3 }),
|
|
228
|
+
],
|
|
229
|
+
}),
|
|
230
|
+
);
|
|
231
|
+
assert.equal(first.length, 1);
|
|
232
|
+
assert.equal(extended.length, 1);
|
|
233
|
+
assert.equal(extended[0]?.endUtc, "2026-03-10T14:45:00.000Z"); // end grew
|
|
234
|
+
assert.equal(extended[0]?.id, first[0]?.id); // …but the id is unchanged
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("default config matches the issue-specified thresholds", () => {
|
|
238
|
+
assert.equal(DEFAULT_MEETINGS_DETECTION_CONFIG.minOverlapMinutes, 2);
|
|
239
|
+
assert.equal(DEFAULT_MEETINGS_DETECTION_CONFIG.audioOnlyMinMinutes, 15);
|
|
240
|
+
assert.equal(DEFAULT_MEETINGS_DETECTION_CONFIG.mergeGapMinutes, 2);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("audio-only exactly at the 15-min floor qualifies", () => {
|
|
244
|
+
const meetings = detectMeetings(
|
|
245
|
+
input({
|
|
246
|
+
audioWindows: [
|
|
247
|
+
audio({ startUtc: "2026-03-10T09:00:00.000Z", endUtc: "2026-03-10T09:15:00.000Z", distinctNonWearerSpeakers: 2 }),
|
|
248
|
+
],
|
|
249
|
+
}),
|
|
250
|
+
);
|
|
251
|
+
assert.equal(meetings.length, 1);
|
|
252
|
+
assert.equal(meetings[0]?.detectionSource, "audio");
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test("app+audio exactly at the 2-min overlap threshold qualifies", () => {
|
|
256
|
+
const meetings = detectMeetings(
|
|
257
|
+
input({
|
|
258
|
+
appSpans: [span("Zoom", "2026-03-10T14:00:00.000Z", "2026-03-10T14:12:00.000Z")],
|
|
259
|
+
audioWindows: [
|
|
260
|
+
audio({ startUtc: "2026-03-10T14:10:00.000Z", endUtc: "2026-03-10T14:17:00.000Z", distinctNonWearerSpeakers: 2 }),
|
|
261
|
+
],
|
|
262
|
+
}),
|
|
263
|
+
);
|
|
264
|
+
// Exactly 2 min overlap (14:10–14:12) → pairs as app+audio.
|
|
265
|
+
assert.equal(meetings.length, 1);
|
|
266
|
+
assert.equal(meetings[0]?.detectionSource, "app+audio");
|
|
267
|
+
assert.equal(meetings[0]?.app, "Zoom");
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test("disjoint app + audio never pair even when minOverlapMinutes is 0", () => {
|
|
271
|
+
const meetings = detectMeetings(
|
|
272
|
+
input({
|
|
273
|
+
appSpans: [span("Zoom", "2026-03-10T14:00:00.000Z", "2026-03-10T14:05:00.000Z")],
|
|
274
|
+
audioWindows: [
|
|
275
|
+
audio({ startUtc: "2026-03-10T14:10:00.000Z", endUtc: "2026-03-10T14:17:00.000Z", distinctNonWearerSpeakers: 2 }),
|
|
276
|
+
],
|
|
277
|
+
}),
|
|
278
|
+
{ ...DEFAULT_MEETINGS_DETECTION_CONFIG, minOverlapMinutes: 0 },
|
|
279
|
+
);
|
|
280
|
+
// Disjoint windows (overlap 0) must not pair; 7-min audio < 15-min floor → no meeting.
|
|
281
|
+
assert.equal(meetings.length, 0);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test("invalid detection thresholds are rejected", () => {
|
|
285
|
+
for (const bad of [Number.NaN, -1, Number.POSITIVE_INFINITY]) {
|
|
286
|
+
assert.throws(
|
|
287
|
+
() => detectMeetings(input(), { ...DEFAULT_MEETINGS_DETECTION_CONFIG, minOverlapMinutes: bad }),
|
|
288
|
+
RangeError,
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
assert.throws(
|
|
292
|
+
() => detectMeetings(input(), { ...DEFAULT_MEETINGS_DETECTION_CONFIG, mergeGapMinutes: Number.NaN }),
|
|
293
|
+
RangeError,
|
|
294
|
+
);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test("a malformed timestamp is skipped defensively without dropping valid meetings", () => {
|
|
298
|
+
const meetings = detectMeetings(
|
|
299
|
+
input({
|
|
300
|
+
audioWindows: [
|
|
301
|
+
audio({ startUtc: "not-a-date", endUtc: "2026-03-10T09:20:00.000Z", distinctNonWearerSpeakers: 3 }),
|
|
302
|
+
audio({ startUtc: "2026-03-10T10:00:00.000Z", endUtc: "2026-03-10T10:20:00.000Z", distinctNonWearerSpeakers: 3 }),
|
|
303
|
+
],
|
|
304
|
+
}),
|
|
305
|
+
);
|
|
306
|
+
// The malformed window is dropped; the valid conversation is still detected.
|
|
307
|
+
assert.equal(meetings.length, 1);
|
|
308
|
+
assert.equal(meetings[0]?.startUtc, "2026-03-10T10:00:00.000Z");
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test("an offset-form rolled-over timestamp is also dropped", () => {
|
|
312
|
+
const meetings = detectMeetings(
|
|
313
|
+
input({
|
|
314
|
+
audioWindows: [
|
|
315
|
+
audio({ startUtc: "2026-02-30T14:00:00.000+00:00", endUtc: "2026-02-30T14:30:00.000+00:00", distinctNonWearerSpeakers: 3 }),
|
|
316
|
+
audio({ startUtc: "2026-03-10T10:00:00.000Z", endUtc: "2026-03-10T10:20:00.000Z", distinctNonWearerSpeakers: 3 }),
|
|
317
|
+
],
|
|
318
|
+
}),
|
|
319
|
+
);
|
|
320
|
+
assert.equal(meetings.length, 1);
|
|
321
|
+
assert.equal(meetings[0]?.startUtc, "2026-03-10T10:00:00.000Z");
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
test("a timestamp offset beyond ±14:00 is dropped", () => {
|
|
325
|
+
const meetings = detectMeetings(
|
|
326
|
+
input({
|
|
327
|
+
audioWindows: [
|
|
328
|
+
audio({ startUtc: "2026-03-10T00:30:00.000+14:59", endUtc: "2026-03-10T01:00:00.000+14:59", distinctNonWearerSpeakers: 3 }),
|
|
329
|
+
audio({ startUtc: "2026-03-10T10:00:00.000Z", endUtc: "2026-03-10T10:20:00.000Z", distinctNonWearerSpeakers: 3 }),
|
|
330
|
+
],
|
|
331
|
+
}),
|
|
332
|
+
);
|
|
333
|
+
assert.equal(meetings.length, 1);
|
|
334
|
+
assert.equal(meetings[0]?.startUtc, "2026-03-10T10:00:00.000Z");
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
test("a non-integer speaker count does not qualify an audio-only meeting", () => {
|
|
338
|
+
const meetings = detectMeetings(
|
|
339
|
+
input({
|
|
340
|
+
audioWindows: [
|
|
341
|
+
audio({ startUtc: "2026-03-10T09:00:00.000Z", endUtc: "2026-03-10T09:30:00.000Z", distinctNonWearerSpeakers: Number.POSITIVE_INFINITY }),
|
|
342
|
+
],
|
|
343
|
+
}),
|
|
344
|
+
);
|
|
345
|
+
assert.equal(meetings.length, 0);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test("detectMeetings rejects a non-YYYY-MM-DD day (kept out of ids)", () => {
|
|
349
|
+
assert.throws(() => detectMeetings(input({ date: "2026/03/10" })), RangeError);
|
|
350
|
+
assert.throws(() => detectMeetings(input({ date: "2026-02-30" })), RangeError);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
test("meetingId validates its inputs for direct callers", () => {
|
|
354
|
+
assert.throws(() => meetingId("2026/03/10", "2026-03-10T14:00:00.000Z"), RangeError);
|
|
355
|
+
assert.throws(() => meetingId("2026-03-10", "not-a-date"), RangeError);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
test("equal-overlap app selection is deterministic regardless of input order", () => {
|
|
359
|
+
const win = { startUtc: "2026-03-10T14:00:00.000Z", endUtc: "2026-03-10T15:00:00.000Z", distinctNonWearerSpeakers: 2 };
|
|
360
|
+
const zoom = span("Zoom", "2026-03-10T14:00:00.000Z", "2026-03-10T15:00:00.000Z");
|
|
361
|
+
const meet = span("Meet", "2026-03-10T14:00:00.000Z", "2026-03-10T15:00:00.000Z");
|
|
362
|
+
const a = detectMeetings(input({ appSpans: [zoom, meet], audioWindows: [audio(win)] }));
|
|
363
|
+
const b = detectMeetings(input({ appSpans: [meet, zoom], audioWindows: [audio(win)] }));
|
|
364
|
+
assert.equal(a[0]?.app, b[0]?.app);
|
|
365
|
+
assert.equal(a[0]?.app, "Meet"); // tie broken by app name ("Meet" < "Zoom")
|
|
366
|
+
});
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Retrospective meeting detection (issue #1900, Phase 4 slice 1).
|
|
3
|
+
*
|
|
4
|
+
* Pure functions over a day's already-ingested signals. A meeting candidate is
|
|
5
|
+
* either (a) an audio conversation overlapping a meeting-app foreground span
|
|
6
|
+
* (`app+audio`), (b) a provider meeting with its own boundaries (`provider`),
|
|
7
|
+
* or (c) a long enough multi-speaker conversation with no app span
|
|
8
|
+
* (`audio`, the phone-call/in-person fallback). App spans with no overlapping
|
|
9
|
+
* audio are NOT meetings (you were watching a recording). Candidates are then
|
|
10
|
+
* merged so a day's meetings never overlap, and each gets a re-run-stable id.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { createHash } from "node:crypto";
|
|
14
|
+
|
|
15
|
+
import type {
|
|
16
|
+
DetectedMeeting,
|
|
17
|
+
MeetingAppSpan,
|
|
18
|
+
MeetingAudioWindow,
|
|
19
|
+
MeetingDetectionSource,
|
|
20
|
+
MeetingsDetectionConfig,
|
|
21
|
+
MeetingsDetectionInput,
|
|
22
|
+
} from "./types.js";
|
|
23
|
+
|
|
24
|
+
/** Shipped meeting-app patterns (used by the later activity-span derivation). */
|
|
25
|
+
export const DEFAULT_MEETING_APP_PATTERNS: readonly string[] = [
|
|
26
|
+
"zoom.us",
|
|
27
|
+
"Zoom",
|
|
28
|
+
"Microsoft Teams",
|
|
29
|
+
"teams.microsoft.com",
|
|
30
|
+
"meet.google.com",
|
|
31
|
+
"Webex",
|
|
32
|
+
"Slack", // huddle windows
|
|
33
|
+
"FaceTime",
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
export const DEFAULT_MEETINGS_DETECTION_CONFIG: MeetingsDetectionConfig = {
|
|
37
|
+
appPatterns: [...DEFAULT_MEETING_APP_PATTERNS],
|
|
38
|
+
minOverlapMinutes: 2,
|
|
39
|
+
audioOnlyMinMinutes: 15,
|
|
40
|
+
mergeGapMinutes: 2,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
interface Candidate {
|
|
44
|
+
startMs: number;
|
|
45
|
+
endMs: number;
|
|
46
|
+
app?: string;
|
|
47
|
+
detectionSource: MeetingDetectionSource;
|
|
48
|
+
sources: string[];
|
|
49
|
+
title?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function ms(iso: string): number {
|
|
53
|
+
if (typeof iso !== "string") return Number.NaN;
|
|
54
|
+
if (!Number.isFinite(Date.parse(iso))) return Number.NaN;
|
|
55
|
+
// Reject invalid calendar rollovers (e.g. 2026-02-30 → Mar 2) in EITHER `Z`
|
|
56
|
+
// or explicit-offset form, by validating the wall-clock calendar fields in the
|
|
57
|
+
// string directly rather than trusting Date.parse's silent normalization.
|
|
58
|
+
const m = iso.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:Z|[+-](?:0\d:[0-5]\d|1[0-3]:[0-5]\d|14:00))$/);
|
|
59
|
+
if (m === null) return Number.NaN;
|
|
60
|
+
const [year, month, day, hour, minute, second] = m.slice(1).map(Number);
|
|
61
|
+
const daysInMonth = month >= 1 && month <= 12 ? new Date(Date.UTC(year, month, 0)).getUTCDate() : 0;
|
|
62
|
+
if (day < 1 || day > daysInMonth || hour > 23 || minute > 59 || second > 59) return Number.NaN;
|
|
63
|
+
return Date.parse(iso);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Overlap of two half-open [start,end) windows, in milliseconds (0 if disjoint). */
|
|
67
|
+
function overlapMs(aStart: number, aEnd: number, bStart: number, bEnd: number): number {
|
|
68
|
+
const start = Math.max(aStart, bStart);
|
|
69
|
+
const end = Math.min(aEnd, bEnd);
|
|
70
|
+
return end > start ? end - start : 0;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Re-run-stable id: same date + exact START instant ⇒ same id. Anchored on the
|
|
74
|
+
* start ONLY (end + app both excluded from the hash) so a resync that extends
|
|
75
|
+
* the meeting's end (a late source / rejoin) or reassigns its app never
|
|
76
|
+
* renumbers an existing record — the start is the stable identity. Full start
|
|
77
|
+
* precision (NOT minute-rounded) keeps ids unique even for short provider
|
|
78
|
+
* meetings that share a start minute: post-merge meetings are non-overlapping,
|
|
79
|
+
* so their start instants are always distinct.
|
|
80
|
+
*
|
|
81
|
+
* A resync that moves a meeting's START earlier (a late source beginning before
|
|
82
|
+
* the first-ingested one) does change the id — a stateless pure detector cannot
|
|
83
|
+
* know the prior id. Preserving ids across a shifted start is cross-run identity
|
|
84
|
+
* work that needs prior-emission state, so it belongs to the fusion/store slice
|
|
85
|
+
* (#1900), which matches a re-detected meeting to its stored record by overlap
|
|
86
|
+
* and keeps the original id. This function stays pure and deterministic. */
|
|
87
|
+
export function meetingId(date: string, startUtc: string): string {
|
|
88
|
+
if (!isValidDay(date)) {
|
|
89
|
+
throw new RangeError(`meetings: invalid day "${date}" for a meeting id; expected a real YYYY-MM-DD.`);
|
|
90
|
+
}
|
|
91
|
+
const startMs = ms(startUtc);
|
|
92
|
+
if (Number.isNaN(startMs)) {
|
|
93
|
+
throw new RangeError(`meetings: invalid meeting start "${startUtc}" for a meeting id.`);
|
|
94
|
+
}
|
|
95
|
+
const anchor = new Date(startMs).toISOString();
|
|
96
|
+
const hash = createHash("sha256").update(`${date}|${anchor}`, "utf8").digest("hex").slice(0, 8);
|
|
97
|
+
return `mtg-${date}-${hash}`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function isFinitePair(a: number, b: number): boolean {
|
|
101
|
+
return Number.isFinite(a) && Number.isFinite(b) && b > a;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function combineDetection(a: MeetingDetectionSource, b: MeetingDetectionSource): MeetingDetectionSource {
|
|
105
|
+
if (a === "app+audio" || b === "app+audio") return "app+audio";
|
|
106
|
+
if (a === "provider" || b === "provider") return "provider";
|
|
107
|
+
return "audio";
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function buildCandidates(
|
|
111
|
+
audioWindows: MeetingAudioWindow[],
|
|
112
|
+
appSpans: MeetingAppSpan[],
|
|
113
|
+
config: MeetingsDetectionConfig,
|
|
114
|
+
): Candidate[] {
|
|
115
|
+
const minOverlapMs = config.minOverlapMinutes * 60_000;
|
|
116
|
+
const audioOnlyMs = config.audioOnlyMinMinutes * 60_000;
|
|
117
|
+
const candidates: Candidate[] = [];
|
|
118
|
+
|
|
119
|
+
for (const window of audioWindows) {
|
|
120
|
+
const startMs = ms(window.startUtc);
|
|
121
|
+
const endMs = ms(window.endUtc);
|
|
122
|
+
if (!isFinitePair(startMs, endMs)) continue;
|
|
123
|
+
|
|
124
|
+
if (window.providerMeeting === true) {
|
|
125
|
+
candidates.push({
|
|
126
|
+
startMs,
|
|
127
|
+
endMs,
|
|
128
|
+
detectionSource: "provider",
|
|
129
|
+
sources: [window.source],
|
|
130
|
+
...(window.title !== undefined ? { title: window.title } : {}),
|
|
131
|
+
});
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Best-overlapping meeting-app span (deterministic: max overlap, then earliest start).
|
|
136
|
+
let bestApp: { span: MeetingAppSpan; overlap: number } | undefined;
|
|
137
|
+
for (const span of appSpans) {
|
|
138
|
+
const spanStart = ms(span.startUtc);
|
|
139
|
+
const spanEnd = ms(span.endUtc);
|
|
140
|
+
if (!isFinitePair(spanStart, spanEnd)) continue;
|
|
141
|
+
const overlap = overlapMs(startMs, endMs, spanStart, spanEnd);
|
|
142
|
+
// Require genuine overlap for an app+audio pairing, even when the caller
|
|
143
|
+
// sets minOverlapMinutes to 0 — disjoint windows must not pair.
|
|
144
|
+
if (overlap <= 0 || overlap < minOverlapMs) continue;
|
|
145
|
+
if (bestApp === undefined || overlap > bestApp.overlap) {
|
|
146
|
+
bestApp = { span, overlap };
|
|
147
|
+
} else if (overlap === bestApp.overlap) {
|
|
148
|
+
// Fully deterministic tie-break so nested/duplicate spans covering the
|
|
149
|
+
// same window never depend on input order: earliest start, then app
|
|
150
|
+
// name, then earliest end.
|
|
151
|
+
const bestStart = ms(bestApp.span.startUtc);
|
|
152
|
+
const better =
|
|
153
|
+
spanStart < bestStart ||
|
|
154
|
+
(spanStart === bestStart &&
|
|
155
|
+
(span.app < bestApp.span.app ||
|
|
156
|
+
(span.app === bestApp.span.app && spanEnd < ms(bestApp.span.endUtc))));
|
|
157
|
+
if (better) bestApp = { span, overlap };
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (bestApp !== undefined) {
|
|
162
|
+
candidates.push({
|
|
163
|
+
startMs,
|
|
164
|
+
endMs,
|
|
165
|
+
app: bestApp.span.app,
|
|
166
|
+
detectionSource: "app+audio",
|
|
167
|
+
sources: [window.source],
|
|
168
|
+
...(window.title !== undefined ? { title: window.title } : {}),
|
|
169
|
+
});
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Audio-only fallback: long enough, ≥ 2 distinct non-wearer speakers.
|
|
174
|
+
if (
|
|
175
|
+
endMs - startMs >= audioOnlyMs &&
|
|
176
|
+
Number.isInteger(window.distinctNonWearerSpeakers) &&
|
|
177
|
+
window.distinctNonWearerSpeakers >= 2
|
|
178
|
+
) {
|
|
179
|
+
candidates.push({
|
|
180
|
+
startMs,
|
|
181
|
+
endMs,
|
|
182
|
+
detectionSource: "audio",
|
|
183
|
+
sources: [window.source],
|
|
184
|
+
...(window.title !== undefined ? { title: window.title } : {}),
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return candidates;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Detection-source rank for a total, stable candidate ordering. */
|
|
193
|
+
const DETECTION_RANK: Record<MeetingDetectionSource, number> = {
|
|
194
|
+
"app+audio": 0,
|
|
195
|
+
provider: 1,
|
|
196
|
+
audio: 2,
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
/** Total order over candidates so equal-time spans resolve deterministically. */
|
|
200
|
+
function candidateOrder(a: Candidate, b: Candidate): number {
|
|
201
|
+
return (
|
|
202
|
+
a.startMs - b.startMs ||
|
|
203
|
+
a.endMs - b.endMs ||
|
|
204
|
+
DETECTION_RANK[a.detectionSource] - DETECTION_RANK[b.detectionSource] ||
|
|
205
|
+
(a.app ?? "").localeCompare(b.app ?? "") ||
|
|
206
|
+
(a.title ?? "").localeCompare(b.title ?? "") ||
|
|
207
|
+
(a.sources[0] ?? "").localeCompare(b.sources[0] ?? "")
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Merge candidates so the day's meetings never overlap. Two candidates merge
|
|
213
|
+
* when they overlap, or when they are within `mergeGapMinutes` and share the
|
|
214
|
+
* same app (rejoin-after-drop). Deterministic via `candidateOrder`.
|
|
215
|
+
*/
|
|
216
|
+
function mergeCandidates(candidates: Candidate[], mergeGapMs: number): Candidate[] {
|
|
217
|
+
const sorted = [...candidates].sort(candidateOrder);
|
|
218
|
+
const merged: Candidate[] = [];
|
|
219
|
+
for (const candidate of sorted) {
|
|
220
|
+
const prev = merged[merged.length - 1];
|
|
221
|
+
const overlaps = prev !== undefined && candidate.startMs < prev.endMs;
|
|
222
|
+
const sameAppAdjacent =
|
|
223
|
+
prev !== undefined &&
|
|
224
|
+
prev.app !== undefined &&
|
|
225
|
+
prev.app === candidate.app &&
|
|
226
|
+
candidate.startMs - prev.endMs <= mergeGapMs;
|
|
227
|
+
if (prev !== undefined && (overlaps || sameAppAdjacent)) {
|
|
228
|
+
prev.endMs = Math.max(prev.endMs, candidate.endMs);
|
|
229
|
+
prev.app = prev.app ?? candidate.app;
|
|
230
|
+
prev.detectionSource = combineDetection(prev.detectionSource, candidate.detectionSource);
|
|
231
|
+
prev.sources = [...new Set([...prev.sources, ...candidate.sources])];
|
|
232
|
+
prev.title = prev.title ?? candidate.title;
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
merged.push({ ...candidate, sources: [...candidate.sources] });
|
|
236
|
+
}
|
|
237
|
+
return merged;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function assertFiniteNonNegative(name: string, value: number): void {
|
|
241
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
242
|
+
throw new RangeError(`meetings config "${name}" must be a finite, non-negative number (got ${value}).`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function isValidDay(date: string): boolean {
|
|
247
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date);
|
|
248
|
+
if (m === null) return false;
|
|
249
|
+
const [, year, month, day] = m.map(Number);
|
|
250
|
+
const daysInMonth = month >= 1 && month <= 12 ? new Date(Date.UTC(year, month, 0)).getUTCDate() : 0;
|
|
251
|
+
return day >= 1 && day <= daysInMonth;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function validateConfig(config: MeetingsDetectionConfig): void {
|
|
255
|
+
assertFiniteNonNegative("minOverlapMinutes", config.minOverlapMinutes);
|
|
256
|
+
assertFiniteNonNegative("audioOnlyMinMinutes", config.audioOnlyMinMinutes);
|
|
257
|
+
assertFiniteNonNegative("mergeGapMinutes", config.mergeGapMinutes);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Detect the day's non-overlapping meetings from its audio + app-span signals. */
|
|
261
|
+
export function detectMeetings(
|
|
262
|
+
input: MeetingsDetectionInput,
|
|
263
|
+
config: MeetingsDetectionConfig = DEFAULT_MEETINGS_DETECTION_CONFIG,
|
|
264
|
+
): DetectedMeeting[] {
|
|
265
|
+
validateConfig(config);
|
|
266
|
+
if (!isValidDay(input.date)) {
|
|
267
|
+
// The day is embedded verbatim in every meeting id (mtg-<date>-<hash>); a
|
|
268
|
+
// non-YYYY-MM-DD value would produce malformed ids (e.g. slashes).
|
|
269
|
+
throw new RangeError(`meetings: invalid day "${input.date}"; expected a real YYYY-MM-DD.`);
|
|
270
|
+
}
|
|
271
|
+
const candidates = buildCandidates(input.audioWindows, input.appSpans, config);
|
|
272
|
+
const merged = mergeCandidates(candidates, config.mergeGapMinutes * 60_000);
|
|
273
|
+
return merged.map((candidate) => {
|
|
274
|
+
const startUtc = new Date(candidate.startMs).toISOString();
|
|
275
|
+
const endUtc = new Date(candidate.endMs).toISOString();
|
|
276
|
+
return {
|
|
277
|
+
id: meetingId(input.date, startUtc),
|
|
278
|
+
date: input.date,
|
|
279
|
+
startUtc,
|
|
280
|
+
endUtc,
|
|
281
|
+
...(candidate.app !== undefined ? { app: candidate.app } : {}),
|
|
282
|
+
detectionSource: candidate.detectionSource,
|
|
283
|
+
sources: [...candidate.sources].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)),
|
|
284
|
+
...(candidate.title !== undefined ? { title: candidate.title } : {}),
|
|
285
|
+
};
|
|
286
|
+
});
|
|
287
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public entry for the retrospective meeting-detection subsystem (issue #1900):
|
|
3
|
+
* pure detection over a day's already-ingested audio + app-span signals.
|
|
4
|
+
* Re-exported from the package root (`src/index.ts`) so consumers import it from
|
|
5
|
+
* `@remnic/core`, matching the wearables/activity subsystems' surfacing.
|
|
6
|
+
*/
|
|
7
|
+
export * from "./types.js";
|
|
8
|
+
export * from "./detect.js";
|