@indigoai-us/hq-cli 5.345.178 → 5.345.180
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/CHANGELOG.md +39 -0
- package/dist/command-catalog.generated.d.ts +197 -1
- package/dist/command-catalog.generated.js +241 -1
- package/dist/commands/bot-jobs.d.ts +11 -9
- package/dist/commands/bot-jobs.js +56 -21
- package/dist/commands/bot.d.ts +37 -1
- package/dist/commands/bot.js +289 -13
- package/dist/commands/channels.d.ts +18 -1
- package/dist/commands/channels.js +185 -1
- package/dist/commands/daemon.d.ts +2 -0
- package/dist/commands/daemon.js +15 -4
- package/dist/commands/integrations-core.d.ts +2 -0
- package/dist/commands/integrations-core.js +1 -0
- package/dist/commands/lanes.d.ts +1 -1
- package/dist/commands/lanes.js +6 -2
- package/dist/commands/mesh.js +19 -0
- package/dist/commands/sources.d.ts +89 -0
- package/dist/commands/sources.js +363 -1
- package/dist/hq-cloud-sync-runner/bin/sync-runner.js +2285 -1743
- package/dist/hq-cloud-sync-runner/package.json +1 -1
- package/dist/lib/bot/config.d.ts +8 -0
- package/dist/lib/bot/fleet-local.d.ts +1 -1
- package/dist/lib/bot/fleet-local.js +82 -30
- package/dist/lib/bot/jobs/hot-signal-sync.d.ts +109 -0
- package/dist/lib/bot/jobs/hot-signal-sync.js +177 -0
- package/dist/lib/bot/local-jobs.d.ts +40 -4
- package/dist/lib/bot/local-jobs.js +72 -5
- package/dist/lib/bot/run.d.ts +15 -0
- package/dist/lib/bot/run.js +99 -1
- package/dist/lib/bot/runtime-auth.d.ts +119 -0
- package/dist/lib/bot/runtime-auth.js +251 -0
- package/dist/lib/daemon/catalog.d.ts +1 -0
- package/dist/lib/daemon/catalog.js +1 -1
- package/dist/lib/daemon/handoff.d.ts +4 -0
- package/dist/lib/daemon/handoff.js +6 -2
- package/dist/lib/daemon/install.d.ts +70 -2
- package/dist/lib/daemon/install.js +185 -27
- package/dist/lib/daemon/legacy.d.ts +9 -3
- package/dist/lib/daemon/legacy.js +11 -4
- package/dist/lib/daemon/migrate.js +1 -1
- package/dist/lib/daemon/run.d.ts +5 -2
- package/dist/lib/daemon/run.js +74 -22
- package/dist/lib/doctor/checks/anywhere.d.ts +6 -0
- package/dist/lib/doctor/checks/anywhere.js +35 -6
- package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
- package/dist/lib/flag-registry-worker-source.generated.js +1 -1
- package/dist/lib/hot-channels/adapters/__fixtures__/dummy.d.ts +33 -0
- package/dist/lib/hot-channels/adapters/__fixtures__/dummy.js +53 -0
- package/dist/lib/hot-channels/adapters/__fixtures__/meeting-providers.d.ts +38 -0
- package/dist/lib/hot-channels/adapters/__fixtures__/meeting-providers.js +64 -0
- package/dist/lib/hot-channels/adapters/attio.d.ts +67 -0
- package/dist/lib/hot-channels/adapters/attio.js +374 -0
- package/dist/lib/hot-channels/adapters/gmail.d.ts +111 -0
- package/dist/lib/hot-channels/adapters/gmail.js +413 -0
- package/dist/lib/hot-channels/adapters/index.d.ts +23 -0
- package/dist/lib/hot-channels/adapters/index.js +39 -0
- package/dist/lib/hot-channels/adapters/linear.d.ts +79 -0
- package/dist/lib/hot-channels/adapters/linear.js +321 -0
- package/dist/lib/hot-channels/adapters/meeting-providers.d.ts +86 -0
- package/dist/lib/hot-channels/adapters/meeting-providers.js +253 -0
- package/dist/lib/hot-channels/adapters/meetings.d.ts +81 -0
- package/dist/lib/hot-channels/adapters/meetings.js +525 -0
- package/dist/lib/hot-channels/adapters/slack.d.ts +70 -0
- package/dist/lib/hot-channels/adapters/slack.js +277 -0
- package/dist/lib/hot-channels/alert.d.ts +78 -0
- package/dist/lib/hot-channels/alert.js +179 -0
- package/dist/lib/hot-channels/audience.d.ts +15 -0
- package/dist/lib/hot-channels/audience.js +25 -0
- package/dist/lib/hot-channels/contract.d.ts +146 -0
- package/dist/lib/hot-channels/contract.js +20 -0
- package/dist/lib/hot-channels/cursors.d.ts +41 -0
- package/dist/lib/hot-channels/cursors.js +84 -0
- package/dist/lib/hot-channels/index.d.ts +10 -0
- package/dist/lib/hot-channels/index.js +10 -0
- package/dist/lib/hot-channels/landing.d.ts +83 -0
- package/dist/lib/hot-channels/landing.js +175 -0
- package/dist/lib/hot-channels/ledger.d.ts +101 -0
- package/dist/lib/hot-channels/ledger.js +203 -0
- package/dist/lib/hot-channels/loop.d.ts +74 -0
- package/dist/lib/hot-channels/loop.js +221 -0
- package/dist/lib/hot-channels/prompts/signals-pass.md +22 -0
- package/dist/lib/hot-channels/registry.d.ts +28 -0
- package/dist/lib/hot-channels/registry.js +121 -0
- package/dist/lib/hot-channels/run.d.ts +137 -0
- package/dist/lib/hot-channels/run.js +324 -0
- package/dist/lib/hot-channels/signals-pass.d.ts +150 -0
- package/dist/lib/hot-channels/signals-pass.js +581 -0
- package/dist/lib/hot-channels/sync-lock.d.ts +51 -0
- package/dist/lib/hot-channels/sync-lock.js +224 -0
- package/dist/lib/install/hook-target.d.ts +6 -2
- package/dist/lib/install/hook-target.js +9 -4
- package/dist/lib/lanes/accounts.d.ts +1 -17
- package/dist/lib/lanes/accounts.js +5 -45
- package/dist/lib/lanes/loop.d.ts +10 -0
- package/dist/lib/lanes/loop.js +18 -1
- package/dist/lib/lanes/provider-home.d.ts +24 -0
- package/dist/lib/lanes/provider-home.js +52 -0
- package/dist/lib/lanes/spawn.d.ts +12 -0
- package/dist/lib/lanes/spawn.js +27 -0
- package/dist/lib/mesh/live/daemon/doctor.d.ts +3 -0
- package/dist/lib/mesh/live/daemon/doctor.js +13 -0
- package/dist/lib/mesh/live/daemon/owner-inbox-push.d.ts +94 -0
- package/dist/lib/mesh/live/daemon/owner-inbox-push.js +273 -0
- package/dist/lib/mesh/live/daemon/run.js +31 -0
- package/dist/lib/mesh/live/daemon/work-subscribe.d.ts +14 -0
- package/dist/lib/mesh/live/daemon/work-subscribe.js +210 -3
- package/dist/lib/mesh/live/paths.d.ts +2 -0
- package/dist/lib/mesh/live/paths.js +2 -0
- package/dist/lib/sync-core/flag.d.ts +1 -1
- package/dist/lib/sync-core/flag.js +1 -1
- package/dist/lib/sync-core/reconcile.d.ts +1 -1
- package/dist/lib/sync-core/reconcile.js +9 -7
- package/dist/lib/sync-core/release.d.ts +3 -3
- package/dist/lib/sync-core/release.js +4 -4
- package/dist/lib/sync-core/rollout-state.d.ts +1 -0
- package/dist/lib/sync-core/rollout-state.js +1 -0
- package/package.json +8 -8
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Linear hot channel (hot-signal-sync US-007).
|
|
3
|
+
*
|
|
4
|
+
* A poll adapter over the gateway's `list_issues` tool on the person's own
|
|
5
|
+
* Linear connection. It replaces the hq-pro Linear cron's watermark logic:
|
|
6
|
+
* list the issues assigned to the person whose `updatedAt` is after the
|
|
7
|
+
* watermark, ordered by `updatedAt`, and page through them with the gateway's
|
|
8
|
+
* cursor. The token stays in the gateway; this adapter never sees it.
|
|
9
|
+
*
|
|
10
|
+
* Budget: Linear allows 2,500 API requests per hour per personal token. One
|
|
11
|
+
* fetch is one request, and the loop stops after 20 pages per adapter per run,
|
|
12
|
+
* so a run spends at most 20 requests (2,000 issues at 100 per page). Comments
|
|
13
|
+
* are not fetched per issue; doing that is what blew the cron's budget.
|
|
14
|
+
*
|
|
15
|
+
* The cursor is a JSON object `{ watermark, after, high }`:
|
|
16
|
+
* - `watermark`: issues with `updatedAt` at or before it were already seen.
|
|
17
|
+
* On a first run it is the catch-up `since`, and every pass starts from
|
|
18
|
+
* `max(watermark, since)` so a long absence never pages through issues the
|
|
19
|
+
* loop would drop as stale.
|
|
20
|
+
* - `after`: the gateway cursor of the next page while a pass is in progress.
|
|
21
|
+
* - `high`: the newest `updatedAt` seen so far in the pass.
|
|
22
|
+
* The watermark advances to `high` only when a pass completes (no next page).
|
|
23
|
+
* A run cut short by the page cap resumes the same pass on the next run with
|
|
24
|
+
* the old watermark, so an issue on a later page is never skipped. A cursor
|
|
25
|
+
* that does not parse falls back to `since`.
|
|
26
|
+
*
|
|
27
|
+
* Each landed document is a pointer in the hq-pro Linear source shape: the
|
|
28
|
+
* frontmatter carries the issue's identifier, state, priority, assignee,
|
|
29
|
+
* team, labels and url, the body is a short header with no description, and
|
|
30
|
+
* the `.raw.json` sidecar (named by `payload_ref`) holds the issue metadata.
|
|
31
|
+
* The event key is `linear:{issueId}:{updatedAt}`, so each update of an issue
|
|
32
|
+
* lands once.
|
|
33
|
+
*
|
|
34
|
+
* A revoked or expired token (`invalid_grant`) surfaces from the gateway call;
|
|
35
|
+
* run.ts turns it into a reconnect prompt in the run ledger and the cursor
|
|
36
|
+
* does not move.
|
|
37
|
+
*/
|
|
38
|
+
export const LINEAR_ADAPTER_ID = "linear";
|
|
39
|
+
export const LINEAR_CHANNEL = "linear";
|
|
40
|
+
export const LINEAR_PROVIDER = "linear";
|
|
41
|
+
export const LINEAR_ISSUE_UPDATED_EVENT = "linear.issue.updated";
|
|
42
|
+
export const LINEAR_LIST_ISSUES_TOOL = "list_issues";
|
|
43
|
+
/** Linear's request budget for one personal token. */
|
|
44
|
+
export const LINEAR_REQUESTS_PER_HOUR = 2500;
|
|
45
|
+
export const LINEAR_CATCH_UP_WINDOW_MS = 14 * 24 * 60 * 60 * 1000;
|
|
46
|
+
const SUGGESTION_WINDOW_MS = 14 * 24 * 60 * 60 * 1000;
|
|
47
|
+
const DEFAULT_PAGE_SIZE = 100;
|
|
48
|
+
const MAX_PAGE_SIZE = 250;
|
|
49
|
+
export const LINEAR_ISSUE_FIELDS = [
|
|
50
|
+
"id",
|
|
51
|
+
"uuid",
|
|
52
|
+
"title",
|
|
53
|
+
"priority",
|
|
54
|
+
"url",
|
|
55
|
+
"createdAt",
|
|
56
|
+
"updatedAt",
|
|
57
|
+
"completedAt",
|
|
58
|
+
"dueDate",
|
|
59
|
+
"status",
|
|
60
|
+
"statusType",
|
|
61
|
+
"labels",
|
|
62
|
+
"assignee",
|
|
63
|
+
"project",
|
|
64
|
+
"team",
|
|
65
|
+
];
|
|
66
|
+
const PRIORITY_LABELS = {
|
|
67
|
+
0: "No priority",
|
|
68
|
+
1: "Urgent",
|
|
69
|
+
2: "High",
|
|
70
|
+
3: "Medium",
|
|
71
|
+
4: "Low",
|
|
72
|
+
};
|
|
73
|
+
function nonEmptyString(value) {
|
|
74
|
+
return typeof value === "string" && value.trim() !== "" ? value : undefined;
|
|
75
|
+
}
|
|
76
|
+
function isRecord(value) {
|
|
77
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
78
|
+
}
|
|
79
|
+
function isIsoTime(value) {
|
|
80
|
+
return typeof value === "string" && !Number.isNaN(Date.parse(value));
|
|
81
|
+
}
|
|
82
|
+
/** A name field may be a plain string or an object with a `name` (or `displayName`). */
|
|
83
|
+
function nameOf(value) {
|
|
84
|
+
if (isRecord(value))
|
|
85
|
+
return nonEmptyString(value.name) ?? nonEmptyString(value.displayName);
|
|
86
|
+
return nonEmptyString(value);
|
|
87
|
+
}
|
|
88
|
+
function laterOf(a, b) {
|
|
89
|
+
return Date.parse(b) > Date.parse(a) ? b : a;
|
|
90
|
+
}
|
|
91
|
+
export function parseLinearCursor(cursor, since) {
|
|
92
|
+
const fresh = { watermark: since, after: null, high: null };
|
|
93
|
+
if (!cursor)
|
|
94
|
+
return fresh;
|
|
95
|
+
let parsed;
|
|
96
|
+
try {
|
|
97
|
+
parsed = JSON.parse(cursor);
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return fresh;
|
|
101
|
+
}
|
|
102
|
+
if (!isRecord(parsed) || !isIsoTime(parsed.watermark))
|
|
103
|
+
return fresh;
|
|
104
|
+
const after = nonEmptyString(parsed.after) ?? null;
|
|
105
|
+
const high = isIsoTime(parsed.high) ? parsed.high : null;
|
|
106
|
+
// A new pass never starts before the catch-up window; a pass in progress
|
|
107
|
+
// keeps its watermark so its later pages filter the same way.
|
|
108
|
+
const watermark = after === null ? laterOf(parsed.watermark, since) : parsed.watermark;
|
|
109
|
+
return { watermark, after, high };
|
|
110
|
+
}
|
|
111
|
+
function serializeCursor(cursor) {
|
|
112
|
+
return JSON.stringify(cursor);
|
|
113
|
+
}
|
|
114
|
+
/** Unwrap a gateway result: a JSON string, an MCP `content[].text` block, or an object. */
|
|
115
|
+
function unwrapResult(result) {
|
|
116
|
+
if (typeof result === "string") {
|
|
117
|
+
try {
|
|
118
|
+
return JSON.parse(result);
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return result;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (isRecord(result) && Array.isArray(result.content)) {
|
|
125
|
+
const text = result.content
|
|
126
|
+
.map((block) => (isRecord(block) ? nonEmptyString(block.text) : undefined))
|
|
127
|
+
.find((value) => value !== undefined);
|
|
128
|
+
if (text !== undefined)
|
|
129
|
+
return unwrapResult(text);
|
|
130
|
+
}
|
|
131
|
+
return result;
|
|
132
|
+
}
|
|
133
|
+
function parseIssuePage(result) {
|
|
134
|
+
const body = unwrapResult(result);
|
|
135
|
+
if (Array.isArray(body))
|
|
136
|
+
return { issues: body, hasNextPage: false, nextCursor: null };
|
|
137
|
+
if (!isRecord(body))
|
|
138
|
+
throw new Error("Linear list_issues returned an unexpected response");
|
|
139
|
+
const issues = Array.isArray(body.issues) ? body.issues : Array.isArray(body.nodes) ? body.nodes : null;
|
|
140
|
+
if (issues === null)
|
|
141
|
+
throw new Error("Linear list_issues returned no issues list");
|
|
142
|
+
const pageInfo = isRecord(body.pageInfo) ? body.pageInfo : {};
|
|
143
|
+
const hasNextPage = body.hasNextPage === true || pageInfo.hasNextPage === true;
|
|
144
|
+
const nextCursor = nonEmptyString(body.cursor) ?? nonEmptyString(body.endCursor) ?? nonEmptyString(pageInfo.endCursor) ?? null;
|
|
145
|
+
return { issues, hasNextPage: hasNextPage && nextCursor !== null, nextCursor };
|
|
146
|
+
}
|
|
147
|
+
function labelsOf(value) {
|
|
148
|
+
const list = isRecord(value) && Array.isArray(value.nodes) ? value.nodes : value;
|
|
149
|
+
if (!Array.isArray(list))
|
|
150
|
+
return [];
|
|
151
|
+
return list.map((label) => nameOf(label)).filter((name) => name !== undefined);
|
|
152
|
+
}
|
|
153
|
+
/** Normalize one `list_issues` item. Returns undefined when it has no id or `updatedAt`. */
|
|
154
|
+
export function toLinearIssue(item) {
|
|
155
|
+
if (!isRecord(item))
|
|
156
|
+
return undefined;
|
|
157
|
+
const updatedAt = isIsoTime(item.updatedAt) ? item.updatedAt : undefined;
|
|
158
|
+
const id = nonEmptyString(item.id);
|
|
159
|
+
const uuid = nonEmptyString(item.uuid) ?? id;
|
|
160
|
+
const identifier = nonEmptyString(item.identifier) ?? id ?? uuid;
|
|
161
|
+
if (!updatedAt || !uuid || !identifier)
|
|
162
|
+
return undefined;
|
|
163
|
+
const status = item.status ?? item.state;
|
|
164
|
+
const issue = {
|
|
165
|
+
uuid,
|
|
166
|
+
identifier,
|
|
167
|
+
title: nonEmptyString(item.title) ?? identifier,
|
|
168
|
+
updatedAt,
|
|
169
|
+
labels: labelsOf(item.labels),
|
|
170
|
+
};
|
|
171
|
+
const createdAt = nonEmptyString(item.createdAt);
|
|
172
|
+
const completedAt = nonEmptyString(item.completedAt);
|
|
173
|
+
const dueDate = nonEmptyString(item.dueDate);
|
|
174
|
+
const url = nonEmptyString(item.url);
|
|
175
|
+
const priority = isRecord(item.priority) ? item.priority.value : item.priority;
|
|
176
|
+
const state = nameOf(status);
|
|
177
|
+
const stateType = nonEmptyString(item.statusType) ?? (isRecord(status) ? nonEmptyString(status.type) : undefined);
|
|
178
|
+
const assignee = nameOf(item.assignee);
|
|
179
|
+
const team = nameOf(item.team);
|
|
180
|
+
const project = nameOf(item.project);
|
|
181
|
+
if (createdAt)
|
|
182
|
+
issue.createdAt = createdAt;
|
|
183
|
+
if (completedAt)
|
|
184
|
+
issue.completedAt = completedAt;
|
|
185
|
+
if (dueDate)
|
|
186
|
+
issue.dueDate = dueDate;
|
|
187
|
+
if (url)
|
|
188
|
+
issue.url = url;
|
|
189
|
+
if (typeof priority === "number" && Number.isInteger(priority))
|
|
190
|
+
issue.priority = priority;
|
|
191
|
+
if (state)
|
|
192
|
+
issue.state = state;
|
|
193
|
+
if (stateType)
|
|
194
|
+
issue.stateType = stateType;
|
|
195
|
+
if (assignee)
|
|
196
|
+
issue.assignee = assignee;
|
|
197
|
+
if (team)
|
|
198
|
+
issue.team = team;
|
|
199
|
+
if (project)
|
|
200
|
+
issue.project = project;
|
|
201
|
+
return issue;
|
|
202
|
+
}
|
|
203
|
+
function priorityLabel(priority) {
|
|
204
|
+
return PRIORITY_LABELS[priority] ?? `P${priority}`;
|
|
205
|
+
}
|
|
206
|
+
/** Frontmatter in the hq-pro Linear source shape, minus fields landing reserves. */
|
|
207
|
+
function frontmatterFor(issue) {
|
|
208
|
+
const fm = {
|
|
209
|
+
issue_id: issue.uuid,
|
|
210
|
+
identifier: issue.identifier,
|
|
211
|
+
issue_updated_at: issue.updatedAt,
|
|
212
|
+
};
|
|
213
|
+
if (issue.state)
|
|
214
|
+
fm.state = issue.state;
|
|
215
|
+
if (issue.stateType)
|
|
216
|
+
fm.state_type = issue.stateType;
|
|
217
|
+
if (issue.priority !== undefined) {
|
|
218
|
+
fm.priority = priorityLabel(issue.priority);
|
|
219
|
+
fm.priority_number = String(issue.priority);
|
|
220
|
+
}
|
|
221
|
+
if (issue.assignee)
|
|
222
|
+
fm.assignee = issue.assignee;
|
|
223
|
+
if (issue.team)
|
|
224
|
+
fm.team = issue.team;
|
|
225
|
+
if (issue.project)
|
|
226
|
+
fm.project = issue.project;
|
|
227
|
+
if (issue.labels.length > 0)
|
|
228
|
+
fm.labels = issue.labels.join(", ");
|
|
229
|
+
if (issue.dueDate)
|
|
230
|
+
fm.due_date = issue.dueDate;
|
|
231
|
+
if (issue.createdAt)
|
|
232
|
+
fm.issue_created_at = issue.createdAt;
|
|
233
|
+
if (issue.completedAt)
|
|
234
|
+
fm.completed_at = issue.completedAt;
|
|
235
|
+
if (issue.url)
|
|
236
|
+
fm.url = issue.url;
|
|
237
|
+
return fm;
|
|
238
|
+
}
|
|
239
|
+
function bodyFor(issue) {
|
|
240
|
+
const lines = [`Linear issue ${issue.identifier} updated ${issue.updatedAt}.`];
|
|
241
|
+
if (issue.state)
|
|
242
|
+
lines.push(`State: ${issue.state}`);
|
|
243
|
+
if (issue.priority !== undefined)
|
|
244
|
+
lines.push(`Priority: ${priorityLabel(issue.priority)}`);
|
|
245
|
+
if (issue.team)
|
|
246
|
+
lines.push(`Team: ${issue.team}`);
|
|
247
|
+
if (issue.project)
|
|
248
|
+
lines.push(`Project: ${issue.project}`);
|
|
249
|
+
if (issue.url)
|
|
250
|
+
lines.push(`Link: ${issue.url}`);
|
|
251
|
+
return lines.join("\n");
|
|
252
|
+
}
|
|
253
|
+
function toEvent(issue, personUid) {
|
|
254
|
+
return {
|
|
255
|
+
providerId: issue.uuid,
|
|
256
|
+
version: issue.updatedAt,
|
|
257
|
+
who: personUid,
|
|
258
|
+
what: LINEAR_ISSUE_UPDATED_EVENT,
|
|
259
|
+
where: issue.project ? `${issue.team ?? "linear"}/${issue.project}` : (issue.team ?? "linear"),
|
|
260
|
+
when: issue.updatedAt,
|
|
261
|
+
audience: [personUid],
|
|
262
|
+
title: `${issue.identifier} ${issue.title}`,
|
|
263
|
+
body: bodyFor(issue),
|
|
264
|
+
raw: issue,
|
|
265
|
+
frontmatter: frontmatterFor(issue),
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
export function createLinearAdapter(options = {}) {
|
|
269
|
+
const pageSize = Math.min(MAX_PAGE_SIZE, Math.max(1, Math.floor(options.pageSize ?? DEFAULT_PAGE_SIZE)));
|
|
270
|
+
async function fetch(cursor, ctx) {
|
|
271
|
+
if (!ctx.gateway)
|
|
272
|
+
throw new Error("Linear hot channel needs a gateway connection");
|
|
273
|
+
const state = parseLinearCursor(cursor, ctx.since);
|
|
274
|
+
const args = {
|
|
275
|
+
assignee: "me",
|
|
276
|
+
updatedAt: state.watermark,
|
|
277
|
+
orderBy: "updatedAt",
|
|
278
|
+
limit: pageSize,
|
|
279
|
+
fields: [...LINEAR_ISSUE_FIELDS],
|
|
280
|
+
};
|
|
281
|
+
if (state.after)
|
|
282
|
+
args.cursor = state.after;
|
|
283
|
+
const page = parseIssuePage(await ctx.gateway.call(LINEAR_LIST_ISSUES_TOOL, args));
|
|
284
|
+
const watermarkMs = Date.parse(state.watermark);
|
|
285
|
+
let high = state.high;
|
|
286
|
+
const events = [];
|
|
287
|
+
for (const item of page.issues) {
|
|
288
|
+
const issue = toLinearIssue(item);
|
|
289
|
+
// Strictly after the watermark: an issue updated exactly at it was seen.
|
|
290
|
+
if (!issue || Date.parse(issue.updatedAt) <= watermarkMs)
|
|
291
|
+
continue;
|
|
292
|
+
high = high === null ? issue.updatedAt : laterOf(high, issue.updatedAt);
|
|
293
|
+
events.push(toEvent(issue, ctx.personUid));
|
|
294
|
+
}
|
|
295
|
+
if (page.hasNextPage) {
|
|
296
|
+
return {
|
|
297
|
+
events,
|
|
298
|
+
cursor: serializeCursor({ watermark: state.watermark, after: page.nextCursor, high }),
|
|
299
|
+
hasMore: true,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
return {
|
|
303
|
+
events,
|
|
304
|
+
cursor: serializeCursor({ watermark: high ?? state.watermark, after: null, high: null }),
|
|
305
|
+
hasMore: false,
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
return {
|
|
309
|
+
id: LINEAR_ADAPTER_ID,
|
|
310
|
+
channel: LINEAR_CHANNEL,
|
|
311
|
+
transport: "poll",
|
|
312
|
+
hotEventTypes: [LINEAR_ISSUE_UPDATED_EVENT],
|
|
313
|
+
catchUpWindow: { maxLookbackMs: LINEAR_CATCH_UP_WINDOW_MS },
|
|
314
|
+
tokenScope: { provider: LINEAR_PROVIDER, scopes: ["read"] },
|
|
315
|
+
// Declared as data only. US-014 evaluates it, together with issues
|
|
316
|
+
// assigned to the person and projects with recent updates.
|
|
317
|
+
suggestionRule: { kind: "recent-activity", withinMs: SUGGESTION_WINDOW_MS, minEvents: 1 },
|
|
318
|
+
fetch,
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
//# sourceMappingURL=linear.js.map
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Meeting-notes providers for the meetings hot channel: Fireflies and Granola,
|
|
3
|
+
* read through the HQ integration gateway on the person's own connection.
|
|
4
|
+
*
|
|
5
|
+
* A person who has no HQ meeting bot yet (a new user) still records meetings
|
|
6
|
+
* in one of these tools. The meetings adapter lists their recent meetings
|
|
7
|
+
* here, reads each one's notes and transcript, and lands them like a bot
|
|
8
|
+
* transcript. Only read tools are called.
|
|
9
|
+
*
|
|
10
|
+
* Gateway tools (shapes taken from live indigo responses, 2026-10-29):
|
|
11
|
+
* - Fireflies `fireflies_get_transcripts` ({ fromDate, toDate, limit<=50, skip })
|
|
12
|
+
* returns a TOON-style text list (`- id: …`, `dateString`, `participants[n]: a,b`);
|
|
13
|
+
* `fireflies_get_transcript` and `fireflies_get_summary` ({ transcriptId })
|
|
14
|
+
* return `Key: value` text with the sentences and the summary.
|
|
15
|
+
* - Granola `list_meetings` ({ time_range: "custom", from_date, to_date,
|
|
16
|
+
* involvement, limit, cursor }) returns XML-ish text (`<meeting …>` elements,
|
|
17
|
+
* `<pagination has_more next_cursor>`); `get_meetings` ({ meeting_ids })
|
|
18
|
+
* returns notes, AI summary and attendees; `get_meeting_transcript`
|
|
19
|
+
* ({ meeting_id }) returns the transcript on plans that include it.
|
|
20
|
+
*/
|
|
21
|
+
import type { HotChannelGateway } from "../contract.js";
|
|
22
|
+
export declare const FIREFLIES_PROVIDER = "fireflies";
|
|
23
|
+
export declare const GRANOLA_PROVIDER = "granola";
|
|
24
|
+
export declare const MEETING_NOTE_PROVIDERS: readonly string[];
|
|
25
|
+
export declare const FIREFLIES_LIST_TOOL = "fireflies_get_transcripts";
|
|
26
|
+
export declare const FIREFLIES_TRANSCRIPT_TOOL = "fireflies_get_transcript";
|
|
27
|
+
export declare const FIREFLIES_SUMMARY_TOOL = "fireflies_get_summary";
|
|
28
|
+
export declare const GRANOLA_LIST_TOOL = "list_meetings";
|
|
29
|
+
export declare const GRANOLA_GET_TOOL = "get_meetings";
|
|
30
|
+
export declare const GRANOLA_TRANSCRIPT_TOOL = "get_meeting_transcript";
|
|
31
|
+
/** One meeting as listed by a provider, before its content is read. */
|
|
32
|
+
export interface ProviderMeeting {
|
|
33
|
+
provider: string;
|
|
34
|
+
id: string;
|
|
35
|
+
title: string;
|
|
36
|
+
/** Meeting start, epoch ms. */
|
|
37
|
+
startMs: number;
|
|
38
|
+
participants: string[];
|
|
39
|
+
organizer?: string;
|
|
40
|
+
meetingUrl?: string;
|
|
41
|
+
calendarEventId?: string;
|
|
42
|
+
}
|
|
43
|
+
/** The meeting's readable content, as returned by the provider. */
|
|
44
|
+
export interface ProviderMeetingContent {
|
|
45
|
+
/** Text that goes in the transcript document, provider output verbatim. */
|
|
46
|
+
text: string;
|
|
47
|
+
/** False when the provider has neither a transcript nor notes for it yet. */
|
|
48
|
+
hasContent: boolean;
|
|
49
|
+
}
|
|
50
|
+
export interface MeetingProvider {
|
|
51
|
+
provider: string;
|
|
52
|
+
/** Meetings that started in [fromMs, toMs], oldest first. */
|
|
53
|
+
list(gateway: HotChannelGateway, fromMs: number, toMs: number): Promise<ProviderMeeting[]>;
|
|
54
|
+
read(gateway: HotChannelGateway, meeting: ProviderMeeting): Promise<ProviderMeetingContent>;
|
|
55
|
+
}
|
|
56
|
+
/** The text of a gateway result: a string, or the MCP `content[].text` blocks joined. */
|
|
57
|
+
export declare function resultText(result: unknown): string;
|
|
58
|
+
/** Parse the Fireflies `fireflies_get_transcripts` text list. */
|
|
59
|
+
export declare function parseFirefliesList(text: string): ProviderMeeting[];
|
|
60
|
+
export declare const firefliesProvider: MeetingProvider;
|
|
61
|
+
/** Parse the Granola `list_meetings` text: `<meeting …>` elements and `<pagination …>`. */
|
|
62
|
+
export declare function parseGranolaList(text: string): {
|
|
63
|
+
meetings: ProviderMeeting[];
|
|
64
|
+
nextCursor: string | null;
|
|
65
|
+
};
|
|
66
|
+
export declare const granolaProvider: MeetingProvider;
|
|
67
|
+
export declare const MEETING_PROVIDERS: ReadonlyMap<string, MeetingProvider>;
|
|
68
|
+
/** Lowercased title with punctuation and spacing collapsed. */
|
|
69
|
+
export declare function normalizeTitle(title: string): string;
|
|
70
|
+
/** The facts two recordings of one meeting share. */
|
|
71
|
+
export interface MeetingIdentity {
|
|
72
|
+
startMs: number;
|
|
73
|
+
title: string;
|
|
74
|
+
participants: readonly string[];
|
|
75
|
+
meetingUrl?: string;
|
|
76
|
+
calendarEventId?: string;
|
|
77
|
+
}
|
|
78
|
+
/** Recordings that start this close together may be the same meeting. */
|
|
79
|
+
export declare const SAME_MEETING_WINDOW_MS: number;
|
|
80
|
+
/**
|
|
81
|
+
* Whether two recordings are the same meeting: the same calendar event, or
|
|
82
|
+
* starts within 30 minutes plus the same call link, a matching title, or at
|
|
83
|
+
* least two participants and more than half of the smaller list in common.
|
|
84
|
+
*/
|
|
85
|
+
export declare function sameMeeting(a: MeetingIdentity, b: MeetingIdentity): boolean;
|
|
86
|
+
//# sourceMappingURL=meeting-providers.d.ts.map
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Meeting-notes providers for the meetings hot channel: Fireflies and Granola,
|
|
3
|
+
* read through the HQ integration gateway on the person's own connection.
|
|
4
|
+
*
|
|
5
|
+
* A person who has no HQ meeting bot yet (a new user) still records meetings
|
|
6
|
+
* in one of these tools. The meetings adapter lists their recent meetings
|
|
7
|
+
* here, reads each one's notes and transcript, and lands them like a bot
|
|
8
|
+
* transcript. Only read tools are called.
|
|
9
|
+
*
|
|
10
|
+
* Gateway tools (shapes taken from live indigo responses, 2026-10-29):
|
|
11
|
+
* - Fireflies `fireflies_get_transcripts` ({ fromDate, toDate, limit<=50, skip })
|
|
12
|
+
* returns a TOON-style text list (`- id: …`, `dateString`, `participants[n]: a,b`);
|
|
13
|
+
* `fireflies_get_transcript` and `fireflies_get_summary` ({ transcriptId })
|
|
14
|
+
* return `Key: value` text with the sentences and the summary.
|
|
15
|
+
* - Granola `list_meetings` ({ time_range: "custom", from_date, to_date,
|
|
16
|
+
* involvement, limit, cursor }) returns XML-ish text (`<meeting …>` elements,
|
|
17
|
+
* `<pagination has_more next_cursor>`); `get_meetings` ({ meeting_ids })
|
|
18
|
+
* returns notes, AI summary and attendees; `get_meeting_transcript`
|
|
19
|
+
* ({ meeting_id }) returns the transcript on plans that include it.
|
|
20
|
+
*/
|
|
21
|
+
export const FIREFLIES_PROVIDER = "fireflies";
|
|
22
|
+
export const GRANOLA_PROVIDER = "granola";
|
|
23
|
+
export const MEETING_NOTE_PROVIDERS = [FIREFLIES_PROVIDER, GRANOLA_PROVIDER];
|
|
24
|
+
export const FIREFLIES_LIST_TOOL = "fireflies_get_transcripts";
|
|
25
|
+
export const FIREFLIES_TRANSCRIPT_TOOL = "fireflies_get_transcript";
|
|
26
|
+
export const FIREFLIES_SUMMARY_TOOL = "fireflies_get_summary";
|
|
27
|
+
export const GRANOLA_LIST_TOOL = "list_meetings";
|
|
28
|
+
export const GRANOLA_GET_TOOL = "get_meetings";
|
|
29
|
+
export const GRANOLA_TRANSCRIPT_TOOL = "get_meeting_transcript";
|
|
30
|
+
const FIREFLIES_PAGE = 50;
|
|
31
|
+
const GRANOLA_PAGE = 100;
|
|
32
|
+
/** List pages read per provider per fetch; bounds a backfill. */
|
|
33
|
+
const MAX_LIST_PAGES = 4;
|
|
34
|
+
function isRecord(value) {
|
|
35
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
36
|
+
}
|
|
37
|
+
/** The text of a gateway result: a string, or the MCP `content[].text` blocks joined. */
|
|
38
|
+
export function resultText(result) {
|
|
39
|
+
if (typeof result === "string")
|
|
40
|
+
return result;
|
|
41
|
+
if (isRecord(result) && Array.isArray(result.content)) {
|
|
42
|
+
return result.content
|
|
43
|
+
.map((block) => (isRecord(block) && typeof block.text === "string" ? block.text : ""))
|
|
44
|
+
.filter((t) => t !== "")
|
|
45
|
+
.join("\n");
|
|
46
|
+
}
|
|
47
|
+
return result === undefined || result === null ? "" : JSON.stringify(result);
|
|
48
|
+
}
|
|
49
|
+
const EMAIL = /[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+/g;
|
|
50
|
+
function emailsIn(text) {
|
|
51
|
+
return [...new Set((text.match(EMAIL) ?? []).map((e) => e.toLowerCase()))];
|
|
52
|
+
}
|
|
53
|
+
function unquote(value) {
|
|
54
|
+
const v = value.trim();
|
|
55
|
+
return v.length >= 2 && v.startsWith('"') && v.endsWith('"') ? v.slice(1, -1) : v;
|
|
56
|
+
}
|
|
57
|
+
function day(ms) {
|
|
58
|
+
return new Date(ms).toISOString().slice(0, 10);
|
|
59
|
+
}
|
|
60
|
+
/** Parse the Fireflies `fireflies_get_transcripts` text list. */
|
|
61
|
+
export function parseFirefliesList(text) {
|
|
62
|
+
const out = [];
|
|
63
|
+
const chunks = text.split(/^\s*- id:/m).slice(1);
|
|
64
|
+
for (const chunk of chunks) {
|
|
65
|
+
const lines = chunk.split(/\r?\n/);
|
|
66
|
+
const id = unquote(lines[0] ?? "");
|
|
67
|
+
const field = (name) => {
|
|
68
|
+
const re = new RegExp(`^\\s*${name}:\\s*(.*)$`);
|
|
69
|
+
for (const line of lines) {
|
|
70
|
+
const m = re.exec(line);
|
|
71
|
+
if (m)
|
|
72
|
+
return unquote(m[1]);
|
|
73
|
+
}
|
|
74
|
+
return undefined;
|
|
75
|
+
};
|
|
76
|
+
const title = field("title");
|
|
77
|
+
const when = field("dateString");
|
|
78
|
+
const startMs = when ? Date.parse(when) : NaN;
|
|
79
|
+
if (!id || !title || title === "null" || Number.isNaN(startMs))
|
|
80
|
+
continue;
|
|
81
|
+
const organizer = field("organizer_email");
|
|
82
|
+
const link = field("meeting_link");
|
|
83
|
+
out.push({
|
|
84
|
+
provider: FIREFLIES_PROVIDER,
|
|
85
|
+
id,
|
|
86
|
+
title,
|
|
87
|
+
startMs,
|
|
88
|
+
participants: emailsIn(lines.slice(1).filter((l) => !/meeting_link:/.test(l)).join("\n")),
|
|
89
|
+
...(organizer && organizer !== "null" ? { organizer: organizer.toLowerCase() } : {}),
|
|
90
|
+
...(link && link !== "null" ? { meetingUrl: link } : {}),
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
/** `Key: value` lines of a Fireflies detail response. */
|
|
96
|
+
function firefliesField(text, name) {
|
|
97
|
+
const m = new RegExp(`^${name}:\\s*(.*)$`, "m").exec(text);
|
|
98
|
+
return m ? m[1].trim() : undefined;
|
|
99
|
+
}
|
|
100
|
+
export const firefliesProvider = {
|
|
101
|
+
provider: FIREFLIES_PROVIDER,
|
|
102
|
+
async list(gateway, fromMs, toMs) {
|
|
103
|
+
const seen = new Map();
|
|
104
|
+
for (let page = 0; page < MAX_LIST_PAGES; page += 1) {
|
|
105
|
+
const text = resultText(await gateway.call(FIREFLIES_LIST_TOOL, {
|
|
106
|
+
fromDate: day(fromMs),
|
|
107
|
+
toDate: day(toMs),
|
|
108
|
+
limit: FIREFLIES_PAGE,
|
|
109
|
+
skip: page * FIREFLIES_PAGE,
|
|
110
|
+
}));
|
|
111
|
+
const items = parseFirefliesList(text);
|
|
112
|
+
for (const item of items)
|
|
113
|
+
seen.set(item.id, item);
|
|
114
|
+
if (items.length < FIREFLIES_PAGE)
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
return [...seen.values()].filter((m) => m.startMs >= fromMs && m.startMs <= toMs).sort(byStart);
|
|
118
|
+
},
|
|
119
|
+
async read(gateway, meeting) {
|
|
120
|
+
const transcript = resultText(await gateway.call(FIREFLIES_TRANSCRIPT_TOOL, { transcriptId: meeting.id }));
|
|
121
|
+
const summary = resultText(await gateway.call(FIREFLIES_SUMMARY_TOOL, { transcriptId: meeting.id }));
|
|
122
|
+
const calId = firefliesField(transcript, "Cal Id");
|
|
123
|
+
if (calId && !meeting.calendarEventId)
|
|
124
|
+
meeting.calendarEventId = calId;
|
|
125
|
+
const noSentences = /^Sentences:\s*No sentences\s*$/m.test(transcript) || transcript.trim() === "";
|
|
126
|
+
const noSummary = /^Summary:\s*No summary\s*$/m.test(summary) || summary.trim() === "";
|
|
127
|
+
return {
|
|
128
|
+
text: ["## Summary", "", summary.trim(), "", "## Transcript", "", transcript.trim(), ""].join("\n"),
|
|
129
|
+
hasContent: !(noSentences && noSummary),
|
|
130
|
+
};
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
function xmlAttrs(tag) {
|
|
134
|
+
const attrs = {};
|
|
135
|
+
for (const m of tag.matchAll(/([A-Za-z_][\w-]*)="([^"]*)"/g))
|
|
136
|
+
attrs[m[1]] = m[2];
|
|
137
|
+
return attrs;
|
|
138
|
+
}
|
|
139
|
+
/** Parse the Granola `list_meetings` text: `<meeting …>` elements and `<pagination …>`. */
|
|
140
|
+
export function parseGranolaList(text) {
|
|
141
|
+
const meetings = [];
|
|
142
|
+
for (const chunk of text.split(/<meeting\s/).slice(1)) {
|
|
143
|
+
const close = chunk.search(/<\/meeting>/);
|
|
144
|
+
const body = close >= 0 ? chunk.slice(0, close) : chunk.split(/<pagination\b/)[0];
|
|
145
|
+
const tagEnd = body.indexOf(">");
|
|
146
|
+
const attrs = xmlAttrs(tagEnd >= 0 ? body.slice(0, tagEnd) : body);
|
|
147
|
+
const id = attrs.id ?? attrs.meeting_id ?? attrs.note_id;
|
|
148
|
+
const titleTag = /<title>([\s\S]*?)<\/title>/.exec(body);
|
|
149
|
+
const title = attrs.title ?? titleTag?.[1]?.trim();
|
|
150
|
+
const when = attrs.meeting_at ?? attrs.date ?? attrs.start ?? attrs.created_at;
|
|
151
|
+
const startMs = when ? Date.parse(when) : NaN;
|
|
152
|
+
if (!id || !title || Number.isNaN(startMs))
|
|
153
|
+
continue;
|
|
154
|
+
const participants = emailsIn(tagEnd >= 0 ? body.slice(tagEnd) : "");
|
|
155
|
+
meetings.push({ provider: GRANOLA_PROVIDER, id, title, startMs, participants });
|
|
156
|
+
}
|
|
157
|
+
const pagination = /<pagination\b([^>]*)>/.exec(text);
|
|
158
|
+
const p = pagination ? xmlAttrs(pagination[1]) : {};
|
|
159
|
+
const nextCursor = p.has_more === "true" && p.next_cursor ? p.next_cursor : null;
|
|
160
|
+
return { meetings, nextCursor };
|
|
161
|
+
}
|
|
162
|
+
export const granolaProvider = {
|
|
163
|
+
provider: GRANOLA_PROVIDER,
|
|
164
|
+
async list(gateway, fromMs, toMs) {
|
|
165
|
+
const seen = new Map();
|
|
166
|
+
let cursor = null;
|
|
167
|
+
for (let page = 0; page < MAX_LIST_PAGES; page += 1) {
|
|
168
|
+
const args = {
|
|
169
|
+
time_range: "custom",
|
|
170
|
+
from_date: new Date(fromMs).toISOString(),
|
|
171
|
+
to_date: new Date(toMs).toISOString(),
|
|
172
|
+
involvement: { captured_by_me: true, listed_as_participant: true },
|
|
173
|
+
limit: GRANOLA_PAGE,
|
|
174
|
+
};
|
|
175
|
+
if (cursor)
|
|
176
|
+
args.cursor = cursor;
|
|
177
|
+
const parsed = parseGranolaList(resultText(await gateway.call(GRANOLA_LIST_TOOL, args)));
|
|
178
|
+
for (const m of parsed.meetings)
|
|
179
|
+
seen.set(m.id, m);
|
|
180
|
+
cursor = parsed.nextCursor;
|
|
181
|
+
if (!cursor)
|
|
182
|
+
break;
|
|
183
|
+
}
|
|
184
|
+
return [...seen.values()].filter((m) => m.startMs >= fromMs && m.startMs <= toMs).sort(byStart);
|
|
185
|
+
},
|
|
186
|
+
async read(gateway, meeting) {
|
|
187
|
+
const notes = resultText(await gateway.call(GRANOLA_GET_TOOL, { meeting_ids: [meeting.id] }));
|
|
188
|
+
let transcript = "";
|
|
189
|
+
try {
|
|
190
|
+
transcript = resultText(await gateway.call(GRANOLA_TRANSCRIPT_TOOL, { meeting_id: meeting.id }));
|
|
191
|
+
}
|
|
192
|
+
catch (err) {
|
|
193
|
+
// Some Granola plans do not include transcripts; the notes still land.
|
|
194
|
+
if (err.code === "HOT_CHANNEL_RECONNECT_REQUIRED")
|
|
195
|
+
throw err;
|
|
196
|
+
}
|
|
197
|
+
const parts = ["## Notes", "", notes.trim(), ""];
|
|
198
|
+
if (transcript.trim() !== "")
|
|
199
|
+
parts.push("## Transcript", "", transcript.trim(), "");
|
|
200
|
+
return { text: parts.join("\n"), hasContent: notes.trim() !== "" || transcript.trim() !== "" };
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
function byStart(a, b) {
|
|
204
|
+
return a.startMs - b.startMs || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|
|
205
|
+
}
|
|
206
|
+
export const MEETING_PROVIDERS = new Map([
|
|
207
|
+
[FIREFLIES_PROVIDER, firefliesProvider],
|
|
208
|
+
[GRANOLA_PROVIDER, granolaProvider],
|
|
209
|
+
]);
|
|
210
|
+
/** Lowercased title with punctuation and spacing collapsed. */
|
|
211
|
+
export function normalizeTitle(title) {
|
|
212
|
+
return title
|
|
213
|
+
.toLowerCase()
|
|
214
|
+
.replace(/[^a-z0-9]+/g, " ")
|
|
215
|
+
.trim();
|
|
216
|
+
}
|
|
217
|
+
function urlKey(url) {
|
|
218
|
+
if (!url)
|
|
219
|
+
return undefined;
|
|
220
|
+
try {
|
|
221
|
+
const u = new URL(url);
|
|
222
|
+
return `${u.host}${u.pathname}`.toLowerCase().replace(/\/+$/, "");
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
return undefined;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
/** Recordings that start this close together may be the same meeting. */
|
|
229
|
+
export const SAME_MEETING_WINDOW_MS = 30 * 60 * 1000;
|
|
230
|
+
/**
|
|
231
|
+
* Whether two recordings are the same meeting: the same calendar event, or
|
|
232
|
+
* starts within 30 minutes plus the same call link, a matching title, or at
|
|
233
|
+
* least two participants and more than half of the smaller list in common.
|
|
234
|
+
*/
|
|
235
|
+
export function sameMeeting(a, b) {
|
|
236
|
+
if (a.calendarEventId && b.calendarEventId && a.calendarEventId === b.calendarEventId)
|
|
237
|
+
return true;
|
|
238
|
+
if (Math.abs(a.startMs - b.startMs) > SAME_MEETING_WINDOW_MS)
|
|
239
|
+
return false;
|
|
240
|
+
const ua = urlKey(a.meetingUrl);
|
|
241
|
+
if (ua && ua === urlKey(b.meetingUrl))
|
|
242
|
+
return true;
|
|
243
|
+
const ta = normalizeTitle(a.title);
|
|
244
|
+
const tb = normalizeTitle(b.title);
|
|
245
|
+
if (ta !== "" && tb !== "" && (ta === tb || ta.includes(tb) || tb.includes(ta)))
|
|
246
|
+
return true;
|
|
247
|
+
const pa = new Set(a.participants.map((p) => p.toLowerCase()));
|
|
248
|
+
const pb = b.participants.map((p) => p.toLowerCase());
|
|
249
|
+
const smaller = Math.min(pa.size, pb.length);
|
|
250
|
+
const common = pb.filter((p) => pa.has(p)).length;
|
|
251
|
+
return common >= 2 && common * 2 > smaller;
|
|
252
|
+
}
|
|
253
|
+
//# sourceMappingURL=meeting-providers.js.map
|