@remnic/core 9.12.0 → 9.13.1
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/{chunk-3VBXP5HS.js → chunk-5RZHHANR.js} +195 -14
- package/dist/chunk-5RZHHANR.js.map +1 -0
- package/dist/index.d.ts +105 -1
- package/dist/index.js +9 -1
- package/dist/orchestrator.js +1 -1
- 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,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";
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Meeting-intelligence subsystem — shared types (issue #1900).
|
|
3
|
+
*
|
|
4
|
+
* Retrospective meeting detection over already-ingested day signals: audio
|
|
5
|
+
* conversation windows (from wearable day transcripts, any source) plus
|
|
6
|
+
* meeting-app foreground spans (derived from screen activity in a later slice).
|
|
7
|
+
* This slice is pure detection — no store, no fusion, no surfaces. All
|
|
8
|
+
* timestamps are UTC ISO-8601; windows are half-open [startUtc, endUtc).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** A contiguous meeting-app foreground span (derived from activity in a later slice). */
|
|
12
|
+
export interface MeetingAppSpan {
|
|
13
|
+
/** Meeting app label (e.g. "Zoom", "Google Meet"). */
|
|
14
|
+
app: string;
|
|
15
|
+
startUtc: string;
|
|
16
|
+
endUtc: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** An audio conversation window from a wearable/connector day transcript. */
|
|
20
|
+
export interface MeetingAudioWindow {
|
|
21
|
+
/** Wearable source id the conversation came from (desktop, limitless, granola, …). */
|
|
22
|
+
source: string;
|
|
23
|
+
startUtc: string;
|
|
24
|
+
endUtc: string;
|
|
25
|
+
/** Distinct non-wearer speakers in the conversation (drives the audio-only rule). */
|
|
26
|
+
distinctNonWearerSpeakers: number;
|
|
27
|
+
/**
|
|
28
|
+
* True when the source is a cloud meeting provider that supplies explicit
|
|
29
|
+
* meeting boundaries (Granola/Fireflies): such a window is a meeting on its
|
|
30
|
+
* own, without a matching app span.
|
|
31
|
+
*/
|
|
32
|
+
providerMeeting?: boolean;
|
|
33
|
+
/** Provider-supplied meeting title, when available. */
|
|
34
|
+
title?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** How a meeting was detected. */
|
|
38
|
+
export type MeetingDetectionSource = "app+audio" | "audio" | "provider";
|
|
39
|
+
|
|
40
|
+
/** One detected meeting for a day (non-overlapping after merge). */
|
|
41
|
+
export interface DetectedMeeting {
|
|
42
|
+
/** Stable id `mtg-<date>-<hash>`, hashed from date + the exact START instant
|
|
43
|
+
* (app + end deliberately excluded) so a resync that grows the meeting never
|
|
44
|
+
* renumbers it, while non-overlapping meetings keep distinct ids. */
|
|
45
|
+
id: string;
|
|
46
|
+
/** Local day YYYY-MM-DD. */
|
|
47
|
+
date: string;
|
|
48
|
+
startUtc: string;
|
|
49
|
+
endUtc: string;
|
|
50
|
+
/** Meeting app, when app context contributed to detection. */
|
|
51
|
+
app?: string;
|
|
52
|
+
detectionSource: MeetingDetectionSource;
|
|
53
|
+
/** Contributing wearable source ids, sorted, de-duplicated. */
|
|
54
|
+
sources: string[];
|
|
55
|
+
/** Title, when a provider supplied one. */
|
|
56
|
+
title?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Per-day detection input (assembled by a later wiring slice). */
|
|
60
|
+
export interface MeetingsDetectionInput {
|
|
61
|
+
date: string;
|
|
62
|
+
appSpans: MeetingAppSpan[];
|
|
63
|
+
audioWindows: MeetingAudioWindow[];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Detection-relevant configuration (full parseConfig wiring lands in a later slice). */
|
|
67
|
+
export interface MeetingsDetectionConfig {
|
|
68
|
+
/** Meeting-app match patterns (used when deriving app spans from activity). */
|
|
69
|
+
appPatterns: string[];
|
|
70
|
+
/** Min app-span ∩ audio-window overlap to pair them (minutes). */
|
|
71
|
+
minOverlapMinutes: number;
|
|
72
|
+
/** Audio-only fallback: min conversation length (minutes). */
|
|
73
|
+
audioOnlyMinMinutes: number;
|
|
74
|
+
/** Merge candidates within this gap of each other (minutes). */
|
|
75
|
+
mergeGapMinutes: number;
|
|
76
|
+
}
|