@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.
Files changed (80) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/command-catalog.generated.d.ts +71 -1
  3. package/dist/command-catalog.generated.js +86 -1
  4. package/dist/commands/bot-jobs.d.ts +11 -9
  5. package/dist/commands/bot-jobs.js +56 -21
  6. package/dist/commands/bot.d.ts +8 -0
  7. package/dist/commands/bot.js +44 -0
  8. package/dist/commands/daemon.d.ts +2 -0
  9. package/dist/commands/daemon.js +15 -4
  10. package/dist/commands/integrations-core.d.ts +2 -0
  11. package/dist/commands/integrations-core.js +1 -0
  12. package/dist/commands/sources.d.ts +89 -0
  13. package/dist/commands/sources.js +363 -1
  14. package/dist/lib/bot/fleet-local.d.ts +1 -1
  15. package/dist/lib/bot/fleet-local.js +7 -7
  16. package/dist/lib/bot/jobs/hot-signal-sync.d.ts +109 -0
  17. package/dist/lib/bot/jobs/hot-signal-sync.js +177 -0
  18. package/dist/lib/bot/local-jobs.d.ts +40 -4
  19. package/dist/lib/bot/local-jobs.js +72 -5
  20. package/dist/lib/bot/run.d.ts +15 -0
  21. package/dist/lib/bot/run.js +61 -0
  22. package/dist/lib/daemon/handoff.d.ts +4 -0
  23. package/dist/lib/daemon/handoff.js +6 -2
  24. package/dist/lib/daemon/install.d.ts +70 -2
  25. package/dist/lib/daemon/install.js +185 -27
  26. package/dist/lib/daemon/legacy.d.ts +9 -3
  27. package/dist/lib/daemon/legacy.js +11 -4
  28. package/dist/lib/daemon/migrate.js +1 -1
  29. package/dist/lib/daemon/run.d.ts +3 -2
  30. package/dist/lib/daemon/run.js +10 -6
  31. package/dist/lib/doctor/checks/anywhere.d.ts +6 -0
  32. package/dist/lib/doctor/checks/anywhere.js +35 -6
  33. package/dist/lib/hot-channels/adapters/__fixtures__/dummy.d.ts +33 -0
  34. package/dist/lib/hot-channels/adapters/__fixtures__/dummy.js +53 -0
  35. package/dist/lib/hot-channels/adapters/__fixtures__/meeting-providers.d.ts +38 -0
  36. package/dist/lib/hot-channels/adapters/__fixtures__/meeting-providers.js +64 -0
  37. package/dist/lib/hot-channels/adapters/attio.d.ts +67 -0
  38. package/dist/lib/hot-channels/adapters/attio.js +374 -0
  39. package/dist/lib/hot-channels/adapters/gmail.d.ts +111 -0
  40. package/dist/lib/hot-channels/adapters/gmail.js +413 -0
  41. package/dist/lib/hot-channels/adapters/index.d.ts +23 -0
  42. package/dist/lib/hot-channels/adapters/index.js +39 -0
  43. package/dist/lib/hot-channels/adapters/linear.d.ts +79 -0
  44. package/dist/lib/hot-channels/adapters/linear.js +321 -0
  45. package/dist/lib/hot-channels/adapters/meeting-providers.d.ts +86 -0
  46. package/dist/lib/hot-channels/adapters/meeting-providers.js +253 -0
  47. package/dist/lib/hot-channels/adapters/meetings.d.ts +81 -0
  48. package/dist/lib/hot-channels/adapters/meetings.js +523 -0
  49. package/dist/lib/hot-channels/adapters/slack.d.ts +70 -0
  50. package/dist/lib/hot-channels/adapters/slack.js +277 -0
  51. package/dist/lib/hot-channels/alert.d.ts +78 -0
  52. package/dist/lib/hot-channels/alert.js +179 -0
  53. package/dist/lib/hot-channels/audience.d.ts +15 -0
  54. package/dist/lib/hot-channels/audience.js +25 -0
  55. package/dist/lib/hot-channels/contract.d.ts +140 -0
  56. package/dist/lib/hot-channels/contract.js +20 -0
  57. package/dist/lib/hot-channels/cursors.d.ts +41 -0
  58. package/dist/lib/hot-channels/cursors.js +84 -0
  59. package/dist/lib/hot-channels/index.d.ts +10 -0
  60. package/dist/lib/hot-channels/index.js +10 -0
  61. package/dist/lib/hot-channels/landing.d.ts +83 -0
  62. package/dist/lib/hot-channels/landing.js +175 -0
  63. package/dist/lib/hot-channels/ledger.d.ts +101 -0
  64. package/dist/lib/hot-channels/ledger.js +186 -0
  65. package/dist/lib/hot-channels/loop.d.ts +74 -0
  66. package/dist/lib/hot-channels/loop.js +219 -0
  67. package/dist/lib/hot-channels/prompts/signals-pass.md +22 -0
  68. package/dist/lib/hot-channels/registry.d.ts +28 -0
  69. package/dist/lib/hot-channels/registry.js +121 -0
  70. package/dist/lib/hot-channels/run.d.ts +137 -0
  71. package/dist/lib/hot-channels/run.js +324 -0
  72. package/dist/lib/hot-channels/signals-pass.d.ts +150 -0
  73. package/dist/lib/hot-channels/signals-pass.js +581 -0
  74. package/dist/lib/hot-channels/sync-lock.d.ts +51 -0
  75. package/dist/lib/hot-channels/sync-lock.js +224 -0
  76. package/dist/lib/install/hook-target.d.ts +6 -2
  77. package/dist/lib/install/hook-target.js +9 -4
  78. package/dist/lib/lanes/loop.d.ts +10 -0
  79. package/dist/lib/lanes/loop.js +18 -1
  80. package/package.json +7 -7
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Meetings hot channel (hot-signal-sync US-008).
3
+ *
4
+ * Reads meeting transcripts already in the company vault under
5
+ * `sources/meetings/` and emits one event per transcript the signed-in person
6
+ * attended. It needs no connection: the catalog item names provider
7
+ * `hq-vault`, and run.ts calls it once with no gateway.
8
+ *
9
+ * Each landed document is a pointer, not a copy. Its body is a short header
10
+ * (title, when, who, audience, where) and `payload_ref` names the original
11
+ * transcript. The `.raw.json` sidecar holds the transcript frontmatter and the
12
+ * audience, never the transcript segments.
13
+ *
14
+ * `audience_source` on the landed document says how the audience was derived:
15
+ * `sidecar` (the attendee list in `{id}.audience[.vN].json`), `frontmatter`
16
+ * (an `attendees` or `audience` list on the transcript) or `person_uid` (only
17
+ * the person who captured the meeting).
18
+ *
19
+ * Fireflies and Granola (owner, 2026-10-29: "especially for a new user ...
20
+ * they won't have our meetings bot yet so we need to use those providers").
21
+ * One Meetings channel reads every source: the vault (HQ bot) plus each usable
22
+ * Fireflies or Granola connection, which run.ts hands over as
23
+ * `PollContext.gateways` (`companionProviders`). For a provider meeting the
24
+ * adapter writes the provider's notes and transcript verbatim to
25
+ * `sources/meeting-notes/{provider}/{id}.md` and lands a pointer to it, with
26
+ * `provider` set on the landed document. A provider meeting that matches a bot
27
+ * transcript, or a meeting already written by another provider, is skipped
28
+ * (same calendar event, or starts within 30 minutes plus the same link, title
29
+ * or most participants), so one meeting yields one set of signals. The reverse
30
+ * order holds too: a bot transcript arriving after one of this person's
31
+ * provider connections already landed the same meeting is skipped, so the
32
+ * meeting keeps one pointer and its signals do not run twice. Only notes
33
+ * written by the person's own connections count for either check.
34
+ *
35
+ * The cursor is the vault cursor (an ISO time) while no provider is
36
+ * connected. With providers it is JSON `{ vault, providers: { [connectionId]:
37
+ * watermark } }`; each provider watermark is the start of the newest meeting
38
+ * handled. A connection's first sync backfills the last 14 days, newest 50
39
+ * meetings at most, so onboarding produces signals on the first run. A
40
+ * meeting from the last 3 hours with no notes yet is left for the next run.
41
+ * A provider that fails (revoked token, outage) is warned about and keeps its
42
+ * watermark; the vault and other providers still land.
43
+ */
44
+ import { type PollHotChannelAdapter } from "../contract.js";
45
+ export declare const MEETINGS_ADAPTER_ID = "meetings";
46
+ export declare const MEETINGS_CHANNEL = "meeting-events";
47
+ export declare const MEETINGS_PROVIDER = "hq-vault";
48
+ export declare const MEETING_TRANSCRIPT_EVENT = "meeting.transcript";
49
+ export declare const MEETINGS_TRANSCRIPT_DIR = "sources/meetings";
50
+ export declare const MEETING_NOTES_DIR = "sources/meeting-notes";
51
+ /** Provider set on landed documents for bot transcripts read from the vault. */
52
+ export declare const MEETINGS_VAULT_SOURCE = "hq-vault";
53
+ /** First sync of a provider connection reaches back this far. */
54
+ export declare const MEETINGS_BACKFILL_MS: number;
55
+ /** ... and lands at most this many meetings. */
56
+ export declare const MEETINGS_BACKFILL_MAX = 50;
57
+ export type MeetingAudienceSource = "sidecar" | "frontmatter" | "person_uid";
58
+ export interface MeetingsAdapterOptions {
59
+ /** Company vault root (`companies/{company}`). */
60
+ vaultRoot: string;
61
+ /**
62
+ * The signed-in person's identities besides `ctx.personUid`: their `prs_`
63
+ * person uid and email. A transcript is emitted only when its audience
64
+ * contains one of them. Compared case-insensitively.
65
+ */
66
+ identities?: readonly string[];
67
+ /** Transcripts newer than the cursor per page. Default 100. */
68
+ pageSize?: number;
69
+ /** Clock for the backfill and settle windows. Default: the run's `since` plus the catch-up window. */
70
+ now?: () => number;
71
+ /** Where a failing provider is reported. Default: a line on stderr. */
72
+ warn?: (message: string) => void;
73
+ }
74
+ /** The adapter cursor: see the file comment. */
75
+ export interface MeetingsCursor {
76
+ vault: string | null;
77
+ providers: Record<string, string>;
78
+ }
79
+ export declare function parseMeetingsCursor(cursor: string | null): MeetingsCursor;
80
+ export declare function createMeetingsAdapter(options: MeetingsAdapterOptions): PollHotChannelAdapter;
81
+ //# sourceMappingURL=meetings.d.ts.map
@@ -0,0 +1,523 @@
1
+ /**
2
+ * Meetings hot channel (hot-signal-sync US-008).
3
+ *
4
+ * Reads meeting transcripts already in the company vault under
5
+ * `sources/meetings/` and emits one event per transcript the signed-in person
6
+ * attended. It needs no connection: the catalog item names provider
7
+ * `hq-vault`, and run.ts calls it once with no gateway.
8
+ *
9
+ * Each landed document is a pointer, not a copy. Its body is a short header
10
+ * (title, when, who, audience, where) and `payload_ref` names the original
11
+ * transcript. The `.raw.json` sidecar holds the transcript frontmatter and the
12
+ * audience, never the transcript segments.
13
+ *
14
+ * `audience_source` on the landed document says how the audience was derived:
15
+ * `sidecar` (the attendee list in `{id}.audience[.vN].json`), `frontmatter`
16
+ * (an `attendees` or `audience` list on the transcript) or `person_uid` (only
17
+ * the person who captured the meeting).
18
+ *
19
+ * Fireflies and Granola (owner, 2026-10-29: "especially for a new user ...
20
+ * they won't have our meetings bot yet so we need to use those providers").
21
+ * One Meetings channel reads every source: the vault (HQ bot) plus each usable
22
+ * Fireflies or Granola connection, which run.ts hands over as
23
+ * `PollContext.gateways` (`companionProviders`). For a provider meeting the
24
+ * adapter writes the provider's notes and transcript verbatim to
25
+ * `sources/meeting-notes/{provider}/{id}.md` and lands a pointer to it, with
26
+ * `provider` set on the landed document. A provider meeting that matches a bot
27
+ * transcript, or a meeting already written by another provider, is skipped
28
+ * (same calendar event, or starts within 30 minutes plus the same link, title
29
+ * or most participants), so one meeting yields one set of signals. The reverse
30
+ * order holds too: a bot transcript arriving after one of this person's
31
+ * provider connections already landed the same meeting is skipped, so the
32
+ * meeting keeps one pointer and its signals do not run twice. Only notes
33
+ * written by the person's own connections count for either check.
34
+ *
35
+ * The cursor is the vault cursor (an ISO time) while no provider is
36
+ * connected. With providers it is JSON `{ vault, providers: { [connectionId]:
37
+ * watermark } }`; each provider watermark is the start of the newest meeting
38
+ * handled. A connection's first sync backfills the last 14 days, newest 50
39
+ * meetings at most, so onboarding produces signals on the first run. A
40
+ * meeting from the last 3 hours with no notes yet is left for the next run.
41
+ * A provider that fails (revoked token, outage) is warned about and keeps its
42
+ * watermark; the vault and other providers still land.
43
+ */
44
+ import { createHash } from "node:crypto";
45
+ import * as fs from "node:fs/promises";
46
+ import * as path from "node:path";
47
+ import * as yaml from "js-yaml";
48
+ import { isAuthError } from "../../../utils/auth-error.js";
49
+ import { MAX_CATCH_UP_WINDOW_MS } from "../contract.js";
50
+ import { assertSafeSegment, HOT_SIGNAL_ORIGIN, writeFileAtomic } from "../landing.js";
51
+ import { MEETING_NOTE_PROVIDERS, MEETING_PROVIDERS, sameMeeting, } from "./meeting-providers.js";
52
+ export const MEETINGS_ADAPTER_ID = "meetings";
53
+ export const MEETINGS_CHANNEL = "meeting-events";
54
+ export const MEETINGS_PROVIDER = "hq-vault";
55
+ export const MEETING_TRANSCRIPT_EVENT = "meeting.transcript";
56
+ export const MEETINGS_TRANSCRIPT_DIR = "sources/meetings";
57
+ export const MEETING_NOTES_DIR = "sources/meeting-notes";
58
+ /** Provider set on landed documents for bot transcripts read from the vault. */
59
+ export const MEETINGS_VAULT_SOURCE = "hq-vault";
60
+ /** First sync of a provider connection reaches back this far. */
61
+ export const MEETINGS_BACKFILL_MS = 14 * 24 * 60 * 60 * 1000;
62
+ /** ... and lands at most this many meetings. */
63
+ export const MEETINGS_BACKFILL_MAX = 50;
64
+ /** Provider meetings read per connection per fetch. */
65
+ const PROVIDER_PAGE_SIZE = 10;
66
+ /** A meeting this recent with no notes yet may still be processing. */
67
+ const SETTLE_MS = 3 * 60 * 60 * 1000;
68
+ const DEFAULT_PAGE_SIZE = 100;
69
+ /** A gateway reconnect, an HQ auth error, or an HTTP 401/403 from a provider call. */
70
+ function isProviderAuthFailure(err) {
71
+ if (err?.code === "HOT_CHANNEL_RECONNECT_REQUIRED")
72
+ return true;
73
+ if (isAuthError(err))
74
+ return true;
75
+ const status = err?.status;
76
+ return status === 401 || status === 403;
77
+ }
78
+ function nonEmptyString(value) {
79
+ return typeof value === "string" && value.trim() !== "" ? value.trim() : undefined;
80
+ }
81
+ function stringList(value) {
82
+ if (!Array.isArray(value))
83
+ return [];
84
+ return [...new Set(value.map(nonEmptyString).filter((v) => v !== undefined))];
85
+ }
86
+ function toVersion(value) {
87
+ const n = typeof value === "string" ? Number(value) : value;
88
+ return typeof n === "number" && Number.isInteger(n) && n >= 0 ? n : 0;
89
+ }
90
+ /** Split `---\n{yaml}\n---\n{body}`. Unparseable or non-object frontmatter is null. */
91
+ function splitFrontmatter(text) {
92
+ const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(text);
93
+ if (!match)
94
+ return null;
95
+ let parsed;
96
+ try {
97
+ parsed = yaml.load(match[1], { schema: yaml.CORE_SCHEMA });
98
+ }
99
+ catch {
100
+ return null;
101
+ }
102
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
103
+ return null;
104
+ return { frontmatter: parsed, body: text.slice(match[0].length) };
105
+ }
106
+ async function readJson(file) {
107
+ try {
108
+ const parsed = JSON.parse(await fs.readFile(file, "utf8"));
109
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
110
+ }
111
+ catch {
112
+ return null;
113
+ }
114
+ }
115
+ /** The audience sidecar with the highest `audienceVersion`, when it lists anyone. */
116
+ async function readSidecarAudience(dir, base, names) {
117
+ const pattern = new RegExp(`^${base.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\.audience(?:\\.v\\d+)?\\.json$`);
118
+ let best = null;
119
+ for (const name of [...names].filter((n) => pattern.test(n)).sort()) {
120
+ const doc = await readJson(path.join(dir, name));
121
+ if (!doc)
122
+ continue;
123
+ const audience = [...new Set([...stringList(doc.invitedMemberUids), ...stringList(doc.unresolvedInvitedEmails)])];
124
+ const version = toVersion(doc.audienceVersion);
125
+ if (audience.length > 0 && (!best || version > best.version))
126
+ best = { audience, version };
127
+ }
128
+ return best;
129
+ }
130
+ async function readTranscript(dir, file, names) {
131
+ let text;
132
+ try {
133
+ text = await fs.readFile(path.join(dir, file), "utf8");
134
+ }
135
+ catch {
136
+ return null;
137
+ }
138
+ const split = splitFrontmatter(text);
139
+ if (!split)
140
+ return null;
141
+ const fm = split.frontmatter;
142
+ if (fm.origin === HOT_SIGNAL_ORIGIN)
143
+ return null;
144
+ const title = nonEmptyString(fm.title);
145
+ const transcriptId = nonEmptyString(fm.transcript_id) ?? nonEmptyString(fm.id) ?? nonEmptyString(fm.source_id);
146
+ const stamp = nonEmptyString(fm.updated_at) ?? nonEmptyString(fm.ingested_at) ?? nonEmptyString(fm.created_at);
147
+ const tsMs = stamp === undefined ? NaN : Date.parse(stamp);
148
+ if (!title || !transcriptId || Number.isNaN(tsMs))
149
+ return null;
150
+ const base = file.slice(0, -".md".length);
151
+ const sidecar = await readSidecarAudience(dir, base, names);
152
+ const fmAudience = stringList(fm.attendees).length > 0 ? stringList(fm.attendees) : stringList(fm.audience);
153
+ const owner = nonEmptyString(fm.person_uid);
154
+ let audience;
155
+ let audienceSource;
156
+ let audienceVersion;
157
+ if (sidecar) {
158
+ ({ audience, version: audienceVersion } = sidecar);
159
+ audienceSource = "sidecar";
160
+ }
161
+ else if (fmAudience.length > 0) {
162
+ audience = fmAudience;
163
+ audienceSource = "frontmatter";
164
+ audienceVersion = toVersion(fm.audience_version);
165
+ }
166
+ else if (owner) {
167
+ audience = [owner];
168
+ audienceSource = "person_uid";
169
+ audienceVersion = toVersion(fm.audience_version);
170
+ }
171
+ else {
172
+ return null;
173
+ }
174
+ return {
175
+ file,
176
+ tsMs,
177
+ transcriptId,
178
+ frontmatter: fm,
179
+ bodyDigest: createHash("sha256").update(split.body).digest("hex").slice(0, 12),
180
+ title,
181
+ audience,
182
+ audienceSource,
183
+ audienceVersion,
184
+ identity: {
185
+ startMs: Date.parse(nonEmptyString(fm.scheduled_start_time) ?? "") || tsMs,
186
+ title,
187
+ participants: audience.filter((a) => a.includes("@")),
188
+ ...(nonEmptyString(fm.meeting_url) ? { meetingUrl: nonEmptyString(fm.meeting_url) } : {}),
189
+ ...(nonEmptyString(fm.calendar_event_id) ? { calendarEventId: nonEmptyString(fm.calendar_event_id) } : {}),
190
+ },
191
+ };
192
+ }
193
+ function toEvent(t) {
194
+ const when = new Date(t.tsMs).toISOString();
195
+ const who = nonEmptyString(t.frontmatter.person_uid) ?? "unknown";
196
+ const where = nonEmptyString(t.frontmatter.meeting_platform) ?? "meeting";
197
+ const payloadRef = `${MEETINGS_TRANSCRIPT_DIR}/${t.file}`;
198
+ const body = [
199
+ `# ${t.title}`,
200
+ "",
201
+ `- When: ${when}`,
202
+ `- Who: ${who}`,
203
+ `- Audience: ${t.audience.join(", ")}`,
204
+ `- Where: ${where}`,
205
+ `- Transcript: ${payloadRef}`,
206
+ "",
207
+ ].join("\n");
208
+ return {
209
+ providerId: t.transcriptId,
210
+ version: `${t.audienceVersion}.${t.bodyDigest}`,
211
+ who,
212
+ what: MEETING_TRANSCRIPT_EVENT,
213
+ where,
214
+ when,
215
+ audience: [...t.audience],
216
+ title: t.title,
217
+ body,
218
+ payloadRef,
219
+ frontmatter: { audience_source: t.audienceSource, provider: MEETINGS_VAULT_SOURCE },
220
+ raw: { transcript: payloadRef, frontmatter: t.frontmatter, audience: [...t.audience], audience_source: t.audienceSource },
221
+ };
222
+ }
223
+ function byTimeThenName(a, b) {
224
+ return a.tsMs - b.tsMs || (a.file < b.file ? -1 : a.file > b.file ? 1 : 0);
225
+ }
226
+ export function parseMeetingsCursor(cursor) {
227
+ if (cursor === null)
228
+ return { vault: null, providers: {} };
229
+ if (!cursor.trimStart().startsWith("{"))
230
+ return { vault: cursor, providers: {} };
231
+ try {
232
+ const parsed = JSON.parse(cursor);
233
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
234
+ return { vault: null, providers: {} };
235
+ const rec = parsed;
236
+ const providers = {};
237
+ if (rec.providers && typeof rec.providers === "object" && !Array.isArray(rec.providers)) {
238
+ for (const [id, mark] of Object.entries(rec.providers)) {
239
+ if (typeof mark === "string" && !Number.isNaN(Date.parse(mark)))
240
+ providers[id] = mark;
241
+ }
242
+ }
243
+ return { vault: typeof rec.vault === "string" ? rec.vault : null, providers };
244
+ }
245
+ catch {
246
+ return { vault: null, providers: {} };
247
+ }
248
+ }
249
+ function serializeMeetingsCursor(cursor) {
250
+ if (Object.keys(cursor.providers).length === 0)
251
+ return cursor.vault;
252
+ return JSON.stringify({ vault: cursor.vault, providers: cursor.providers });
253
+ }
254
+ function safeId(id) {
255
+ try {
256
+ assertSafeSegment(id, "meeting id");
257
+ return id;
258
+ }
259
+ catch {
260
+ return createHash("sha256").update(id).digest("hex").slice(0, 32);
261
+ }
262
+ }
263
+ async function readNotesDocs(vaultRoot, sinceMs) {
264
+ const out = [];
265
+ for (const provider of MEETING_NOTE_PROVIDERS) {
266
+ const dir = path.join(vaultRoot, MEETING_NOTES_DIR, provider);
267
+ let names;
268
+ try {
269
+ names = await fs.readdir(dir);
270
+ }
271
+ catch {
272
+ continue;
273
+ }
274
+ for (const name of names.filter((n) => n.endsWith(".md"))) {
275
+ let text;
276
+ try {
277
+ text = await fs.readFile(path.join(dir, name), "utf8");
278
+ }
279
+ catch {
280
+ continue;
281
+ }
282
+ const fm = splitFrontmatter(text)?.frontmatter;
283
+ const start = fm ? Date.parse(nonEmptyString(fm.meeting_start) ?? "") : NaN;
284
+ const id = fm ? nonEmptyString(fm.provider_meeting_id) : undefined;
285
+ if (!fm || !id || Number.isNaN(start) || start < sinceMs - MEETINGS_BACKFILL_MS)
286
+ continue;
287
+ out.push({
288
+ provider,
289
+ id,
290
+ connectionId: nonEmptyString(fm.connection_id) ?? "",
291
+ identity: {
292
+ startMs: start,
293
+ title: nonEmptyString(fm.title) ?? "",
294
+ participants: stringList(fm.participants),
295
+ ...(nonEmptyString(fm.meeting_url) ? { meetingUrl: nonEmptyString(fm.meeting_url) } : {}),
296
+ ...(nonEmptyString(fm.calendar_event_id) ? { calendarEventId: nonEmptyString(fm.calendar_event_id) } : {}),
297
+ },
298
+ });
299
+ }
300
+ }
301
+ return out;
302
+ }
303
+ export function createMeetingsAdapter(options) {
304
+ const dir = path.join(options.vaultRoot, MEETINGS_TRANSCRIPT_DIR);
305
+ const pageSize = options.pageSize ?? DEFAULT_PAGE_SIZE;
306
+ if (!Number.isInteger(pageSize) || pageSize < 1)
307
+ throw new Error("meetings pageSize must be a positive integer");
308
+ const warn = options.warn ?? ((message) => process.stderr.write(`${message}\n`));
309
+ /** Every parseable bot transcript, and the person's page after the cursor. */
310
+ async function fetchVault(cursor, ctx) {
311
+ let names;
312
+ try {
313
+ names = await fs.readdir(dir);
314
+ }
315
+ catch (err) {
316
+ if (err.code === "ENOENT") {
317
+ return { events: [], cursor, hasMore: false, all: [] };
318
+ }
319
+ throw err;
320
+ }
321
+ const nameSet = new Set(names);
322
+ const me = new Set([ctx.personUid, ...(options.identities ?? [])].map((id) => id.toLowerCase()));
323
+ const sinceMs = Date.parse(ctx.since);
324
+ const cursorMs = cursor === null ? NaN : Date.parse(cursor);
325
+ const all = [];
326
+ const candidates = [];
327
+ for (const file of names.filter((n) => n.endsWith(".md")).sort()) {
328
+ const t = await readTranscript(dir, file, nameSet);
329
+ if (!t || t.tsMs < sinceMs - MEETINGS_BACKFILL_MS)
330
+ continue;
331
+ all.push(t);
332
+ if (t.tsMs < sinceMs)
333
+ continue;
334
+ if (!t.audience.some((a) => me.has(a.toLowerCase())))
335
+ continue;
336
+ candidates.push(t);
337
+ }
338
+ candidates.sort(byTimeThenName);
339
+ // Transcripts at the cursor time are re-read every page: a transcript that
340
+ // lands later with the same timestamp is not lost, and duplicates are
341
+ // dropped by landing. A page never splits one timestamp.
342
+ const atCursor = candidates.filter((t) => t.tsMs === cursorMs);
343
+ const newer = Number.isNaN(cursorMs) ? candidates : candidates.filter((t) => t.tsMs > cursorMs);
344
+ let end = Math.min(pageSize, newer.length);
345
+ while (end > 0 && end < newer.length && newer[end].tsMs === newer[end - 1].tsMs)
346
+ end += 1;
347
+ const page = newer.slice(0, end);
348
+ const last = page[page.length - 1];
349
+ return {
350
+ events: [...atCursor, ...page].map(toEvent),
351
+ cursor: last ? new Date(last.tsMs).toISOString() : cursor,
352
+ hasMore: end < newer.length,
353
+ all,
354
+ };
355
+ }
356
+ async function writeNotes(provider, connectionId, m, text) {
357
+ const ref = `${MEETING_NOTES_DIR}/${provider.provider}/${safeId(m.id)}.md`;
358
+ const file = path.resolve(options.vaultRoot, ref);
359
+ const sourcesRoot = path.resolve(options.vaultRoot, "sources") + path.sep;
360
+ if (!file.startsWith(sourcesRoot))
361
+ throw new Error(`meetings: refused to write outside sources/ (${ref})`);
362
+ const frontmatter = {
363
+ title: m.title,
364
+ provider: provider.provider,
365
+ provider_meeting_id: m.id,
366
+ connection_id: connectionId,
367
+ meeting_start: new Date(m.startMs).toISOString(),
368
+ participants: m.participants,
369
+ ...(m.organizer ? { organizer: m.organizer } : {}),
370
+ ...(m.meetingUrl ? { meeting_url: m.meetingUrl } : {}),
371
+ ...(m.calendarEventId ? { calendar_event_id: m.calendarEventId } : {}),
372
+ origin: HOT_SIGNAL_ORIGIN,
373
+ };
374
+ await writeFileAtomic(file, `---\n${yaml.dump(frontmatter, { lineWidth: -1, noRefs: true }).trimEnd()}\n---\n${text}`);
375
+ return ref;
376
+ }
377
+ function providerEvent(provider, m, ref, text, me) {
378
+ const when = new Date(m.startMs).toISOString();
379
+ const audience = m.participants.length > 0 ? [...new Set([me, ...m.participants])] : [me];
380
+ const who = m.organizer ?? me;
381
+ const body = [
382
+ `# ${m.title}`,
383
+ "",
384
+ `- When: ${when}`,
385
+ `- Who: ${who}`,
386
+ `- Audience: ${audience.join(", ")}`,
387
+ `- Where: ${provider}`,
388
+ `- Transcript: ${ref}`,
389
+ "",
390
+ ].join("\n");
391
+ return {
392
+ providerId: `${provider}:${m.id}`,
393
+ version: createHash("sha256").update(text).digest("hex").slice(0, 12),
394
+ who,
395
+ what: MEETING_TRANSCRIPT_EVENT,
396
+ where: provider,
397
+ when,
398
+ audience,
399
+ title: m.title,
400
+ body,
401
+ payloadRef: ref,
402
+ frontmatter: { audience_source: "provider", provider },
403
+ raw: { transcript: ref, provider, meeting: { ...m, start: when }, audience },
404
+ };
405
+ }
406
+ /** One page of one provider connection. Returns its events and new watermark. */
407
+ async function fetchProvider(gateway, provider, watermark, ctx, known, notes) {
408
+ const nowMs = options.now ? options.now() : Date.parse(ctx.since) + MAX_CATCH_UP_WINDOW_MS;
409
+ const firstRun = watermark === undefined;
410
+ const fromMs = firstRun
411
+ ? Math.max(Date.parse(ctx.since), nowMs - MEETINGS_BACKFILL_MS)
412
+ : Math.max(Date.parse(watermark), Date.parse(ctx.since));
413
+ let listed = (await provider.list(gateway, fromMs, nowMs)).filter((m) => firstRun || m.startMs > fromMs);
414
+ if (firstRun && listed.length > MEETINGS_BACKFILL_MAX)
415
+ listed = listed.slice(-MEETINGS_BACKFILL_MAX);
416
+ const me = new Set([ctx.personUid, ...(options.identities ?? [])].map((id) => id.toLowerCase()));
417
+ const emails = [...me].filter((id) => id.includes("@"));
418
+ const events = [];
419
+ let mark = watermark;
420
+ let deduped = 0;
421
+ let processed = 0;
422
+ for (const m of listed) {
423
+ if (processed >= PROVIDER_PAGE_SIZE)
424
+ break;
425
+ // The person's own connection lists what they can see; keep meetings
426
+ // they were in when their email is known.
427
+ const mine = emails.length === 0 ||
428
+ m.participants.length === 0 ||
429
+ m.participants.some((p) => me.has(p)) ||
430
+ (m.organizer !== undefined && me.has(m.organizer));
431
+ const sameAsKnown = known.some((k) => sameMeeting(k, m));
432
+ const sameAsOther = notes.some((n) => !(n.provider === provider.provider && n.id === m.id) && sameMeeting(n.identity, m));
433
+ if (!mine || sameAsKnown || sameAsOther) {
434
+ if (mine)
435
+ deduped += 1;
436
+ mark = new Date(m.startMs).toISOString();
437
+ continue;
438
+ }
439
+ processed += 1;
440
+ const content = await provider.read(gateway, m);
441
+ // read() may fill in the calendar event id; check again with it.
442
+ if (known.some((k) => sameMeeting(k, m)) || notes.some((n) => !(n.provider === provider.provider && n.id === m.id) && sameMeeting(n.identity, m))) {
443
+ deduped += 1;
444
+ mark = new Date(m.startMs).toISOString();
445
+ continue;
446
+ }
447
+ if (!content.hasContent) {
448
+ if (nowMs - m.startMs < SETTLE_MS)
449
+ break;
450
+ mark = new Date(m.startMs).toISOString();
451
+ continue;
452
+ }
453
+ const ref = await writeNotes(provider, gateway.connectionId, m, content.text);
454
+ notes.push({ provider: provider.provider, id: m.id, connectionId: gateway.connectionId, identity: m });
455
+ events.push(providerEvent(provider.provider, m, ref, content.text, ctx.personUid));
456
+ mark = new Date(m.startMs).toISOString();
457
+ }
458
+ const markMs = mark === undefined ? NaN : Date.parse(mark);
459
+ const hasMore = listed.some((m) => Number.isNaN(markMs) || m.startMs > markMs) && processed >= PROVIDER_PAGE_SIZE;
460
+ // A first run that found nothing still records where it looked from.
461
+ return { events, watermark: mark ?? new Date(fromMs).toISOString(), hasMore, deduped };
462
+ }
463
+ async function fetch(cursor, ctx) {
464
+ const state = parseMeetingsCursor(cursor);
465
+ const vault = await fetchVault(state.vault, ctx);
466
+ const gateways = (ctx.gateways ?? []).filter((g) => MEETING_PROVIDERS.has(g.provider.toLowerCase()));
467
+ // Provider notes this person's own connections wrote (current connections,
468
+ // or any connection this cursor holds a watermark for). A teammate's notes
469
+ // never suppress this person's meetings.
470
+ const ownConnections = new Set([...gateways.map((g) => g.connectionId), ...Object.keys(state.providers)]);
471
+ const notes = ownConnections.size > 0
472
+ ? (await readNotesDocs(options.vaultRoot, Date.parse(ctx.since))).filter((n) => ownConnections.has(n.connectionId))
473
+ : [];
474
+ // A bot transcript for a meeting a provider already landed is skipped:
475
+ // one pointer per meeting, and its signals already ran.
476
+ const vaultEvents = vault.events.filter((e) => {
477
+ const t = vault.all.find((x) => x.transcriptId === e.providerId);
478
+ return !t || !notes.some((n) => sameMeeting(n.identity, t.identity));
479
+ });
480
+ if (vaultEvents.length < vault.events.length) {
481
+ warn(`meetings: skipped ${vault.events.length - vaultEvents.length} bot transcript(s) already captured by a meeting provider`);
482
+ }
483
+ const events = [...vaultEvents];
484
+ let hasMore = vault.hasMore;
485
+ const next = { vault: vault.cursor, providers: { ...state.providers } };
486
+ if (gateways.length > 0) {
487
+ const known = vault.all.map((t) => t.identity);
488
+ for (const gateway of gateways) {
489
+ const provider = MEETING_PROVIDERS.get(gateway.provider.toLowerCase());
490
+ try {
491
+ const page = await fetchProvider(gateway, provider, state.providers[gateway.connectionId], ctx, known, notes);
492
+ events.push(...page.events);
493
+ if (page.watermark !== undefined)
494
+ next.providers[gateway.connectionId] = page.watermark;
495
+ hasMore = hasMore || page.hasMore;
496
+ if (page.deduped > 0)
497
+ warn(`meetings: ${provider.provider} ${gateway.connectionId}: skipped ${page.deduped} meeting(s) already captured by another source`);
498
+ }
499
+ catch (err) {
500
+ // A sign-in or revoked connection must reach run.ts so the ledger
501
+ // records the reconnect; only other provider errors are swallowed.
502
+ if (isProviderAuthFailure(err))
503
+ throw err;
504
+ warn(`meetings: ${provider.provider} ${gateway.connectionId} failed; its watermark stays put: ${err instanceof Error ? err.message : String(err)}`);
505
+ }
506
+ }
507
+ }
508
+ return { events, cursor: serializeMeetingsCursor(next), hasMore };
509
+ }
510
+ return {
511
+ id: MEETINGS_ADAPTER_ID,
512
+ channel: MEETINGS_CHANNEL,
513
+ transport: "poll",
514
+ hotEventTypes: [MEETING_TRANSCRIPT_EVENT],
515
+ catchUpWindow: { maxLookbackMs: MAX_CATCH_UP_WINDOW_MS },
516
+ tokenScope: { provider: MEETINGS_PROVIDER, scopes: [] },
517
+ companionProviders: MEETING_NOTE_PROVIDERS,
518
+ // Recurring meetings the person attended in the last 30 days.
519
+ suggestionRule: { kind: "recent-activity", withinMs: MAX_CATCH_UP_WINDOW_MS, minEvents: 2 },
520
+ fetch,
521
+ };
522
+ }
523
+ //# sourceMappingURL=meetings.js.map
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Slack hot channel (hot-signal-sync US-006).
3
+ *
4
+ * The live Slack events webhook in hq-pro-agents
5
+ * (`src/sources/inbound/slack-events.ts`) already lands one document per
6
+ * thread in the company vault under `sources/slack/`. From the local loop's
7
+ * side those threads are in the vault before the sync starts, so this adapter
8
+ * is a poll adapter over that folder, the same shape as meetings (US-008). It
9
+ * lists the thread documents ingested after the cursor that belong to the
10
+ * signed-in person, oldest first, and the cursor is the newest `ingested_at`.
11
+ *
12
+ * The hq-pro catalog entry for slack still says `transport: push`. run.ts
13
+ * plans by the registered adapter's transport, so this adapter runs once per
14
+ * usable Slack connection either way; the catalog correction is an hq-pro
15
+ * follow-up.
16
+ *
17
+ * Each landed document is a pointer. Its body is a short header (channel,
18
+ * time, owner, message count, audience) and `payload_ref` names the thread
19
+ * document. No message text is copied: the `.raw.json` sidecar holds only
20
+ * thread metadata.
21
+ *
22
+ * `audience_source` says how the audience was derived: `channel_members` (a
23
+ * `channel_members` or `audience` list on the thread document) or
24
+ * `person_uid` (only the person whose connection landed the thread). Today's
25
+ * writer records no channel membership, so the audience is the person until it
26
+ * does. The Slack user ids of the people in the thread are kept as
27
+ * `slack_participants`.
28
+ *
29
+ * Slack pauses event delivery to an app that exceeds its rate limit and sends
30
+ * `app_rate_limited`. A marker newer than the cursor (`_app_rate_limited.json`
31
+ * or a document with `type: app_rate_limited` in `sources/slack/`, without a
32
+ * `resolved_at`) fails the adapter with `SlackAppRateLimitedError`, whose
33
+ * message lands in the run ledger's `error`. The cursor does not move.
34
+ * Writing that marker from the webhook is an hq-pro-agents follow-up.
35
+ */
36
+ import { type PollHotChannelAdapter } from "../contract.js";
37
+ export declare const SLACK_ADAPTER_ID = "slack";
38
+ export declare const SLACK_CHANNEL = "slack-events";
39
+ export declare const SLACK_PROVIDER = "slack";
40
+ export declare const SLACK_THREAD_EVENT = "slack.thread";
41
+ export declare const SLACK_THREAD_DIR = "sources/slack";
42
+ export declare const SLACK_RATE_LIMIT_MARKER = "_app_rate_limited.json";
43
+ export declare const SLACK_APP_RATE_LIMITED = "app_rate_limited";
44
+ export type SlackAudienceSource = "channel_members" | "person_uid";
45
+ /** Slack paused event delivery, so threads after `pausedAt` may be missing. */
46
+ export declare class SlackAppRateLimitedError extends Error {
47
+ readonly pausedAt: string;
48
+ readonly code = "app_rate_limited";
49
+ constructor(pausedAt: string);
50
+ }
51
+ export interface SlackAdapterOptions {
52
+ /** Company vault root (`companies/{company}`). */
53
+ vaultRoot: string;
54
+ /**
55
+ * The signed-in person's identities besides `ctx.personUid`: their `prs_`
56
+ * person uid and email. A thread is the person's when its `person_uid` is
57
+ * one of them. Compared case-insensitively.
58
+ */
59
+ identities?: readonly string[];
60
+ /**
61
+ * The person's Slack account ids (`slk_...`). A thread is also the person's
62
+ * when its `account_id` is one of them. The run's connection id is checked
63
+ * the same way.
64
+ */
65
+ accountIds?: readonly string[];
66
+ /** Threads newer than the cursor per page. Default 100. */
67
+ pageSize?: number;
68
+ }
69
+ export declare function createSlackAdapter(options: SlackAdapterOptions): PollHotChannelAdapter;
70
+ //# sourceMappingURL=slack.d.ts.map