@clubnet/seedclub 0.2.35 → 0.2.36

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.
@@ -120,6 +120,10 @@ export default function (pi: ExtensionAPI) {
120
120
  for (const key of ENV_TOKEN_KEYS) delete process.env[key];
121
121
  }
122
122
 
123
+ function isSeedClubInternalEmail(email: string | null | undefined) {
124
+ return !!email?.trim().toLowerCase().endsWith("@seedclub.com");
125
+ }
126
+
123
127
  async function validateCurrentCredential(ctx: any): Promise<{ name?: string | null; email?: string | null } | null> {
124
128
  const envToken = getRuntimeEnvToken();
125
129
  const stored = envToken ? null : await getStoredToken();
@@ -135,6 +139,17 @@ export default function (pi: ExtensionAPI) {
135
139
  clearSeedStatuses(ctx);
136
140
  return null;
137
141
  }
142
+ if (!isSeedClubInternalEmail(user.email)) {
143
+ await clearCredentials();
144
+ if (envToken) clearRuntimeEnvTokens();
145
+ clearSeedStatuses(ctx);
146
+ markAuthRequired({
147
+ authUrl: null,
148
+ message: "Seed Club sign-in is required before /login or /model.",
149
+ error: "Use a @seedclub.com account to connect this agent.",
150
+ });
151
+ return null;
152
+ }
138
153
 
139
154
  await applyConnectedStatus(ctx, user);
140
155
  markAuthComplete(getPostAuthInstruction(ctx));
@@ -339,6 +354,17 @@ export default function (pi: ExtensionAPI) {
339
354
  ctx.ui.notify(`Token verification failed: ${result.error}`, "error");
340
355
  return false;
341
356
  }
357
+ if (!isSeedClubInternalEmail(result.email)) {
358
+ await clearCredentials();
359
+ clearSeedStatuses(ctx);
360
+ markAuthRequired({
361
+ authUrl: null,
362
+ message: "Seed Club sign-in is still required. Run /connect to retry.",
363
+ error: "Use a @seedclub.com account to connect this agent.",
364
+ });
365
+ ctx.ui.notify("Use a @seedclub.com account to connect this agent.", "error");
366
+ return false;
367
+ }
342
368
 
343
369
  await storeToken(token, result.email, apiBase, { authBase, name: result.name });
344
370
  await applyConnectedStatus(ctx, result);
@@ -120,6 +120,141 @@ async function listProgramContacts(args: { programSlug: string; search?: string
120
120
  }
121
121
  }
122
122
 
123
+ async function listProgramFunnels(args: { programSlug: string }) {
124
+ try {
125
+ return await api.get<any>(`/programs/${args.programSlug}/funnels`);
126
+ } catch (error) {
127
+ if (error instanceof ApiError) return { error: error.message, status: error.status };
128
+ throw error;
129
+ }
130
+ }
131
+
132
+ async function createFunnelStage(args: {
133
+ programSlug: string;
134
+ funnelSlug: string;
135
+ stageKey: string;
136
+ label: string;
137
+ sortOrder: number;
138
+ isEntry?: boolean;
139
+ isExit?: boolean;
140
+ confirmed?: boolean;
141
+ }) {
142
+ try {
143
+ if (args.confirmed !== true) {
144
+ return {
145
+ error: "Stage creation requires explicit confirmation after listing the current funnel stages.",
146
+ status: 400,
147
+ requiredFlow: [
148
+ "Call seedclub_list_program_funnels for the program.",
149
+ "Confirm the funnel, stage key, label, and sort order with the user.",
150
+ "Call seedclub_create_funnel_stage with confirmed=true.",
151
+ ],
152
+ };
153
+ }
154
+
155
+ return await api.post<any>(`/programs/${args.programSlug}/funnels/${args.funnelSlug}/stages`, {
156
+ stage_key: args.stageKey,
157
+ label: args.label,
158
+ sort_order: args.sortOrder,
159
+ is_entry: args.isEntry,
160
+ is_exit: args.isExit,
161
+ });
162
+ } catch (error) {
163
+ if (error instanceof ApiError) return { error: error.message, status: error.status };
164
+ throw error;
165
+ }
166
+ }
167
+
168
+ async function updateFunnelStage(args: {
169
+ programSlug: string;
170
+ funnelSlug: string;
171
+ stageKey: string;
172
+ nextStageKey?: string | null;
173
+ label?: string | null;
174
+ sortOrder?: number | null;
175
+ isEntry?: boolean | null;
176
+ isExit?: boolean | null;
177
+ confirmed?: boolean;
178
+ }) {
179
+ try {
180
+ if (args.confirmed !== true) {
181
+ return {
182
+ error: "Stage updates require explicit confirmation after listing the current funnel stages.",
183
+ status: 400,
184
+ requiredFlow: [
185
+ "Call seedclub_list_program_funnels for the program.",
186
+ "Confirm the target stage and exact changes with the user.",
187
+ "Call seedclub_update_funnel_stage with confirmed=true.",
188
+ ],
189
+ };
190
+ }
191
+
192
+ const body: Record<string, unknown> = {};
193
+ if (args.nextStageKey != null) body.stage_key = args.nextStageKey;
194
+ if (args.label != null) body.label = args.label;
195
+ if (args.sortOrder != null) body.sort_order = args.sortOrder;
196
+ if (args.isEntry != null) body.is_entry = args.isEntry;
197
+ if (args.isExit != null) body.is_exit = args.isExit;
198
+
199
+ return await api.patch<any>(
200
+ `/programs/${args.programSlug}/funnels/${args.funnelSlug}/stages/${args.stageKey}`,
201
+ body,
202
+ );
203
+ } catch (error) {
204
+ if (error instanceof ApiError) return { error: error.message, status: error.status };
205
+ throw error;
206
+ }
207
+ }
208
+
209
+ async function listFunnelContacts(args: { programSlug: string; funnelSlug: string; search?: string }) {
210
+ try {
211
+ return await api.get<any>(`/programs/${args.programSlug}/funnels/${args.funnelSlug}/enrollments`, {
212
+ search: args.search,
213
+ });
214
+ } catch (error) {
215
+ if (error instanceof ApiError) return { error: error.message, status: error.status };
216
+ throw error;
217
+ }
218
+ }
219
+
220
+ async function listFunnelStageContacts(args: {
221
+ programSlug: string;
222
+ funnelSlug: string;
223
+ stageKey: string;
224
+ search?: string;
225
+ }) {
226
+ try {
227
+ return await api.get<any>(
228
+ `/programs/${args.programSlug}/funnels/${args.funnelSlug}/stages/${args.stageKey}/contacts`,
229
+ { search: args.search },
230
+ );
231
+ } catch (error) {
232
+ if (error instanceof ApiError) return { error: error.message, status: error.status };
233
+ throw error;
234
+ }
235
+ }
236
+
237
+ async function moveFunnelEnrollment(args: {
238
+ programSlug: string;
239
+ funnelSlug: string;
240
+ enrollmentId: string;
241
+ toStageId: string;
242
+ reason?: string | null;
243
+ }) {
244
+ try {
245
+ return await api.patch<any>(
246
+ `/programs/${args.programSlug}/funnels/${args.funnelSlug}/enrollments/${args.enrollmentId}`,
247
+ {
248
+ reason: args.reason,
249
+ to_stage_id: args.toStageId,
250
+ },
251
+ );
252
+ } catch (error) {
253
+ if (error instanceof ApiError) return { error: error.message, status: error.status };
254
+ throw error;
255
+ }
256
+ }
257
+
123
258
  export function registerCrmTools(pi: ExtensionAPI) {
124
259
  pi.registerTool({
125
260
  name: "seedclub_list_crm_records",
@@ -224,4 +359,134 @@ export function registerCrmTools(pi: ExtensionAPI) {
224
359
  return new Text(text, 0, 0);
225
360
  },
226
361
  });
362
+
363
+ pi.registerTool({
364
+ name: "seedclub_list_program_funnels",
365
+ label: "List Program Funnels",
366
+ description:
367
+ "List funnels and stages for a Seed Club program, including active enrollment counts. Use this to understand workflow lanes like seed-network scouts, angels, and founders before listing contacts.",
368
+ parameters: Type.Object({
369
+ programSlug: Type.String({ description: "Program slug, for example seed-network or 11am" }),
370
+ }),
371
+ execute: wrapExecute(listProgramFunnels),
372
+ renderCall: makeProgressCallRenderer(getToolCallLabel("seedclub_list_program_funnels"), (args) => args?.programSlug || undefined),
373
+ renderResult: makeProgressResultRenderer(getToolSuccessLabel("seedclub_list_program_funnels"), (details) => {
374
+ const rows = Array.isArray(details?.data) ? details.data : [];
375
+ const total = rows.reduce((sum: number, funnel: any) => sum + (Number(funnel?.active_enrollment_count) || 0), 0);
376
+ return `${rows.length} funnel${rows.length === 1 ? "" : "s"} · ${total} active`;
377
+ }),
378
+ });
379
+
380
+ pi.registerTool({
381
+ name: "seedclub_create_funnel_stage",
382
+ label: "Create Funnel Stage",
383
+ description:
384
+ "Create a new stage in a program funnel. Use only after seedclub_list_program_funnels and after the user explicitly confirms the funnel, stage key, label, and sort order.",
385
+ parameters: Type.Object({
386
+ programSlug: Type.String({ description: "Program slug" }),
387
+ funnelSlug: Type.String({ description: "Funnel slug" }),
388
+ stageKey: Type.String({ description: "New stage key, lowercase letters/numbers/underscore/hyphen only" }),
389
+ label: Type.String({ description: "Human-readable stage label" }),
390
+ sortOrder: Type.Number({ description: "Integer stage sort order chosen from the current stage list" }),
391
+ isEntry: Type.Optional(Type.Boolean({ description: "Set true if this should be the funnel entry stage" })),
392
+ isExit: Type.Optional(Type.Boolean({ description: "Set true if this should be an exit stage" })),
393
+ confirmed: Type.Boolean({ description: "Must be true after explicit user confirmation." }),
394
+ }),
395
+ execute: wrapExecute(createFunnelStage),
396
+ renderCall: makeProgressCallRenderer(getToolCallLabel("seedclub_create_funnel_stage"), (args) =>
397
+ [args?.funnelSlug, args?.stageKey].filter(Boolean).join(" / ") || undefined,
398
+ ),
399
+ renderResult: makeProgressResultRenderer(getToolSuccessLabel("seedclub_create_funnel_stage"), (details) =>
400
+ details?.stage?.stage_key ?? details?.stage?.label ?? undefined,
401
+ ),
402
+ });
403
+
404
+ pi.registerTool({
405
+ name: "seedclub_update_funnel_stage",
406
+ label: "Update Funnel Stage",
407
+ description:
408
+ "Update a funnel stage key, label, sort order, or entry/exit flags. Use only after seedclub_list_program_funnels and after the user explicitly confirms the target stage and exact changes.",
409
+ parameters: Type.Object({
410
+ programSlug: Type.String({ description: "Program slug" }),
411
+ funnelSlug: Type.String({ description: "Funnel slug" }),
412
+ stageKey: Type.String({ description: "Current stage key to update" }),
413
+ nextStageKey: Type.Optional(Type.Union([Type.String(), Type.Null()], { description: "Optional replacement stage key" })),
414
+ label: Type.Optional(Type.Union([Type.String(), Type.Null()], { description: "Optional replacement label" })),
415
+ sortOrder: Type.Optional(Type.Union([Type.Number(), Type.Null()], { description: "Optional replacement integer sort order" })),
416
+ isEntry: Type.Optional(Type.Union([Type.Boolean(), Type.Null()], { description: "Optional entry-stage flag" })),
417
+ isExit: Type.Optional(Type.Union([Type.Boolean(), Type.Null()], { description: "Optional exit-stage flag" })),
418
+ confirmed: Type.Boolean({ description: "Must be true after explicit user confirmation." }),
419
+ }),
420
+ execute: wrapExecute(updateFunnelStage),
421
+ renderCall: makeProgressCallRenderer(getToolCallLabel("seedclub_update_funnel_stage"), (args) =>
422
+ [args?.funnelSlug, args?.stageKey].filter(Boolean).join(" / ") || undefined,
423
+ ),
424
+ renderResult: makeProgressResultRenderer(getToolSuccessLabel("seedclub_update_funnel_stage"), (details) =>
425
+ details?.stage?.stage_key ?? details?.stage?.label ?? undefined,
426
+ ),
427
+ });
428
+
429
+ pi.registerTool({
430
+ name: "seedclub_list_funnel_contacts",
431
+ label: "List Funnel Contacts",
432
+ description:
433
+ "List active contacts enrolled in a specific program funnel. Use after seedclub_list_program_funnels when the user asks who is in scouts, angels, founders, guests, or another funnel.",
434
+ parameters: Type.Object({
435
+ programSlug: Type.String({ description: "Program slug" }),
436
+ funnelSlug: Type.String({ description: "Funnel slug" }),
437
+ search: Type.Optional(Type.String({ description: "Optional contact search text" })),
438
+ }),
439
+ execute: wrapExecute(listFunnelContacts),
440
+ renderCall: makeProgressCallRenderer(getToolCallLabel("seedclub_list_funnel_contacts"), (args) =>
441
+ [args?.programSlug, args?.funnelSlug].filter(Boolean).join(" / ") || undefined,
442
+ ),
443
+ renderResult: makeProgressResultRenderer(getToolSuccessLabel("seedclub_list_funnel_contacts"), (details) => {
444
+ const rows = Array.isArray(details?.data) ? details.data : [];
445
+ const funnel = details?.funnel?.slug ?? "funnel";
446
+ return `${rows.length} contact${rows.length === 1 ? "" : "s"} in ${funnel}`;
447
+ }),
448
+ });
449
+
450
+ pi.registerTool({
451
+ name: "seedclub_list_funnel_stage_contacts",
452
+ label: "List Funnel Stage Contacts",
453
+ description:
454
+ "List active contacts in one stage of a program funnel. Use this for questions like 'who are the angels not contacted yet?' or 'which founders are imported?'.",
455
+ parameters: Type.Object({
456
+ programSlug: Type.String({ description: "Program slug" }),
457
+ funnelSlug: Type.String({ description: "Funnel slug" }),
458
+ stageKey: Type.String({ description: "Stage key, for example not_contacted or imported" }),
459
+ search: Type.Optional(Type.String({ description: "Optional contact search text" })),
460
+ }),
461
+ execute: wrapExecute(listFunnelStageContacts),
462
+ renderCall: makeProgressCallRenderer(getToolCallLabel("seedclub_list_funnel_stage_contacts"), (args) =>
463
+ [args?.funnelSlug, args?.stageKey].filter(Boolean).join(" / ") || undefined,
464
+ ),
465
+ renderResult: makeProgressResultRenderer(getToolSuccessLabel("seedclub_list_funnel_stage_contacts"), (details) => {
466
+ const rows = Array.isArray(details?.data) ? details.data : [];
467
+ const stage = details?.stage?.stage_key ?? details?.stage?.label ?? "stage";
468
+ return `${rows.length} contact${rows.length === 1 ? "" : "s"} in ${stage}`;
469
+ }),
470
+ });
471
+
472
+ pi.registerTool({
473
+ name: "seedclub_move_funnel_enrollment",
474
+ label: "Move Funnel Enrollment",
475
+ description:
476
+ "Move an active contact enrollment to another stage in a program funnel. Use only after confirming the target enrollment id and target stage id from funnel tools.",
477
+ parameters: Type.Object({
478
+ programSlug: Type.String({ description: "Program slug" }),
479
+ funnelSlug: Type.String({ description: "Funnel slug" }),
480
+ enrollmentId: Type.String({ description: "Active enrollment id" }),
481
+ toStageId: Type.String({ description: "Target stage id, not the stage key" }),
482
+ reason: Type.Optional(Type.Union([Type.String(), Type.Null()], { description: "Optional transition reason" })),
483
+ }),
484
+ execute: wrapExecute(moveFunnelEnrollment),
485
+ renderCall: makeProgressCallRenderer(getToolCallLabel("seedclub_move_funnel_enrollment"), (args) =>
486
+ args?.enrollmentId || undefined,
487
+ ),
488
+ renderResult: makeProgressResultRenderer(getToolSuccessLabel("seedclub_move_funnel_enrollment"), (details) =>
489
+ details?.enrollment?.id || undefined,
490
+ ),
491
+ });
227
492
  }
@@ -17,7 +17,7 @@ const DEFAULT_BOOKING_DURATION_MINUTES = 20;
17
17
  const TRANSCRIPT_TEXT_PREVIEW_CHARS = 1800;
18
18
  const TRANSCRIPT_VTT_PREVIEW_CHARS = 1000;
19
19
 
20
- const GUEST_ROSTER_ALLOWED_FIELDS = new Set([
20
+ const SHOW_GUEST_ALLOWED_FIELDS = new Set([
21
21
  "date",
22
22
  "startsAt",
23
23
  "endsAt",
@@ -28,15 +28,17 @@ const GUEST_ROSTER_ALLOWED_FIELDS = new Set([
28
28
  "partyId",
29
29
  "meetingId",
30
30
  "email",
31
+ "transcriptAvailable",
31
32
  ]);
32
33
 
33
- const GUEST_ROSTER_DEFAULT_FIELDS = [
34
+ const SHOW_GUEST_DEFAULT_FIELDS = [
34
35
  "date",
35
36
  "startsAt",
36
37
  "title",
37
38
  "displayName",
38
39
  "organizationName",
39
40
  "organizationRole",
41
+ "transcriptAvailable",
40
42
  ];
41
43
 
42
44
  function summarizeCount(count: number | undefined, noun: string) {
@@ -436,33 +438,15 @@ function extractGuestSummary(row: any) {
436
438
  };
437
439
  }
438
440
 
439
- function normalizeGuestRosterFields(fields: string[] | undefined) {
441
+ function normalizeShowGuestFields(fields: string[] | undefined) {
440
442
  const selected = Array.isArray(fields)
441
- ? fields.filter((field) => typeof field === "string" && GUEST_ROSTER_ALLOWED_FIELDS.has(field))
443
+ ? fields.filter((field) => typeof field === "string" && SHOW_GUEST_ALLOWED_FIELDS.has(field))
442
444
  : [];
443
- if (!selected.length) return [...GUEST_ROSTER_DEFAULT_FIELDS];
445
+ if (!selected.length) return [...SHOW_GUEST_DEFAULT_FIELDS];
444
446
  return [...new Set(selected)];
445
447
  }
446
448
 
447
- function buildGuestRosterRow(row: any, options: { includeMeetingIds: boolean; includeEmails: boolean }) {
448
- const meeting = row?.meeting ?? null;
449
- const guest = extractGuestSummary(row);
450
- const startsAt = meeting?.starts_at ?? null;
451
- return {
452
- date: dateFromTimestamp(startsAt),
453
- startsAt,
454
- endsAt: meeting?.ends_at ?? null,
455
- title: meeting?.title ?? null,
456
- displayName: guest.displayName,
457
- organizationName: guest.organizationName,
458
- organizationRole: guest.organizationRole,
459
- partyId: guest.partyId,
460
- meetingId: options.includeMeetingIds ? (meeting?.id ?? null) : undefined,
461
- email: options.includeEmails ? guest.email : undefined,
462
- };
463
- }
464
-
465
- function pickGuestRosterFields(row: Record<string, unknown>, fields: string[]) {
449
+ function pickShowGuestFields(row: Record<string, unknown>, fields: string[]) {
466
450
  const picked: Record<string, unknown> = {};
467
451
  for (const field of fields) {
468
452
  if (field in row) picked[field] = row[field];
@@ -470,7 +454,7 @@ function pickGuestRosterFields(row: Record<string, unknown>, fields: string[]) {
470
454
  return picked;
471
455
  }
472
456
 
473
- function shapeDistinctGuestRoster(rows: Record<string, unknown>[]) {
457
+ function shapeDistinctShowGuests(rows: Record<string, unknown>[]) {
474
458
  const byGuest = new Map<string, any>();
475
459
  for (const row of rows) {
476
460
  const partyId = typeof row.partyId === "string" ? row.partyId : null;
@@ -489,6 +473,7 @@ function shapeDistinctGuestRoster(rows: Record<string, unknown>[]) {
489
473
  firstSeenAt: startsAt,
490
474
  lastSeenAt: startsAt,
491
475
  appearances: 1,
476
+ transcriptAvailable: row.transcriptAvailable ?? null,
492
477
  });
493
478
  continue;
494
479
  }
@@ -499,6 +484,7 @@ function shapeDistinctGuestRoster(rows: Record<string, unknown>[]) {
499
484
  if (!existing.organizationName && row.organizationName) existing.organizationName = row.organizationName;
500
485
  if (!existing.organizationRole && row.organizationRole) existing.organizationRole = row.organizationRole;
501
486
  if (!existing.email && row.email) existing.email = row.email;
487
+ if (existing.transcriptAvailable !== true && row.transcriptAvailable === true) existing.transcriptAvailable = true;
502
488
  }
503
489
 
504
490
  return [...byGuest.values()].sort((a, b) => {
@@ -509,12 +495,13 @@ function shapeDistinctGuestRoster(rows: Record<string, unknown>[]) {
509
495
  });
510
496
  }
511
497
 
512
- async function listGuestRoster(args: {
498
+ async function listShowGuests(args: {
513
499
  programSlug: string;
514
- from: string;
515
- to: string;
500
+ from?: string;
501
+ to?: string;
516
502
  assignmentStatus?: string;
517
503
  limit?: number;
504
+ includeTranscriptStatus?: boolean;
518
505
  distinct?: boolean;
519
506
  includeMeetingIds?: boolean;
520
507
  includeEmails?: boolean;
@@ -528,8 +515,7 @@ async function listGuestRoster(args: {
528
515
  const includeMeetingIds = args.includeMeetingIds === true;
529
516
  const includeEmails = args.includeEmails === true;
530
517
  const distinct = args.distinct === true;
531
-
532
- const selectedFields = normalizeGuestRosterFields(args.fields);
518
+ const selectedFields = normalizeShowGuestFields(args.fields);
533
519
  if (includeMeetingIds && !selectedFields.includes("meetingId")) selectedFields.push("meetingId");
534
520
  if (includeEmails && !selectedFields.includes("email")) selectedFields.push("email");
535
521
 
@@ -541,49 +527,6 @@ async function listGuestRoster(args: {
541
527
  limit,
542
528
  });
543
529
 
544
- const rows = Array.isArray(meetingsResponse?.data) ? meetingsResponse.data : [];
545
- const rosterRows = rows.map((row: any) => buildGuestRosterRow(row, { includeMeetingIds, includeEmails }));
546
- const distinctRows = distinct ? shapeDistinctGuestRoster(rosterRows) : null;
547
-
548
- return {
549
- programSlug: args.programSlug,
550
- from: args.from,
551
- to: args.to,
552
- distinct,
553
- fields: selectedFields,
554
- count: distinct ? (distinctRows?.length ?? 0) : rosterRows.length,
555
- guests: distinct
556
- ? (distinctRows ?? []).map((row: any) => pickGuestRosterFields(row, selectedFields))
557
- : rosterRows.map((row: any) => pickGuestRosterFields(row, selectedFields)),
558
- };
559
- } catch (error) {
560
- if (error instanceof ApiError) return { error: error.message, status: error.status };
561
- throw error;
562
- }
563
- }
564
-
565
- async function listShowGuests(args: {
566
- programSlug: string;
567
- from?: string;
568
- to?: string;
569
- assignmentStatus?: string;
570
- limit?: number;
571
- includeTranscriptStatus?: boolean;
572
- }) {
573
- try {
574
- const limit = normalizeLimit(args.limit, {
575
- fallback: DEFAULT_MEETINGS_LIMIT,
576
- max: MAX_MEETINGS_LIMIT,
577
- });
578
-
579
- const meetingsResponse = await api.get<any>("/meetings", {
580
- program_slug: args.programSlug,
581
- from: args.from,
582
- to: args.to,
583
- assignment_status: args.assignmentStatus,
584
- limit,
585
- });
586
-
587
530
  const rows = Array.isArray(meetingsResponse?.data) ? meetingsResponse.data : [];
588
531
  const includeTranscriptStatus = args.includeTranscriptStatus !== false;
589
532
 
@@ -603,12 +546,39 @@ async function listShowGuests(args: {
603
546
  );
604
547
  }
605
548
 
549
+ const guestRows = rows.map((row: any) => {
550
+ const meeting = row?.meeting ?? null;
551
+ const meetingId = meeting?.id ?? null;
552
+ const guest = extractGuestSummary(row);
553
+ const startsAt = meeting?.starts_at ?? null;
554
+ return {
555
+ date: dateFromTimestamp(startsAt),
556
+ startsAt,
557
+ endsAt: meeting?.ends_at ?? null,
558
+ title: meeting?.title ?? null,
559
+ displayName: guest.displayName,
560
+ organizationName: guest.organizationName,
561
+ organizationRole: guest.organizationRole,
562
+ partyId: guest.partyId,
563
+ meetingId: includeMeetingIds ? meetingId : undefined,
564
+ email: includeEmails ? guest.email : undefined,
565
+ transcriptAvailable: includeTranscriptStatus && meetingId ? transcriptByMeetingId.get(meetingId) === true : null,
566
+ };
567
+ });
568
+ const distinctGuestRows = distinct ? shapeDistinctShowGuests(guestRows) : null;
569
+
606
570
  return {
607
571
  program: meetingsResponse?.program ?? null,
608
572
  from: args.from ?? null,
609
573
  to: args.to ?? null,
610
574
  total: rows.length,
611
575
  returned: rows.length,
576
+ distinct,
577
+ fields: selectedFields,
578
+ count: distinct ? (distinctGuestRows?.length ?? 0) : guestRows.length,
579
+ guests: distinct
580
+ ? (distinctGuestRows ?? []).map((row: any) => pickShowGuestFields(row, selectedFields))
581
+ : guestRows.map((row: any) => pickShowGuestFields(row, selectedFields)),
612
582
  data: rows.map((row: any) => {
613
583
  const meeting = row?.meeting ?? null;
614
584
  const meetingId = meeting?.id ?? null;
@@ -634,19 +604,48 @@ function normalizeCompareValue(value: unknown) {
634
604
  return typeof value === "string" ? value.trim().toLowerCase() : "";
635
605
  }
636
606
 
607
+ function primaryProgramRole(contact: any) {
608
+ return Array.isArray(contact?.roles) ? contact.roles[0] : contact?.primary_org_role ?? null;
609
+ }
610
+
637
611
  function shapeContactCandidate(contact: any) {
612
+ const primaryRole = primaryProgramRole(contact);
638
613
  return {
639
614
  partyId: contact?.party?.id ?? null,
640
615
  displayName: contact?.party?.display_name ?? contact?.person?.full_name ?? null,
641
616
  email: contact?.person?.email ?? null,
642
- organizationName: contact?.primary_org_role?.organization_name ?? null,
643
- organizationRole: contact?.primary_org_role?.role ?? null,
617
+ organizationName: primaryRole?.organization_name ?? null,
618
+ organizationRole: primaryRole?.role ?? null,
619
+ crmRecord: contact?.crm_record
620
+ ? {
621
+ recordId: contact.crm_record.id ?? null,
622
+ engagementStatus: contact.crm_record.engagement_status ?? null,
623
+ ownerUserId: contact.crm_record.owner_user_id ?? null,
624
+ updatedAt: contact.crm_record.updated_at ?? null,
625
+ }
626
+ : null,
644
627
  raw: {
645
628
  partyType: contact?.party?.party_type ?? null,
646
629
  },
647
630
  };
648
631
  }
649
632
 
633
+ function shapeContactCrmCandidate(contact: any) {
634
+ const primaryRole = primaryProgramRole(contact);
635
+ return contact?.crm_record
636
+ ? {
637
+ recordId: contact.crm_record.id ?? null,
638
+ partyId: contact?.party?.id ?? null,
639
+ displayName: contact?.party?.display_name ?? contact?.person?.full_name ?? null,
640
+ email: contact?.person?.email ?? null,
641
+ engagementStatus: contact.crm_record.engagement_status ?? null,
642
+ ownerUserId: contact.crm_record.owner_user_id ?? null,
643
+ updatedAt: contact.crm_record.updated_at ?? null,
644
+ organizationName: primaryRole?.organization_name ?? null,
645
+ }
646
+ : null;
647
+ }
648
+
650
649
  function shapeCrmCandidate(item: any) {
651
650
  return {
652
651
  recordId: item?.record?.id ?? null,
@@ -751,8 +750,26 @@ async function getGuestProfile(args: {
751
750
  let crmMatches: any[] = [];
752
751
 
753
752
  if (includeCrm) {
753
+ crmMatches = contactRows
754
+ .map((row: any) => {
755
+ const shaped = shapeContactCrmCandidate(row);
756
+ if (!shaped) return null;
757
+ const score = scoreCandidateMatch({
758
+ candidatePartyId: shaped.partyId,
759
+ candidateEmail: shaped.email,
760
+ candidateName: shaped.displayName,
761
+ partyId: resolvedPartyId ?? bestContact?.partyId ?? undefined,
762
+ email: resolvedEmail ?? bestContact?.email ?? undefined,
763
+ search: resolvedSearch,
764
+ });
765
+ return { shaped, score };
766
+ })
767
+ .filter((row: any) => row && (row.score > 0 || (!resolvedPartyId && !resolvedEmail)))
768
+ .sort((a: any, b: any) => b.score - a.score)
769
+ .map((row: any) => row.shaped);
770
+
754
771
  const crmSearch = resolvedEmail ?? resolvedSearch;
755
- if (crmSearch) {
772
+ if (!crmMatches.length && crmSearch) {
756
773
  const crmResponse = await api.get<any>("/crm/records", {
757
774
  search: crmSearch,
758
775
  limit: 10,
@@ -1564,26 +1581,6 @@ async function cancelMeeting(args: { meetingId: string; calendarAccountId?: stri
1564
1581
  }
1565
1582
  }
1566
1583
 
1567
- async function updateMeeting(args: {
1568
- meetingId: string;
1569
- startsAt?: string | null;
1570
- endsAt?: string | null;
1571
- title?: string | null;
1572
- producerFraming?: string | null;
1573
- }) {
1574
- try {
1575
- return await api.patch<any>(`/meetings/${args.meetingId}`, {
1576
- starts_at: args.startsAt,
1577
- ends_at: args.endsAt,
1578
- title: args.title,
1579
- producer_framing: args.producerFraming,
1580
- });
1581
- } catch (error) {
1582
- if (error instanceof ApiError) return { error: error.message, status: error.status };
1583
- throw error;
1584
- }
1585
- }
1586
-
1587
1584
  async function assignMeeting(args: {
1588
1585
  meetingId: string;
1589
1586
  primaryPartyId: string;
@@ -1795,6 +1792,17 @@ export function registerMeetingTools(pi: ExtensionAPI) {
1795
1792
  to: Type.Optional(Type.String({ description: "Optional to ISO timestamp" })),
1796
1793
  assignmentStatus: Type.Optional(Type.String({ description: "Optional assignment status filter" })),
1797
1794
  limit: Type.Optional(Type.Number({ description: "Optional max rows to return. Defaults to 10, max 25." })),
1795
+ distinct: Type.Optional(Type.Boolean({ description: "Set true to de-duplicate guests." })),
1796
+ includeMeetingIds: Type.Optional(Type.Boolean({ description: "Set true to include meetingId in compact guest rows." })),
1797
+ includeEmails: Type.Optional(Type.Boolean({ description: "Set true to include guest emails in compact guest rows." })),
1798
+ fields: Type.Optional(
1799
+ Type.Array(
1800
+ Type.String({
1801
+ description:
1802
+ "Optional compact guest field projection. Allowed: date, startsAt, endsAt, title, displayName, organizationName, organizationRole, partyId, meetingId, email, transcriptAvailable.",
1803
+ }),
1804
+ ),
1805
+ ),
1798
1806
  includeTranscriptStatus: Type.Optional(
1799
1807
  Type.Boolean({
1800
1808
  description: "Set true to check transcript availability for each meeting. Defaults to true.",
@@ -1809,37 +1817,6 @@ export function registerMeetingTools(pi: ExtensionAPI) {
1809
1817
  }),
1810
1818
  });
1811
1819
 
1812
- pi.registerTool({
1813
- name: "seedclub_list_guest_roster",
1814
- label: "List Guest Roster",
1815
- description:
1816
- "Return a compact, query-driven guest roster for a program and date range. Prefer this when the user asks who was on a show and you want a minimal payload.",
1817
- parameters: Type.Object({
1818
- programSlug: Type.String({ description: "Program slug" }),
1819
- from: Type.String({ description: "Inclusive start date/time (ISO)." }),
1820
- to: Type.String({ description: "Inclusive end date/time (ISO)." }),
1821
- assignmentStatus: Type.Optional(Type.String({ description: "Optional assignment status filter" })),
1822
- limit: Type.Optional(Type.Number({ description: "Optional max rows to return. Defaults to 10, max 25." })),
1823
- distinct: Type.Optional(Type.Boolean({ description: "Set true to de-duplicate guests." })),
1824
- includeMeetingIds: Type.Optional(Type.Boolean({ description: "Set true to include meetingId in rows." })),
1825
- includeEmails: Type.Optional(Type.Boolean({ description: "Set true to include guest emails." })),
1826
- fields: Type.Optional(
1827
- Type.Array(
1828
- Type.String({
1829
- description:
1830
- "Optional field projection. Allowed: date, startsAt, endsAt, title, displayName, organizationName, organizationRole, partyId, meetingId, email.",
1831
- }),
1832
- ),
1833
- ),
1834
- }),
1835
- execute: wrapExecute(listGuestRoster),
1836
- renderCall: makeProgressCallRenderer(getToolCallLabel("seedclub_list_guest_roster"), (args) => args?.programSlug || undefined),
1837
- renderResult: makeProgressResultRenderer(getToolSuccessLabel("seedclub_list_guest_roster"), (details) => {
1838
- const count = Array.isArray(details?.rows) ? details.rows.length : undefined;
1839
- return typeof count === "number" ? `${count} guest${count === 1 ? "" : "s"}` : undefined;
1840
- }),
1841
- });
1842
-
1843
1820
  pi.registerTool({
1844
1821
  name: "seedclub_get_guest_profile",
1845
1822
  label: "Get Guest Profile",
@@ -2124,25 +2101,55 @@ export function registerMeetingTools(pi: ExtensionAPI) {
2124
2101
  });
2125
2102
 
2126
2103
  pi.registerTool({
2127
- name: "seedclub_update_meeting",
2128
- label: "Update Meeting",
2129
- description: "Update editable Seed Club meeting fields.",
2104
+ name: "seedclub_reschedule_meeting",
2105
+ label: "Reschedule Meeting",
2106
+ description:
2107
+ "Reschedule a booked Seed Club meeting and update the calendar invite and studio timing together. Always fetch availability first with seedclub_list_meeting_availability and use one of the returned startsAt/endsAt pairs. Pass calendarAccountId when multiple writable calendars exist.",
2130
2108
  parameters: Type.Object({
2131
2109
  meetingId: Type.String({ description: "Meeting id" }),
2132
- startsAt: Type.Optional(Type.Union([Type.String(), Type.Null()])),
2133
- endsAt: Type.Optional(Type.Union([Type.String(), Type.Null()])),
2110
+ startsAt: Type.String({ description: "New meeting start time as an ISO timestamp from availability" }),
2111
+ endsAt: Type.Optional(
2112
+ Type.Union([Type.String(), Type.Null()], {
2113
+ description: "Optional new meeting end time as an ISO timestamp from availability.",
2114
+ }),
2115
+ ),
2116
+ durationMinutes: Type.Optional(
2117
+ Type.Union([Type.Number(), Type.Null()], {
2118
+ description: "Optional meeting length in minutes if endsAt is not provided.",
2119
+ }),
2120
+ ),
2121
+ timeZone: Type.Optional(Type.String({ description: "IANA timezone like America/New_York" })),
2134
2122
  title: Type.Optional(Type.Union([Type.String(), Type.Null()])),
2135
- producerFraming: Type.Optional(Type.Union([Type.String(), Type.Null()])),
2136
- }),
2137
- execute: wrapExecute(updateMeeting),
2138
- renderCall: makeProgressCallRenderer(getToolCallLabel("seedclub_update_meeting"), (args) =>
2139
- firstNonEmptyString(
2140
- formatMeetingDateTimeRange(args?.startsAt, args?.endsAt),
2141
- args?.title,
2142
- args?.meetingId,
2123
+ description: Type.Optional(Type.Union([Type.String(), Type.Null()])),
2124
+ calendarAccountId: Type.Optional(
2125
+ Type.Union([Type.String(), Type.Null()], {
2126
+ description: "Optional calendar account id that owns the invite.",
2127
+ }),
2143
2128
  ),
2129
+ }),
2130
+ execute: wrapExecute(rescheduleMeeting),
2131
+ renderCall: makeProgressCallRenderer(getToolCallLabel("seedclub_reschedule_meeting"), (args) =>
2132
+ firstNonEmptyString(formatMeetingDateTimeRange(args?.startsAt, args?.endsAt), args?.meetingId),
2144
2133
  ),
2145
- renderResult: makeProgressResultRenderer(getToolSuccessLabel("seedclub_update_meeting")),
2134
+ renderResult: makeProgressResultRenderer(getToolSuccessLabel("seedclub_reschedule_meeting")),
2135
+ });
2136
+
2137
+ pi.registerTool({
2138
+ name: "seedclub_cancel_meeting",
2139
+ label: "Cancel Meeting",
2140
+ description:
2141
+ "Cancel a booked Seed Club meeting and clean up the calendar invite and studio access. Pass calendarAccountId when multiple writable calendars exist.",
2142
+ parameters: Type.Object({
2143
+ meetingId: Type.String({ description: "Meeting id" }),
2144
+ calendarAccountId: Type.Optional(
2145
+ Type.Union([Type.String(), Type.Null()], {
2146
+ description: "Optional calendar account id that owns the invite.",
2147
+ }),
2148
+ ),
2149
+ }),
2150
+ execute: wrapExecute(cancelMeeting),
2151
+ renderCall: makeProgressCallRenderer(getToolCallLabel("seedclub_cancel_meeting"), (args) => args?.meetingId || undefined),
2152
+ renderResult: makeProgressResultRenderer(getToolSuccessLabel("seedclub_cancel_meeting")),
2146
2153
  });
2147
2154
 
2148
2155
  pi.registerTool({
@@ -8,6 +8,12 @@ export const TOOL_CALL_LABELS: Record<string, string> = {
8
8
  seedclub_create_crm_note: "Saving CRM note",
9
9
  seedclub_create_crm_task: "Creating CRM task",
10
10
  seedclub_list_program_contacts: "Loading program contacts",
11
+ seedclub_list_program_funnels: "Loading program funnels",
12
+ seedclub_create_funnel_stage: "Creating funnel stage",
13
+ seedclub_update_funnel_stage: "Updating funnel stage",
14
+ seedclub_list_funnel_contacts: "Loading funnel contacts",
15
+ seedclub_list_funnel_stage_contacts: "Loading funnel stage contacts",
16
+ seedclub_move_funnel_enrollment: "Moving funnel enrollment",
11
17
  seedclub_search_people: "Searching people",
12
18
  seedclub_list_meeting_availability: "Loading availability slots",
13
19
  seedclub_list_meeting_calendars: "Loading booking calendars",
@@ -18,7 +24,6 @@ export const TOOL_CALL_LABELS: Record<string, string> = {
18
24
  seedclub_find_common_meeting_availability: "Checking common availability",
19
25
  seedclub_list_meetings: "Checking meeting schedule",
20
26
  seedclub_list_show_guests: "Looking up show guests",
21
- seedclub_list_guest_roster: "Building guest roster",
22
27
  seedclub_get_guest_profile: "Resolving guest profile",
23
28
  seedclub_find_latest_guest_transcript: "Finding the latest guest transcript",
24
29
  seedclub_prepare_clip_packet: "Checking clip readiness",
@@ -29,7 +34,6 @@ export const TOOL_CALL_LABELS: Record<string, string> = {
29
34
  seedclub_book_meeting: "Booking meeting",
30
35
  seedclub_reschedule_meeting: "Rescheduling meeting",
31
36
  seedclub_cancel_meeting: "Cancelling meeting",
32
- seedclub_update_meeting: "Updating meeting",
33
37
  seedclub_assign_meeting: "Assigning meeting",
34
38
  seedclub_list_program_media_assets: "Checking media assets",
35
39
  seedclub_get_program_media_asset: "Loading media asset details",
@@ -51,6 +55,12 @@ export const TOOL_SUCCESS_LABELS: Record<string, string> = {
51
55
  seedclub_create_crm_note: "CRM note saved",
52
56
  seedclub_create_crm_task: "CRM task created",
53
57
  seedclub_list_program_contacts: "Program contacts loaded",
58
+ seedclub_list_program_funnels: "Program funnels loaded",
59
+ seedclub_create_funnel_stage: "Funnel stage created",
60
+ seedclub_update_funnel_stage: "Funnel stage updated",
61
+ seedclub_list_funnel_contacts: "Funnel contacts loaded",
62
+ seedclub_list_funnel_stage_contacts: "Funnel stage contacts loaded",
63
+ seedclub_move_funnel_enrollment: "Funnel enrollment moved",
54
64
  seedclub_search_people: "People loaded",
55
65
  seedclub_list_meeting_availability: "Availability loaded",
56
66
  seedclub_list_meeting_calendars: "Booking calendars loaded",
@@ -61,7 +71,6 @@ export const TOOL_SUCCESS_LABELS: Record<string, string> = {
61
71
  seedclub_find_common_meeting_availability: "Common availability loaded",
62
72
  seedclub_list_meetings: "Meeting schedule loaded",
63
73
  seedclub_list_show_guests: "Show guests loaded",
64
- seedclub_list_guest_roster: "Guest roster ready",
65
74
  seedclub_get_guest_profile: "Guest profile loaded",
66
75
  seedclub_find_latest_guest_transcript: "Guest transcript loaded",
67
76
  seedclub_prepare_clip_packet: "Clip readiness checked",
@@ -72,7 +81,6 @@ export const TOOL_SUCCESS_LABELS: Record<string, string> = {
72
81
  seedclub_book_meeting: "Meeting booked",
73
82
  seedclub_reschedule_meeting: "Meeting rescheduled",
74
83
  seedclub_cancel_meeting: "Meeting cancelled",
75
- seedclub_update_meeting: "Meeting updated",
76
84
  seedclub_assign_meeting: "Meeting assigned",
77
85
  seedclub_list_program_media_assets: "Media assets checked",
78
86
  seedclub_get_program_media_asset: "Media asset details loaded",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubnet/seedclub",
3
- "version": "0.2.35",
3
+ "version": "0.2.36",
4
4
  "description": "A branded command-line agent wrapper around pi, with integrated Seed Club commands, tools, and app actions",
5
5
  "license": "MIT",
6
6
  "repository": {