@indigoai-us/hq-cli 5.345.178 → 5.345.179
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 +19 -0
- package/dist/command-catalog.generated.d.ts +71 -1
- package/dist/command-catalog.generated.js +86 -1
- package/dist/commands/bot-jobs.d.ts +11 -9
- package/dist/commands/bot-jobs.js +56 -21
- package/dist/commands/bot.d.ts +8 -0
- package/dist/commands/bot.js +44 -0
- 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/sources.d.ts +89 -0
- package/dist/commands/sources.js +363 -1
- package/dist/lib/bot/fleet-local.d.ts +1 -1
- package/dist/lib/bot/fleet-local.js +7 -7
- 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 +61 -0
- 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 +3 -2
- package/dist/lib/daemon/run.js +10 -6
- package/dist/lib/doctor/checks/anywhere.d.ts +6 -0
- package/dist/lib/doctor/checks/anywhere.js +35 -6
- 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 +523 -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 +140 -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 +186 -0
- package/dist/lib/hot-channels/loop.d.ts +74 -0
- package/dist/lib/hot-channels/loop.js +219 -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/loop.d.ts +10 -0
- package/dist/lib/lanes/loop.js +18 -1
- package/package.json +7 -7
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gmail hot channel (hot-signal-sync US-013), flag-gated.
|
|
3
|
+
*
|
|
4
|
+
* A poll adapter over the person's Google connection, routed through the HQ
|
|
5
|
+
* gateway. It watches INBOX with `users.watch`, renewed daily, and reads new
|
|
6
|
+
* mail with `history.list` from the stored `historyId`. The token stays in the
|
|
7
|
+
* gateway; this adapter never sees it.
|
|
8
|
+
*
|
|
9
|
+
* The adapter is always registered. It declares `gate.flag`, and the catalog
|
|
10
|
+
* lists gmail only when that flag is on for the company. With the flag off,
|
|
11
|
+
* `hq sources sync` reports gmail as `gated` and never calls this adapter.
|
|
12
|
+
*
|
|
13
|
+
* Gateway tools: the HQ google provider does not expose these four tools yet.
|
|
14
|
+
* They are named here as constants, and a follow-up hq-pro story adds them
|
|
15
|
+
* (all within `gmail.readonly`) before the flag turns on. The argument shapes
|
|
16
|
+
* are recorded in the project design note US-013-gmail-gateway-tools.md.
|
|
17
|
+
*
|
|
18
|
+
* The cursor is a JSON object `{ historyId, watchExpiresAt, watchedAt, pageToken }`:
|
|
19
|
+
* - `historyId`: Gmail history at or before it was already read.
|
|
20
|
+
* - `watchExpiresAt` / `watchedAt`: when the INBOX watch expires and when it
|
|
21
|
+
* was last renewed. The watch is renewed when it was never set up, when it
|
|
22
|
+
* is 24 hours old or older, or when it expires in under 24 hours.
|
|
23
|
+
* - `pageToken`: the `history.list` page token while a pass is in progress.
|
|
24
|
+
* The pass keeps the old `historyId` until its last page, then advances to
|
|
25
|
+
* the response's top-level `historyId`.
|
|
26
|
+
*
|
|
27
|
+
* A first run seeds `historyId` from the watch response (or `users.getProfile`)
|
|
28
|
+
* and lands nothing: Gmail history only exists after a known `historyId`.
|
|
29
|
+
* When Google answers 404 because the stored `historyId` is too old (about a
|
|
30
|
+
* week), the adapter re-seeds the same way and lands nothing.
|
|
31
|
+
*
|
|
32
|
+
* Each landed document is a pointer: subject, from, to, date and thread id
|
|
33
|
+
* from a metadata-only `messages.get`. The adapter never requests or copies a
|
|
34
|
+
* message body or snippet. The event key is `gmail:{messageId}:{historyId}`,
|
|
35
|
+
* with the history record id as the version.
|
|
36
|
+
*
|
|
37
|
+
* A revoked or expired token surfaces from the gateway call; run.ts turns it
|
|
38
|
+
* into a reconnect prompt in the run ledger and the cursor does not move.
|
|
39
|
+
*/
|
|
40
|
+
export const GMAIL_ADAPTER_ID = "gmail";
|
|
41
|
+
export const GMAIL_CHANNEL = "gmail";
|
|
42
|
+
export const GMAIL_PROVIDER = "google";
|
|
43
|
+
export const GMAIL_READONLY_SCOPE = "https://www.googleapis.com/auth/gmail.readonly";
|
|
44
|
+
/** The flag registered in hq-pro `src/flags/keys.ts`. */
|
|
45
|
+
export const GMAIL_FLAG = "hq.hot-channel-gmail";
|
|
46
|
+
export const GMAIL_MESSAGE_ADDED_EVENT = "gmail.message.added";
|
|
47
|
+
export const GMAIL_LABEL_ADDED_EVENT = "gmail.label.added";
|
|
48
|
+
export const GMAIL_WATCH_TOOL = "google.gmail.users.watch";
|
|
49
|
+
export const GMAIL_HISTORY_LIST_TOOL = "google.gmail.history.list";
|
|
50
|
+
export const GMAIL_GET_PROFILE_TOOL = "google.gmail.users.getProfile";
|
|
51
|
+
export const GMAIL_GET_MESSAGE_TOOL = "google.gmail.messages.get";
|
|
52
|
+
export const GMAIL_INBOX_LABEL = "INBOX";
|
|
53
|
+
export const GMAIL_METADATA_HEADERS = ["Subject", "From", "To", "Date"];
|
|
54
|
+
export const GMAIL_CATCH_UP_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
|
|
55
|
+
/** Renew the watch at least this often, and when it expires sooner than this. */
|
|
56
|
+
export const GMAIL_WATCH_RENEW_MS = 24 * 60 * 60 * 1000;
|
|
57
|
+
const SUGGESTION_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
|
|
58
|
+
const DEFAULT_PAGE_SIZE = 100;
|
|
59
|
+
const MAX_PAGE_SIZE = 500;
|
|
60
|
+
function nonEmptyString(value) {
|
|
61
|
+
return typeof value === "string" && value.trim() !== "" ? value : undefined;
|
|
62
|
+
}
|
|
63
|
+
function isRecord(value) {
|
|
64
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
65
|
+
}
|
|
66
|
+
function isIsoTime(value) {
|
|
67
|
+
return typeof value === "string" && !Number.isNaN(Date.parse(value));
|
|
68
|
+
}
|
|
69
|
+
/** Gmail ids and history ids arrive as decimal strings; accept numbers too. */
|
|
70
|
+
function idOf(value) {
|
|
71
|
+
if (typeof value === "number" && Number.isSafeInteger(value) && value >= 0)
|
|
72
|
+
return String(value);
|
|
73
|
+
const text = nonEmptyString(value);
|
|
74
|
+
return text !== undefined && /^[A-Za-z0-9_-]+$/.test(text) ? text : undefined;
|
|
75
|
+
}
|
|
76
|
+
function stringList(value) {
|
|
77
|
+
return Array.isArray(value) ? value.filter((v) => typeof v === "string" && v !== "") : [];
|
|
78
|
+
}
|
|
79
|
+
/** Epoch milliseconds (number or decimal string) as an ISO time. */
|
|
80
|
+
function epochMsToIso(value) {
|
|
81
|
+
const ms = typeof value === "number" ? value : typeof value === "string" && /^\d+$/.test(value) ? Number(value) : NaN;
|
|
82
|
+
return Number.isFinite(ms) && ms > 0 ? new Date(ms).toISOString() : null;
|
|
83
|
+
}
|
|
84
|
+
export function parseGmailCursor(cursor) {
|
|
85
|
+
if (!cursor)
|
|
86
|
+
return null;
|
|
87
|
+
let parsed;
|
|
88
|
+
try {
|
|
89
|
+
parsed = JSON.parse(cursor);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
if (!isRecord(parsed))
|
|
95
|
+
return null;
|
|
96
|
+
const historyId = idOf(parsed.historyId);
|
|
97
|
+
if (historyId === undefined)
|
|
98
|
+
return null;
|
|
99
|
+
return {
|
|
100
|
+
historyId,
|
|
101
|
+
watchExpiresAt: isIsoTime(parsed.watchExpiresAt) ? parsed.watchExpiresAt : null,
|
|
102
|
+
watchedAt: isIsoTime(parsed.watchedAt) ? parsed.watchedAt : null,
|
|
103
|
+
pageToken: nonEmptyString(parsed.pageToken) ?? null,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
function serializeCursor(cursor) {
|
|
107
|
+
return JSON.stringify(cursor);
|
|
108
|
+
}
|
|
109
|
+
/** True when the INBOX watch must be set up or renewed now. */
|
|
110
|
+
export function gmailWatchDue(cursor, now) {
|
|
111
|
+
if (!cursor?.watchedAt)
|
|
112
|
+
return true;
|
|
113
|
+
if (now.getTime() - Date.parse(cursor.watchedAt) >= GMAIL_WATCH_RENEW_MS)
|
|
114
|
+
return true;
|
|
115
|
+
if (!cursor.watchExpiresAt)
|
|
116
|
+
return true;
|
|
117
|
+
return Date.parse(cursor.watchExpiresAt) - now.getTime() < GMAIL_WATCH_RENEW_MS;
|
|
118
|
+
}
|
|
119
|
+
/** Unwrap a gateway result: a JSON string, an MCP `content[].text` block, or an object. */
|
|
120
|
+
function unwrapResult(result) {
|
|
121
|
+
if (typeof result === "string") {
|
|
122
|
+
try {
|
|
123
|
+
return JSON.parse(result);
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return result;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (isRecord(result) && Array.isArray(result.content)) {
|
|
130
|
+
const text = result.content
|
|
131
|
+
.map((block) => (isRecord(block) ? nonEmptyString(block.text) : undefined))
|
|
132
|
+
.find((value) => value !== undefined);
|
|
133
|
+
if (text !== undefined)
|
|
134
|
+
return unwrapResult(text);
|
|
135
|
+
}
|
|
136
|
+
return result;
|
|
137
|
+
}
|
|
138
|
+
function statusOf(err) {
|
|
139
|
+
const e = err;
|
|
140
|
+
if (typeof e?.status === "number")
|
|
141
|
+
return e.status;
|
|
142
|
+
return typeof e?.code === "number" ? e.code : undefined;
|
|
143
|
+
}
|
|
144
|
+
/** Google's answer when `startHistoryId` is older than the history it keeps. */
|
|
145
|
+
export function isExpiredHistoryError(err) {
|
|
146
|
+
if (statusOf(err) === 404)
|
|
147
|
+
return true;
|
|
148
|
+
return err instanceof Error && /\b404\b|requested entity was not found/i.test(err.message);
|
|
149
|
+
}
|
|
150
|
+
const AUTH_PATTERN = /HOT_CHANNEL_RECONNECT_REQUIRED|invalid_grant|revoked|reconnect|unauthori[sz]ed|forbidden/i;
|
|
151
|
+
/** Auth failures move nothing; they surface to run.ts as a reconnect. */
|
|
152
|
+
function isAuthFailure(err) {
|
|
153
|
+
const status = statusOf(err);
|
|
154
|
+
if (status === 401 || status === 403)
|
|
155
|
+
return true;
|
|
156
|
+
const code = err?.code;
|
|
157
|
+
if (typeof code === "string" && AUTH_PATTERN.test(code))
|
|
158
|
+
return true;
|
|
159
|
+
return err instanceof Error && AUTH_PATTERN.test(err.message);
|
|
160
|
+
}
|
|
161
|
+
const RATE_LIMIT_PATTERN = /\b429\b|rateLimitExceeded|userRateLimitExceeded|too many requests/i;
|
|
162
|
+
/** Gmail's 429 / rateLimitExceeded answer. */
|
|
163
|
+
function isRateLimited(err) {
|
|
164
|
+
if (statusOf(err) === 429)
|
|
165
|
+
return true;
|
|
166
|
+
const reason = err?.reason;
|
|
167
|
+
if (typeof reason === "string" && RATE_LIMIT_PATTERN.test(reason))
|
|
168
|
+
return true;
|
|
169
|
+
return err instanceof Error && RATE_LIMIT_PATTERN.test(err.message);
|
|
170
|
+
}
|
|
171
|
+
function parseWatch(result, now) {
|
|
172
|
+
const body = unwrapResult(result);
|
|
173
|
+
if (!isRecord(body))
|
|
174
|
+
throw new Error("Gmail users.watch returned an unexpected response");
|
|
175
|
+
return {
|
|
176
|
+
historyId: idOf(body.historyId) ?? null,
|
|
177
|
+
watchExpiresAt: epochMsToIso(body.expiration),
|
|
178
|
+
watchedAt: now.toISOString(),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
function parseProfileHistoryId(result) {
|
|
182
|
+
const body = unwrapResult(result);
|
|
183
|
+
const historyId = isRecord(body) ? idOf(body.historyId) : undefined;
|
|
184
|
+
if (historyId === undefined)
|
|
185
|
+
throw new Error("Gmail users.getProfile returned no historyId");
|
|
186
|
+
return historyId;
|
|
187
|
+
}
|
|
188
|
+
function messageOf(value) {
|
|
189
|
+
if (!isRecord(value) || !isRecord(value.message))
|
|
190
|
+
return undefined;
|
|
191
|
+
const messageId = idOf(value.message.id);
|
|
192
|
+
if (messageId === undefined)
|
|
193
|
+
return undefined;
|
|
194
|
+
const threadId = idOf(value.message.threadId);
|
|
195
|
+
return { messageId, ...(threadId ? { threadId } : {}), labelIds: stringList(value.message.labelIds) };
|
|
196
|
+
}
|
|
197
|
+
/** Parse one `history.list` page into INBOX message and label changes. */
|
|
198
|
+
export function parseGmailHistoryPage(result) {
|
|
199
|
+
const body = unwrapResult(result);
|
|
200
|
+
if (!isRecord(body))
|
|
201
|
+
throw new Error("Gmail history.list returned an unexpected response");
|
|
202
|
+
const records = body.history === undefined ? [] : body.history;
|
|
203
|
+
if (!Array.isArray(records))
|
|
204
|
+
throw new Error("Gmail history.list returned no history list");
|
|
205
|
+
const changes = [];
|
|
206
|
+
for (const record of records) {
|
|
207
|
+
if (!isRecord(record))
|
|
208
|
+
continue;
|
|
209
|
+
const historyId = idOf(record.id);
|
|
210
|
+
if (historyId === undefined)
|
|
211
|
+
continue;
|
|
212
|
+
for (const added of Array.isArray(record.messagesAdded) ? record.messagesAdded : []) {
|
|
213
|
+
const message = messageOf(added);
|
|
214
|
+
// labelIds may be absent on a messagesAdded item; the INBOX label filter
|
|
215
|
+
// on the request already scoped it.
|
|
216
|
+
if (!message || (message.labelIds.length > 0 && !message.labelIds.includes(GMAIL_INBOX_LABEL)))
|
|
217
|
+
continue;
|
|
218
|
+
changes.push({ kind: GMAIL_MESSAGE_ADDED_EVENT, historyId, ...message });
|
|
219
|
+
}
|
|
220
|
+
for (const added of Array.isArray(record.labelsAdded) ? record.labelsAdded : []) {
|
|
221
|
+
const message = messageOf(added);
|
|
222
|
+
if (!message || !isRecord(added) || !stringList(added.labelIds).includes(GMAIL_INBOX_LABEL))
|
|
223
|
+
continue;
|
|
224
|
+
changes.push({ kind: GMAIL_LABEL_ADDED_EVENT, historyId, ...message });
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return {
|
|
228
|
+
changes,
|
|
229
|
+
nextPageToken: nonEmptyString(body.nextPageToken) ?? null,
|
|
230
|
+
historyId: idOf(body.historyId) ?? null,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
/** Headers from a metadata-format `messages.get`. Body, snippet and parts are ignored. */
|
|
234
|
+
export function parseGmailMessageHeaders(result) {
|
|
235
|
+
const body = unwrapResult(result);
|
|
236
|
+
if (!isRecord(body))
|
|
237
|
+
return {};
|
|
238
|
+
const out = {};
|
|
239
|
+
const headers = isRecord(body.payload) && Array.isArray(body.payload.headers) ? body.payload.headers : [];
|
|
240
|
+
for (const header of headers) {
|
|
241
|
+
if (!isRecord(header))
|
|
242
|
+
continue;
|
|
243
|
+
const name = nonEmptyString(header.name)?.toLowerCase();
|
|
244
|
+
const value = nonEmptyString(header.value);
|
|
245
|
+
if (!value)
|
|
246
|
+
continue;
|
|
247
|
+
if (name === "subject" && out.subject === undefined)
|
|
248
|
+
out.subject = value;
|
|
249
|
+
else if (name === "from" && out.from === undefined)
|
|
250
|
+
out.from = value;
|
|
251
|
+
else if (name === "to" && out.to === undefined)
|
|
252
|
+
out.to = value;
|
|
253
|
+
else if (name === "date" && out.date === undefined)
|
|
254
|
+
out.date = value;
|
|
255
|
+
}
|
|
256
|
+
const internalDate = epochMsToIso(body.internalDate);
|
|
257
|
+
if (internalDate)
|
|
258
|
+
out.internalDate = internalDate;
|
|
259
|
+
return out;
|
|
260
|
+
}
|
|
261
|
+
function receivedAt(headers, now) {
|
|
262
|
+
if (headers.date && !Number.isNaN(Date.parse(headers.date)))
|
|
263
|
+
return new Date(headers.date).toISOString();
|
|
264
|
+
return headers.internalDate ?? now.toISOString();
|
|
265
|
+
}
|
|
266
|
+
function frontmatterFor(pointer, when) {
|
|
267
|
+
const fm = {
|
|
268
|
+
message_id: pointer.messageId,
|
|
269
|
+
history_id: pointer.historyId,
|
|
270
|
+
received_at: when,
|
|
271
|
+
};
|
|
272
|
+
if (pointer.threadId)
|
|
273
|
+
fm.thread_id = pointer.threadId;
|
|
274
|
+
if (pointer.headers.subject)
|
|
275
|
+
fm.subject = pointer.headers.subject;
|
|
276
|
+
if (pointer.headers.from)
|
|
277
|
+
fm.from = pointer.headers.from;
|
|
278
|
+
if (pointer.headers.to)
|
|
279
|
+
fm.to = pointer.headers.to;
|
|
280
|
+
if (pointer.labelIds.length > 0)
|
|
281
|
+
fm.labels = pointer.labelIds.join(", ");
|
|
282
|
+
return fm;
|
|
283
|
+
}
|
|
284
|
+
function bodyFor(kind, pointer, when) {
|
|
285
|
+
const action = kind === GMAIL_LABEL_ADDED_EVENT ? "moved to the inbox" : "arrived in the inbox";
|
|
286
|
+
const lines = [`Gmail message ${pointer.messageId} ${action} ${when}.`];
|
|
287
|
+
if (pointer.headers.from)
|
|
288
|
+
lines.push(`From: ${pointer.headers.from}`);
|
|
289
|
+
if (pointer.headers.to)
|
|
290
|
+
lines.push(`To: ${pointer.headers.to}`);
|
|
291
|
+
if (pointer.threadId)
|
|
292
|
+
lines.push(`Thread: ${pointer.threadId}`);
|
|
293
|
+
return lines.join("\n");
|
|
294
|
+
}
|
|
295
|
+
function toEvent(change, headers, personUid, now) {
|
|
296
|
+
const when = receivedAt(headers, now);
|
|
297
|
+
const pointer = {
|
|
298
|
+
messageId: change.messageId,
|
|
299
|
+
...(change.threadId ? { threadId: change.threadId } : {}),
|
|
300
|
+
historyId: change.historyId,
|
|
301
|
+
labelIds: change.labelIds,
|
|
302
|
+
headers: {
|
|
303
|
+
...(headers.subject ? { subject: headers.subject } : {}),
|
|
304
|
+
...(headers.from ? { from: headers.from } : {}),
|
|
305
|
+
...(headers.to ? { to: headers.to } : {}),
|
|
306
|
+
...(headers.date ? { date: headers.date } : {}),
|
|
307
|
+
},
|
|
308
|
+
};
|
|
309
|
+
return {
|
|
310
|
+
providerId: change.messageId,
|
|
311
|
+
version: change.historyId,
|
|
312
|
+
who: headers.from ?? personUid,
|
|
313
|
+
what: change.kind,
|
|
314
|
+
where: GMAIL_INBOX_LABEL.toLowerCase(),
|
|
315
|
+
when,
|
|
316
|
+
audience: [personUid],
|
|
317
|
+
title: headers.subject ?? `Gmail message ${change.messageId}`,
|
|
318
|
+
body: bodyFor(change.kind, pointer, when),
|
|
319
|
+
raw: pointer,
|
|
320
|
+
frontmatter: frontmatterFor(pointer, when),
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
export function createGmailAdapter(options = {}) {
|
|
324
|
+
const pageSize = Math.min(MAX_PAGE_SIZE, Math.max(1, Math.floor(options.pageSize ?? DEFAULT_PAGE_SIZE)));
|
|
325
|
+
const clock = options.now ?? (() => new Date());
|
|
326
|
+
async function fetch(cursor, ctx) {
|
|
327
|
+
const gateway = ctx.gateway;
|
|
328
|
+
if (!gateway)
|
|
329
|
+
throw new Error("Gmail hot channel needs a gateway connection");
|
|
330
|
+
const now = clock();
|
|
331
|
+
const state = parseGmailCursor(cursor);
|
|
332
|
+
let watch = null;
|
|
333
|
+
if (gmailWatchDue(state, now)) {
|
|
334
|
+
watch = parseWatch(await gateway.call(GMAIL_WATCH_TOOL, { labelIds: [GMAIL_INBOX_LABEL], labelFilterBehavior: "include" }), now);
|
|
335
|
+
}
|
|
336
|
+
const watchFields = {
|
|
337
|
+
watchExpiresAt: watch ? watch.watchExpiresAt : (state?.watchExpiresAt ?? null),
|
|
338
|
+
watchedAt: watch ? watch.watchedAt : (state?.watchedAt ?? null),
|
|
339
|
+
};
|
|
340
|
+
async function seed() {
|
|
341
|
+
const historyId = watch?.historyId ?? parseProfileHistoryId(await gateway.call(GMAIL_GET_PROFILE_TOOL, {}));
|
|
342
|
+
return { events: [], cursor: serializeCursor({ historyId, ...watchFields, pageToken: null }), hasMore: false };
|
|
343
|
+
}
|
|
344
|
+
if (state === null)
|
|
345
|
+
return seed();
|
|
346
|
+
const args = {
|
|
347
|
+
startHistoryId: state.historyId,
|
|
348
|
+
labelId: GMAIL_INBOX_LABEL,
|
|
349
|
+
historyTypes: ["messageAdded", "labelAdded"],
|
|
350
|
+
maxResults: pageSize,
|
|
351
|
+
};
|
|
352
|
+
if (state.pageToken)
|
|
353
|
+
args.pageToken = state.pageToken;
|
|
354
|
+
let page;
|
|
355
|
+
try {
|
|
356
|
+
page = parseGmailHistoryPage(await gateway.call(GMAIL_HISTORY_LIST_TOOL, args));
|
|
357
|
+
}
|
|
358
|
+
catch (err) {
|
|
359
|
+
if (!isAuthFailure(err) && isExpiredHistoryError(err))
|
|
360
|
+
return seed();
|
|
361
|
+
throw err;
|
|
362
|
+
}
|
|
363
|
+
const events = [];
|
|
364
|
+
const seen = new Set();
|
|
365
|
+
for (const change of page.changes) {
|
|
366
|
+
const key = `${change.messageId}:${change.historyId}`;
|
|
367
|
+
if (seen.has(key))
|
|
368
|
+
continue;
|
|
369
|
+
seen.add(key);
|
|
370
|
+
let headers = {};
|
|
371
|
+
try {
|
|
372
|
+
headers = parseGmailMessageHeaders(await gateway.call(GMAIL_GET_MESSAGE_TOOL, {
|
|
373
|
+
id: change.messageId,
|
|
374
|
+
format: "metadata",
|
|
375
|
+
metadataHeaders: [...GMAIL_METADATA_HEADERS],
|
|
376
|
+
}));
|
|
377
|
+
}
|
|
378
|
+
catch (err) {
|
|
379
|
+
// A failed header lookup still lands the pointer; auth failures stop the
|
|
380
|
+
// run, and a rate limit abandons the page (the cursor stays put) rather
|
|
381
|
+
// than spending hundreds more calls against an exhausted quota.
|
|
382
|
+
if (isAuthFailure(err) || isRateLimited(err))
|
|
383
|
+
throw err;
|
|
384
|
+
}
|
|
385
|
+
events.push(toEvent(change, headers, ctx.personUid, now));
|
|
386
|
+
}
|
|
387
|
+
if (page.nextPageToken) {
|
|
388
|
+
return {
|
|
389
|
+
events,
|
|
390
|
+
cursor: serializeCursor({ historyId: state.historyId, ...watchFields, pageToken: page.nextPageToken }),
|
|
391
|
+
hasMore: true,
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
return {
|
|
395
|
+
events,
|
|
396
|
+
cursor: serializeCursor({ historyId: page.historyId ?? state.historyId, ...watchFields, pageToken: null }),
|
|
397
|
+
hasMore: false,
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
return {
|
|
401
|
+
id: GMAIL_ADAPTER_ID,
|
|
402
|
+
channel: GMAIL_CHANNEL,
|
|
403
|
+
transport: "poll",
|
|
404
|
+
hotEventTypes: [GMAIL_MESSAGE_ADDED_EVENT, GMAIL_LABEL_ADDED_EVENT],
|
|
405
|
+
catchUpWindow: { maxLookbackMs: GMAIL_CATCH_UP_WINDOW_MS },
|
|
406
|
+
tokenScope: { provider: GMAIL_PROVIDER, scopes: [GMAIL_READONLY_SCOPE] },
|
|
407
|
+
// Declared as data only. US-014 evaluates it.
|
|
408
|
+
suggestionRule: { kind: "recent-activity", withinMs: SUGGESTION_WINDOW_MS, minEvents: 2 },
|
|
409
|
+
gate: { flag: GMAIL_FLAG },
|
|
410
|
+
fetch,
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
//# sourceMappingURL=gmail.js.map
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type HotChannelRegistry } from "../registry.js";
|
|
2
|
+
export * from "./attio.js";
|
|
3
|
+
export * from "./gmail.js";
|
|
4
|
+
export * from "./linear.js";
|
|
5
|
+
export * from "./meetings.js";
|
|
6
|
+
export * from "./slack.js";
|
|
7
|
+
export interface BuiltinHotChannelOptions {
|
|
8
|
+
/** Company vault root (`companies/{company}`). */
|
|
9
|
+
vaultRoot: string;
|
|
10
|
+
/** The signed-in person's identities besides the run's `personUid` (`prs_` uid, email). */
|
|
11
|
+
identities?: readonly string[];
|
|
12
|
+
/** The person's Slack account ids (`slk_...`), when known. */
|
|
13
|
+
slackAccountIds?: readonly string[];
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Register the adapters HQ ships with: meetings, slack and attio (all read the
|
|
17
|
+
* vault), linear (gateway poll) and gmail (gateway poll, gated on the
|
|
18
|
+
* `hq.hot-channel-gmail` flag). Attio also uses the gateway to scope events
|
|
19
|
+
* to the person.
|
|
20
|
+
*/
|
|
21
|
+
export declare function registerBuiltinHotChannels(registry: HotChannelRegistry, options: BuiltinHotChannelOptions): HotChannelRegistry;
|
|
22
|
+
export declare function createBuiltinHotChannelRegistry(options: BuiltinHotChannelOptions): HotChannelRegistry;
|
|
23
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { createHotChannelRegistry } from "../registry.js";
|
|
2
|
+
import { createAttioAdapter } from "./attio.js";
|
|
3
|
+
import { createGmailAdapter } from "./gmail.js";
|
|
4
|
+
import { createLinearAdapter } from "./linear.js";
|
|
5
|
+
import { createMeetingsAdapter } from "./meetings.js";
|
|
6
|
+
import { createSlackAdapter } from "./slack.js";
|
|
7
|
+
export * from "./attio.js";
|
|
8
|
+
export * from "./gmail.js";
|
|
9
|
+
export * from "./linear.js";
|
|
10
|
+
export * from "./meetings.js";
|
|
11
|
+
export * from "./slack.js";
|
|
12
|
+
/**
|
|
13
|
+
* Register the adapters HQ ships with: meetings, slack and attio (all read the
|
|
14
|
+
* vault), linear (gateway poll) and gmail (gateway poll, gated on the
|
|
15
|
+
* `hq.hot-channel-gmail` flag). Attio also uses the gateway to scope events
|
|
16
|
+
* to the person.
|
|
17
|
+
*/
|
|
18
|
+
export function registerBuiltinHotChannels(registry, options) {
|
|
19
|
+
registry.register(createMeetingsAdapter({
|
|
20
|
+
vaultRoot: options.vaultRoot,
|
|
21
|
+
...(options.identities ? { identities: options.identities } : {}),
|
|
22
|
+
}));
|
|
23
|
+
registry.register(createSlackAdapter({
|
|
24
|
+
vaultRoot: options.vaultRoot,
|
|
25
|
+
...(options.identities ? { identities: options.identities } : {}),
|
|
26
|
+
...(options.slackAccountIds ? { accountIds: options.slackAccountIds } : {}),
|
|
27
|
+
}));
|
|
28
|
+
registry.register(createLinearAdapter());
|
|
29
|
+
registry.register(createAttioAdapter({
|
|
30
|
+
vaultRoot: options.vaultRoot,
|
|
31
|
+
...(options.identities ? { identities: options.identities } : {}),
|
|
32
|
+
}));
|
|
33
|
+
registry.register(createGmailAdapter());
|
|
34
|
+
return registry;
|
|
35
|
+
}
|
|
36
|
+
export function createBuiltinHotChannelRegistry(options) {
|
|
37
|
+
return registerBuiltinHotChannels(createHotChannelRegistry(), options);
|
|
38
|
+
}
|
|
39
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,79 @@
|
|
|
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
|
+
import { type PollHotChannelAdapter } from "../contract.js";
|
|
39
|
+
export declare const LINEAR_ADAPTER_ID = "linear";
|
|
40
|
+
export declare const LINEAR_CHANNEL = "linear";
|
|
41
|
+
export declare const LINEAR_PROVIDER = "linear";
|
|
42
|
+
export declare const LINEAR_ISSUE_UPDATED_EVENT = "linear.issue.updated";
|
|
43
|
+
export declare const LINEAR_LIST_ISSUES_TOOL = "list_issues";
|
|
44
|
+
/** Linear's request budget for one personal token. */
|
|
45
|
+
export declare const LINEAR_REQUESTS_PER_HOUR = 2500;
|
|
46
|
+
export declare const LINEAR_CATCH_UP_WINDOW_MS: number;
|
|
47
|
+
export declare const LINEAR_ISSUE_FIELDS: readonly ["id", "uuid", "title", "priority", "url", "createdAt", "updatedAt", "completedAt", "dueDate", "status", "statusType", "labels", "assignee", "project", "team"];
|
|
48
|
+
export interface LinearAdapterOptions {
|
|
49
|
+
/** Issues per `list_issues` call. Clamped to 1..250. Default 100. */
|
|
50
|
+
pageSize?: number;
|
|
51
|
+
}
|
|
52
|
+
/** The adapter's cursor: see the file comment. */
|
|
53
|
+
export interface LinearCursor {
|
|
54
|
+
watermark: string;
|
|
55
|
+
after: string | null;
|
|
56
|
+
high: string | null;
|
|
57
|
+
}
|
|
58
|
+
export interface LinearIssue {
|
|
59
|
+
uuid: string;
|
|
60
|
+
identifier: string;
|
|
61
|
+
title: string;
|
|
62
|
+
updatedAt: string;
|
|
63
|
+
createdAt?: string;
|
|
64
|
+
completedAt?: string;
|
|
65
|
+
dueDate?: string;
|
|
66
|
+
url?: string;
|
|
67
|
+
priority?: number;
|
|
68
|
+
state?: string;
|
|
69
|
+
stateType?: string;
|
|
70
|
+
assignee?: string;
|
|
71
|
+
team?: string;
|
|
72
|
+
project?: string;
|
|
73
|
+
labels: string[];
|
|
74
|
+
}
|
|
75
|
+
export declare function parseLinearCursor(cursor: string | null, since: string): LinearCursor;
|
|
76
|
+
/** Normalize one `list_issues` item. Returns undefined when it has no id or `updatedAt`. */
|
|
77
|
+
export declare function toLinearIssue(item: unknown): LinearIssue | undefined;
|
|
78
|
+
export declare function createLinearAdapter(options?: LinearAdapterOptions): PollHotChannelAdapter;
|
|
79
|
+
//# sourceMappingURL=linear.d.ts.map
|