@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.
@@ -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";