@clubnet/seedclub 0.2.21 → 0.2.23

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.
@@ -1,9 +1,10 @@
1
1
  import { mkdir, writeFile } from "node:fs/promises";
2
2
  import { homedir } from "node:os";
3
- import { basename, extname, join, resolve } from "node:path";
3
+ import { join, resolve } from "node:path";
4
4
  import { Type } from "@sinclair/typebox";
5
5
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
6
6
  import { api } from "../api-client.js";
7
+ import { makeProgressCallRenderer, makeProgressResultRenderer } from "../tool-utils.js";
7
8
  import { getSessionContext } from "../tools/utility.js";
8
9
 
9
10
  interface ProgramSummary {
@@ -14,6 +15,7 @@ interface ProgramSummary {
14
15
 
15
16
  interface TranscriptIntent {
16
17
  person?: string;
18
+ people?: string[];
17
19
  date?: string;
18
20
  time?: string;
19
21
  outDir?: string;
@@ -27,6 +29,7 @@ interface TranscriptCandidate {
27
29
  eventDate: string;
28
30
  person: string;
29
31
  vtt: string;
32
+ vttMode?: "native" | "generated_from_text";
30
33
  videoUrl?: string | null;
31
34
  audioUrl?: string | null;
32
35
  fileNameHint?: string | null;
@@ -173,6 +176,18 @@ function isLikelyTranscriptRequest(input: string) {
173
176
  );
174
177
  }
175
178
 
179
+ function isExplicitTranscriptExportRequest(input: string) {
180
+ const text = normalizeWhitespace(input).toLowerCase();
181
+ if (!text || text.startsWith("/")) return false;
182
+ if (!hasTranscriptArtifactWord(text)) return false;
183
+ if (isTranscriptInventoryQuestion(text)) return false;
184
+
185
+ const hasExportVerb = /\b(download|export|save|write|pull|grab)\b/.test(text);
186
+ const hasPathHint = /(?:^|\s)(~\/|\/[^\s]+|downloads?\/|desktop\/)/.test(text);
187
+ const shortArtifactPrompt = /\b(today(?:'s|s)?|yesterday(?:'s|s)?|tomorrow(?:'s|s)?|recent|latest|\d{1,2}(?::\d{2})?\s*(?:am|pm))\b/.test(text);
188
+ return hasExportVerb || hasPathHint || shortArtifactPrompt;
189
+ }
190
+
176
191
  function trimQuotes(value: string) {
177
192
  return value.replace(/^['\"]|['\"]$/g, "");
178
193
  }
@@ -296,6 +311,14 @@ function parseIntent(input: string): TranscriptIntent {
296
311
  const text = normalizeWhitespace(input);
297
312
  const intent: TranscriptIntent = {};
298
313
 
314
+ const betweenPair = text.match(/\bbetween\s+([\w'. -]{2,80}?)\s*(?:and|&)\s*([\w'. -]{2,80}?)(?=\s+(?:from|on|in|at|for|transcripts?|vtt|interviews?|calls?|meetings?|date|to)\b|[?.!,]|$)/i);
315
+ if (betweenPair?.[1] && betweenPair?.[2]) {
316
+ const first = normalizePersonQuery(betweenPair[1]);
317
+ const second = normalizePersonQuery(betweenPair[2]);
318
+ const people = [first, second].filter((value): value is string => !!value);
319
+ if (people.length) intent.people = Array.from(new Set(people));
320
+ }
321
+
299
322
  const personWith = text.match(/\bwith\s+([\w'. -]{2,80}?)(?=\s+(?:from|on|in|at|for|transcripts?|vtt|interviews?|calls?|meetings?|date|to)\b|$)/i);
300
323
  if (personWith?.[1]) intent.person = normalizePersonQuery(personWith[1]);
301
324
 
@@ -314,6 +337,10 @@ function parseIntent(input: string): TranscriptIntent {
314
337
  }
315
338
  }
316
339
 
340
+ if ((!intent.people || !intent.people.length) && intent.person) {
341
+ intent.people = [intent.person];
342
+ }
343
+
317
344
  const latestN = text.match(/\b(?:last|latest|most recent)\s+(\d{1,2})\b/i);
318
345
  if (latestN?.[1]) {
319
346
  intent.latestCount = Math.max(1, Math.min(20, Number.parseInt(latestN[1], 10)));
@@ -324,6 +351,9 @@ function parseIntent(input: string): TranscriptIntent {
324
351
  }
325
352
 
326
353
  intent.date = parseDateHint(text);
354
+ if ((intent.people?.length ?? 0) > 1 && /\b(?:both|between|across|and|&)\b/i.test(text)) {
355
+ intent.date = undefined;
356
+ }
327
357
 
328
358
  const explicitTimeMatch = text.match(/\b(?:at|time)\s+(\d{1,2}(?::\d{2})?\s*(?:am|pm)|(?:[01]?\d|2[0-3]):[0-5]\d)\b/i);
329
359
  const bareTimeMatch = text.match(/\b((?:[1-9]|1[0-2])(?::\d{2})?\s*(?:am|pm)|(?:[01]?\d|2[0-3]):[0-5]\d)\b/i);
@@ -344,7 +374,8 @@ function resolveOutputDir(programSlug: string, requested?: string): string {
344
374
  const raw = requested.replace(/^~(?=$|\/)/, homedir());
345
375
  return resolve(raw);
346
376
  }
347
- return join(homedir(), "Downloads", "seedclub", "transcripts", programSlug);
377
+ const safeProgram = sanitizeFilePart(programSlug || "", "transcripts");
378
+ return join(homedir(), "Downloads", safeProgram || "transcripts");
348
379
  }
349
380
 
350
381
  function sanitizeFilePart(value: string, fallback: string) {
@@ -357,6 +388,17 @@ function sanitizeFilePart(value: string, fallback: string) {
357
388
  return normalized || fallback;
358
389
  }
359
390
 
391
+ function hasNamedPerson(value: string | null | undefined) {
392
+ if (typeof value !== "string") return false;
393
+ const normalized = value.trim().toLowerCase();
394
+ if (!normalized) return false;
395
+ return !["unassigned", "unknown", "unknown guest"].includes(normalized);
396
+ }
397
+
398
+ function hasTranscriptSourceRef(candidate: TranscriptCandidate) {
399
+ return !!(candidate.assetId || candidate.meetingId);
400
+ }
401
+
360
402
  function parseTimeFilter(input?: string): { hour: number; minute?: number } | null {
361
403
  if (!input) return null;
362
404
  const value = input.trim().toLowerCase();
@@ -449,17 +491,93 @@ function exactNormalizedTextMatch(query: string, target: string) {
449
491
  return t.includes(q);
450
492
  }
451
493
 
452
- function ensureVtt(asset: any): string | null {
453
- const candidates = [asset?.transcript_edited, asset?.transcript_vtt];
494
+ function normalizeTranscriptText(value: string): string {
495
+ return value.replace(/\r\n/g, "\n").trim();
496
+ }
497
+
498
+ function isLikelyVtt(value: string): boolean {
499
+ const text = value.trim();
500
+ if (!text) return false;
501
+ return /^WEBVTT\b/i.test(text) || /\d{2}:\d{2}:\d{2}[\.,]\d{3}\s+-->\s+\d{2}:\d{2}:\d{2}[\.,]\d{3}/.test(text);
502
+ }
503
+
504
+ function toTimestamp(totalSeconds: number): string {
505
+ const ms = Math.max(0, Math.floor(totalSeconds * 1000));
506
+ const h = Math.floor(ms / 3600000);
507
+ const m = Math.floor((ms % 3600000) / 60000);
508
+ const s = Math.floor((ms % 60000) / 1000);
509
+ const milli = ms % 1000;
510
+ return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}.${String(milli).padStart(3, "0")}`;
511
+ }
512
+
513
+ function textToSyntheticVtt(text: string): string | null {
514
+ const normalized = normalizeTranscriptText(text);
515
+ if (!normalized) return null;
516
+
517
+ const sections = normalized
518
+ .split(/\n\s*\n+/)
519
+ .map((value) => value.trim())
520
+ .filter(Boolean)
521
+ .slice(0, 500);
522
+ if (!sections.length) return null;
523
+
524
+ let cursor = 0;
525
+ const cues: string[] = [];
526
+ for (const section of sections) {
527
+ const charCount = section.length;
528
+ const duration = Math.max(2, Math.min(10, Math.ceil(charCount / 28)));
529
+ const start = toTimestamp(cursor);
530
+ const end = toTimestamp(cursor + duration);
531
+ cues.push(`${start} --> ${end}\n${section}`);
532
+ cursor += duration;
533
+ }
534
+
535
+ return `WEBVTT\n\n${cues.join("\n\n")}`;
536
+ }
537
+
538
+ function extractTranscriptText(obj: any): string | null {
539
+ const candidates = [
540
+ obj?.transcript_edited,
541
+ obj?.transcript_raw,
542
+ obj?.merged_text,
543
+ obj?.transcript_text,
544
+ obj?.merged_transcript,
545
+ ];
454
546
  for (const candidate of candidates) {
455
- if (typeof candidate === "string" && candidate.trim()) return candidate;
547
+ if (typeof candidate === "string" && candidate.trim()) return candidate.trim();
456
548
  }
457
549
  return null;
458
550
  }
459
551
 
460
- async function listCandidateAssets(programSlug: string, partyId?: string, eventDate?: string) {
552
+ function resolveVttContent(source: any): { vtt: string; mode: "native" | "generated_from_text" } | null {
553
+ const explicitVtt = typeof source?.transcript_vtt === "string" ? source.transcript_vtt.trim() : "";
554
+ if (explicitVtt) return { vtt: explicitVtt, mode: "native" };
555
+
556
+ const mergedVtt = typeof source?.merged_vtt === "string" ? source.merged_vtt.trim() : "";
557
+ if (mergedVtt) return { vtt: mergedVtt, mode: "native" };
558
+
559
+ const maybeVtt = extractTranscriptText(source);
560
+ if (typeof maybeVtt === "string" && maybeVtt.trim()) {
561
+ if (isLikelyVtt(maybeVtt)) return { vtt: maybeVtt.trim(), mode: "native" };
562
+ const generated = textToSyntheticVtt(maybeVtt);
563
+ if (generated) return { vtt: generated, mode: "generated_from_text" };
564
+ }
565
+
566
+ return null;
567
+ }
568
+
569
+ function ensureVtt(asset: any): string | null {
570
+ return resolveVttContent(asset)?.vtt ?? null;
571
+ }
572
+
573
+ async function listCandidateAssets(
574
+ programSlug: string,
575
+ partyId?: string,
576
+ eventDate?: string,
577
+ assetKind: "full_conversation" | "clip" = "full_conversation",
578
+ ) {
461
579
  const response = await api.get<any>(`/programs/${programSlug}/media/assets`, {
462
- asset_kind: "full_conversation",
580
+ asset_kind: assetKind,
463
581
  party_id: partyId,
464
582
  event_date: eventDate,
465
583
  });
@@ -515,16 +633,6 @@ function applyLatestFilter<T>(rows: T[], latestCount: number | undefined, sortFn
515
633
  return sorted.slice(0, latestCount);
516
634
  }
517
635
 
518
- function filterAssetsByPersonText(assets: any[], personQuery?: string) {
519
- if (!personQuery?.trim()) return assets;
520
- return assets.filter((asset) => {
521
- const haystack = [asset?.title, asset?.key_question, asset?.file_name]
522
- .filter((v) => typeof v === "string" && v.trim())
523
- .join(" ");
524
- return exactNormalizedTextMatch(personQuery, haystack);
525
- });
526
- }
527
-
528
636
  function personFromMeetingTitle(title?: string | null): string | null {
529
637
  if (!title || typeof title !== "string") return null;
530
638
  const cleaned = title.replace(/\s+on\s+11am$/i, "").trim();
@@ -532,8 +640,8 @@ function personFromMeetingTitle(title?: string | null): string | null {
532
640
  }
533
641
 
534
642
  function toCandidateFromAsset(asset: any, fallbackPerson: string): TranscriptCandidate | null {
535
- const vtt = ensureVtt(asset);
536
- if (!vtt) return null;
643
+ const resolvedVtt = resolveVttContent(asset);
644
+ if (!resolvedVtt) return null;
537
645
  const eventDate = pickDate(asset);
538
646
  return {
539
647
  source: "full_conversation_asset",
@@ -541,7 +649,8 @@ function toCandidateFromAsset(asset: any, fallbackPerson: string): TranscriptCan
541
649
  meetingId: asset?.meeting_id ?? null,
542
650
  eventDate,
543
651
  person: pickPersonName(asset, fallbackPerson),
544
- vtt,
652
+ vtt: resolvedVtt.vtt,
653
+ vttMode: resolvedVtt.mode,
545
654
  videoUrl: asset?.storage_url ?? null,
546
655
  audioUrl: asset?.audio_url ?? null,
547
656
  fileNameHint: asset?.file_name ?? null,
@@ -561,7 +670,8 @@ async function listFallbackTranscriptCandidates(programSlug: string, intent: Tra
561
670
  rows = rows.filter((row: any) => row?.transcript?.event_date === intent.date);
562
671
  }
563
672
 
564
- if (intent.person?.trim()) {
673
+ const peopleQueries = Array.from(new Set([...(intent.people ?? []), ...(intent.person ? [intent.person] : [])].filter(Boolean)));
674
+ if (peopleQueries.length) {
565
675
  const personFiltered = rows.filter((row: any) => {
566
676
  const haystack = [
567
677
  row?.meeting?.title,
@@ -570,7 +680,7 @@ async function listFallbackTranscriptCandidates(programSlug: string, intent: Tra
570
680
  ]
571
681
  .filter((v: unknown) => typeof v === "string" && v.trim())
572
682
  .join(" ");
573
- return exactNormalizedTextMatch(intent.person!, haystack);
683
+ return peopleQueries.some((query) => exactNormalizedTextMatch(query, haystack));
574
684
  });
575
685
  if (personFiltered.length) {
576
686
  rows = personFiltered;
@@ -594,8 +704,8 @@ async function listFallbackTranscriptCandidates(programSlug: string, intent: Tra
594
704
 
595
705
  const candidates: TranscriptCandidate[] = [];
596
706
  for (const row of rows) {
597
- const vtt = typeof row?.transcript?.merged_vtt === "string" ? row.transcript.merged_vtt.trim() : "";
598
- if (!vtt) continue;
707
+ const resolvedVtt = resolveVttContent(row?.transcript ?? {});
708
+ if (!resolvedVtt) continue;
599
709
  const eventDate = typeof row?.transcript?.event_date === "string" ? row.transcript.event_date : new Date().toISOString().slice(0, 10);
600
710
  const person = pickPersonFromTranscriptRow(row, intent.person?.trim() || "unassigned");
601
711
  const sortTimestamp = /^\d{4}-\d{2}-\d{2}$/.test(eventDate)
@@ -607,7 +717,8 @@ async function listFallbackTranscriptCandidates(programSlug: string, intent: Tra
607
717
  meetingId: row?.transcript?.meeting_id ?? row?.meeting?.id ?? null,
608
718
  eventDate,
609
719
  person,
610
- vtt,
720
+ vtt: resolvedVtt.vtt,
721
+ vttMode: resolvedVtt.mode,
611
722
  videoUrl: null,
612
723
  audioUrl: null,
613
724
  fileNameHint: null,
@@ -655,14 +766,6 @@ function pickPersonFromTranscriptRow(row: any, fallback: string): string {
655
766
  return "unassigned";
656
767
  }
657
768
 
658
- function pickVideoFileName(asset: any): string {
659
- const fileName = typeof asset?.file_name === "string" ? asset.file_name.trim() : "";
660
- if (fileName) return fileName;
661
- const fromUrl = typeof asset?.storage_url === "string" ? basename(asset.storage_url.split("?")[0] || "") : "";
662
- if (fromUrl) return fromUrl;
663
- return `${sanitizeFilePart(asset?.id || "video", "video")}.mp4`;
664
- }
665
-
666
769
  function toFileUri(path: string) {
667
770
  const normalized = path.replace(/\\/g, "/");
668
771
  return `file://${encodeURI(normalized)}`;
@@ -689,19 +792,52 @@ function buildTranscriptDownloadSummary(manifest: Array<Record<string, unknown>>
689
792
  .join("\n");
690
793
  }
691
794
 
692
- function buildTranscriptCandidateSummary(candidates: TranscriptCandidate[], outDir: string) {
693
- const rows = candidates.slice(0, 10).map((candidate) => {
795
+ function planCandidateFileNames(candidates: TranscriptCandidate[]) {
796
+ const used = new Set<string>();
797
+ return candidates.map((candidate) => {
798
+ const safePerson = sanitizeFilePart(candidate.person, "unassigned");
799
+ const safeMeeting = sanitizeFilePart(candidate.meetingId || "no-meeting", "no-meeting");
800
+ const baseName = `${candidate.eventDate}__${safePerson}__${safeMeeting}`;
801
+ let fileName = `${baseName}.vtt`;
802
+ let suffix = 2;
803
+ while (used.has(fileName)) {
804
+ fileName = `${baseName}__${suffix}.vtt`;
805
+ suffix += 1;
806
+ }
807
+ used.add(fileName);
808
+ return { candidate, fileName };
809
+ });
810
+ }
811
+
812
+ function buildExportBatchFolderName(planned: Array<{ candidate: TranscriptCandidate; fileName: string }>) {
813
+ if (!planned.length) return "batch";
814
+ if (planned.length === 1) {
815
+ const row = planned[0].candidate;
816
+ const safePerson = sanitizeFilePart(row.person || "guest", "guest");
817
+ const safeDate = sanitizeFilePart(row.eventDate || "date", "date");
818
+ return `${safePerson}&${safeDate}`;
819
+ }
820
+
821
+ const dates = Array.from(new Set(planned.map((row) => row.candidate.eventDate).filter(Boolean))).sort();
822
+ const firstDate = dates[0] ?? "date";
823
+ const lastDate = dates[dates.length - 1] ?? firstDate;
824
+ const datePart = firstDate === lastDate ? firstDate : `${firstDate}_to_${lastDate}`;
825
+ return `batch--${sanitizeFilePart(datePart, "range")}--${planned.length}`;
826
+ }
827
+
828
+ function buildTranscriptCandidateSummary(planned: Array<{ candidate: TranscriptCandidate; fileName: string }>, outDir: string) {
829
+ const rows = planned.slice(0, 10).map(({ candidate, fileName }) => {
694
830
  const date = candidate.eventDate || "unknown date";
695
831
  const person = candidate.person || "Unknown guest";
696
- return `- ${date} - ${person}`;
832
+ return `- ${date} - ${person} -> ${fileName}`;
697
833
  });
698
834
 
699
- if (candidates.length > rows.length) {
700
- rows.push(`- ...and ${candidates.length - rows.length} more`);
835
+ if (planned.length > rows.length) {
836
+ rows.push(`- ...and ${planned.length - rows.length} more`);
701
837
  }
702
838
 
703
839
  return [
704
- `Found ${candidates.length} transcript file${candidates.length === 1 ? "" : "s"}.`,
840
+ `Found ${planned.length} transcript file${planned.length === 1 ? "" : "s"}.`,
705
841
  rows.length ? rows.join("\n") : null,
706
842
  `Download to ${outDir}?`,
707
843
  ]
@@ -721,19 +857,12 @@ async function openPathWithSystem(agent: ExtensionAPI, targetPath: string) {
721
857
  await agent.exec("xdg-open", [targetPath]);
722
858
  }
723
859
 
724
- async function offerOpenDownloadedPath(
725
- agent: ExtensionAPI,
726
- ctx: any,
727
- outDir: string,
728
- manifestPath: string,
729
- videoDir?: string,
730
- ) {
731
- const options = ["Open folder", "Open index.json", ...(videoDir ? ["Open videos folder"] : []), "Done"];
860
+ async function offerOpenDownloadedPath(agent: ExtensionAPI, ctx: any, outDir: string, videoDir?: string) {
861
+ const options = ["Open folder", ...(videoDir ? ["Open videos folder"] : []), "Done"];
732
862
  const choice = await ctx.ui.select(`Transcripts saved to ${outDir}`, options);
733
863
  if (!choice || choice === "Done") return;
734
864
 
735
- const target =
736
- choice === "Open folder" ? outDir : choice === "Open index.json" ? manifestPath : videoDir || outDir;
865
+ const target = choice === "Open folder" ? outDir : videoDir || outDir;
737
866
  try {
738
867
  await openPathWithSystem(agent, target);
739
868
  } catch {
@@ -741,51 +870,6 @@ async function offerOpenDownloadedPath(
741
870
  }
742
871
  }
743
872
 
744
- async function maybeDownloadVideos(
745
- ctx: any,
746
- candidates: TranscriptCandidate[],
747
- outDir: string,
748
- manifest: Array<Record<string, unknown>>,
749
- ): Promise<string | null> {
750
- const downloadable = candidates.filter((item) => typeof item.videoUrl === "string" && item.videoUrl.trim());
751
- if (!downloadable.length) return null;
752
-
753
- const choice = await ctx.ui.select(`Also download ${downloadable.length} full video file(s)?`, [
754
- "Download videos",
755
- "Skip",
756
- ]);
757
- if (choice !== "Download videos") {
758
- ctx.ui.notify(
759
- `${buildTranscriptDownloadSummary(manifest, outDir)}\n\nSkipped video download. Say 'download the videos too' if you want them next.`,
760
- "info",
761
- );
762
- return null;
763
- }
764
-
765
- const videoDir = join(outDir, "videos");
766
- await mkdir(videoDir, { recursive: true });
767
-
768
- for (const candidate of downloadable) {
769
- const url = String(candidate.videoUrl);
770
- const originalName = pickVideoFileName({ file_name: candidate.fileNameHint, storage_url: candidate.videoUrl, id: candidate.assetId });
771
- const ext = extname(originalName) || ".mp4";
772
- const stem = sanitizeFilePart(originalName.replace(ext, ""), "video");
773
- const filePath = join(videoDir, `${stem}${ext}`);
774
-
775
- ctx.ui.notify(`Downloading video: ${originalName}`, "info");
776
- const response = await fetch(url);
777
- if (!response.ok) {
778
- ctx.ui.notify(`Failed to download ${originalName}: HTTP ${response.status}`, "warning");
779
- continue;
780
- }
781
- const bytes = Buffer.from(await response.arrayBuffer());
782
- await writeFile(filePath, bytes);
783
- }
784
-
785
- ctx.ui.notify(`${buildTranscriptDownloadSummary(manifest, outDir)}\n\nVideos saved to ${videoDir}`, "info");
786
- return videoDir;
787
- }
788
-
789
873
  async function exportTranscriptsForRequest(agent: ExtensionAPI, ctx: any, args: TranscriptExportArgs) {
790
874
  const request = args.request?.trim();
791
875
  if (!request) {
@@ -827,15 +911,29 @@ async function exportTranscriptsForRequest(agent: ExtensionAPI, ctx: any, args:
827
911
  };
828
912
  }
829
913
 
830
- ctx.ui.notify(`Looking up full-conversation VTT files in ${program.slug}...`, "info");
831
- let assets = await listCandidateAssets(program.slug, args.partyId, intent.date);
832
- assets = await filterByMeetingTime(assets, intent.time);
833
- assets = assets.filter((asset) => !!ensureVtt(asset));
834
- if (intent.person && !args.partyId) {
835
- const personFilteredAssets = filterAssetsByPersonText(assets, intent.person);
836
- if (personFilteredAssets.length) {
837
- assets = personFilteredAssets;
914
+ const filterAssetsForIntent = async (inputAssets: any[]) => {
915
+ let filtered = await filterByMeetingTime(inputAssets, intent.time);
916
+ filtered = filtered.filter((asset) => !!ensureVtt(asset));
917
+ if (!args.partyId) {
918
+ const peopleQueries = Array.from(new Set([...(intent.people ?? []), ...(intent.person ? [intent.person] : [])].filter(Boolean)));
919
+ if (peopleQueries.length) {
920
+ const personFilteredAssets = filtered.filter((asset) => {
921
+ const haystack = [asset?.title, asset?.key_question, asset?.file_name]
922
+ .filter((v) => typeof v === "string" && v.trim())
923
+ .join(" ");
924
+ return peopleQueries.some((query) => exactNormalizedTextMatch(query, haystack));
925
+ });
926
+ if (personFilteredAssets.length) filtered = personFilteredAssets;
927
+ }
838
928
  }
929
+ return filtered;
930
+ };
931
+
932
+ ctx.ui.notify(`Looking up full-conversation VTT files in ${program.slug}...`, "info");
933
+ let assets = await filterAssetsForIntent(await listCandidateAssets(program.slug, args.partyId, intent.date, "full_conversation"));
934
+ if (!assets.length) {
935
+ ctx.ui.notify(`No full-conversation VTT found. Checking clip transcript text to generate VTT in ${program.slug}...`, "info");
936
+ assets = await filterAssetsForIntent(await listCandidateAssets(program.slug, args.partyId, intent.date, "clip"));
839
937
  }
840
938
 
841
939
  let candidates = assets
@@ -845,7 +943,7 @@ async function exportTranscriptsForRequest(agent: ExtensionAPI, ctx: any, args:
845
943
  candidates = applyLatestFilter(candidates, intent.latestCount, (row) => row.sortTimestamp);
846
944
 
847
945
  if (!candidates.length) {
848
- ctx.ui.notify("No matching full-conversation VTT files found. Checking meeting transcript rows...", "info");
946
+ ctx.ui.notify("No matching asset transcript content found. Checking meeting transcript rows...", "info");
849
947
  candidates = await listFallbackTranscriptCandidates(program.slug, intent);
850
948
  }
851
949
 
@@ -860,10 +958,39 @@ async function exportTranscriptsForRequest(agent: ExtensionAPI, ctx: any, args:
860
958
  };
861
959
  }
862
960
 
863
- const outDir = resolveOutputDir(program.slug, intent.outDir);
961
+ const unresolvedSource = candidates.filter((candidate) => !hasTranscriptSourceRef(candidate));
962
+ const unresolvedPerson = candidates.filter((candidate) => !hasNamedPerson(candidate.person));
963
+
964
+ if (unresolvedSource.length || unresolvedPerson.length) {
965
+ const unresolvedRows = candidates
966
+ .filter((candidate) => !hasTranscriptSourceRef(candidate) || !hasNamedPerson(candidate.person))
967
+ .slice(0, 5)
968
+ .map((candidate) => {
969
+ const sourceRef = hasTranscriptSourceRef(candidate) ? "ok" : "missing-source";
970
+ const personRef = hasNamedPerson(candidate.person) ? "ok" : "missing-person";
971
+ return `- ${candidate.eventDate} :: ${candidate.person || "unknown"} [${sourceRef}; ${personRef}]`;
972
+ })
973
+ .join("\n");
974
+
975
+ const message = `Strict transcript export guard blocked this download. ${
976
+ unresolvedSource.length
977
+ } row(s) are missing source reference (assetId/meetingId), and ${unresolvedPerson.length} row(s) are missing a named person.`;
978
+ ctx.ui.notify(`${message}${unresolvedRows ? `\n${unresolvedRows}` : ""}`, "warning");
979
+ return {
980
+ program: program.slug,
981
+ request,
982
+ count: 0,
983
+ files: [],
984
+ message,
985
+ };
986
+ }
987
+
988
+ const baseOutDir = resolveOutputDir(program.slug, intent.outDir);
989
+ const planned = planCandidateFileNames(candidates);
990
+ const outDir = intent.outDir ? baseOutDir : join(baseOutDir, buildExportBatchFolderName(planned));
864
991
  if (!intent.outDir && ctx.hasUI !== false) {
865
992
  const choice = await ctx.ui.select(
866
- buildTranscriptCandidateSummary(candidates, outDir),
993
+ buildTranscriptCandidateSummary(planned, outDir),
867
994
  ["Download", "Cancel"],
868
995
  );
869
996
  if (choice !== "Download") {
@@ -871,7 +998,7 @@ async function exportTranscriptsForRequest(agent: ExtensionAPI, ctx: any, args:
871
998
  program: program.slug,
872
999
  request,
873
1000
  cancelled: true,
874
- count: candidates.length,
1001
+ count: planned.length,
875
1002
  files: [],
876
1003
  message: "Transcript download cancelled.",
877
1004
  };
@@ -880,21 +1007,9 @@ async function exportTranscriptsForRequest(agent: ExtensionAPI, ctx: any, args:
880
1007
 
881
1008
  await mkdir(outDir, { recursive: true });
882
1009
 
883
- const used = new Set<string>();
884
1010
  const manifest: Array<Record<string, unknown>> = [];
885
1011
 
886
- for (const candidate of candidates) {
887
- const safePerson = sanitizeFilePart(candidate.person, "unassigned");
888
- const safeMeeting = sanitizeFilePart(candidate.meetingId || "no-meeting", "no-meeting");
889
- const baseName = `${candidate.eventDate}__${safePerson}__${safeMeeting}`;
890
- let fileName = `${baseName}.vtt`;
891
- let suffix = 2;
892
- while (used.has(fileName)) {
893
- fileName = `${baseName}__${suffix}.vtt`;
894
- suffix += 1;
895
- }
896
- used.add(fileName);
897
-
1012
+ for (const { candidate, fileName } of planned) {
898
1013
  await writeFile(join(outDir, fileName), candidate.vtt, "utf8");
899
1014
  manifest.push({
900
1015
  file: fileName,
@@ -903,6 +1018,7 @@ async function exportTranscriptsForRequest(agent: ExtensionAPI, ctx: any, args:
903
1018
  meetingId: candidate.meetingId ?? null,
904
1019
  eventDate: candidate.eventDate,
905
1020
  person: candidate.person,
1021
+ vttMode: candidate.vttMode ?? "native",
906
1022
  videoUrl: candidate.videoUrl ?? null,
907
1023
  audioUrl: candidate.audioUrl ?? null,
908
1024
  });
@@ -919,21 +1035,25 @@ async function exportTranscriptsForRequest(agent: ExtensionAPI, ctx: any, args:
919
1035
  };
920
1036
  }
921
1037
 
922
- const manifestPath = join(outDir, "index.json");
923
- await writeFile(manifestPath, JSON.stringify(manifest, null, 2), "utf8");
924
-
1038
+ const generatedCount = manifest.filter((row: any) => row?.vttMode === "generated_from_text").length;
925
1039
  const summary = buildTranscriptDownloadSummary(manifest, outDir);
926
- ctx.ui.notify(summary, "info");
1040
+ ctx.ui.notify(
1041
+ generatedCount > 0
1042
+ ? `${summary}\n\nGenerated ${generatedCount} VTT file${generatedCount === 1 ? "" : "s"} from transcript text.`
1043
+ : summary,
1044
+ "info",
1045
+ );
927
1046
 
928
- const videoDir = ctx.hasUI === false ? null : await maybeDownloadVideos(ctx, candidates, outDir, manifest);
929
- if (ctx.hasUI !== false) await offerOpenDownloadedPath(agent, ctx, outDir, manifestPath, videoDir ?? undefined);
1047
+ if (ctx.hasUI !== false) await offerOpenDownloadedPath(agent, ctx, outDir);
1048
+ const downloadableVideoCount = candidates.filter((item) => typeof item.videoUrl === "string" && item.videoUrl.trim()).length;
930
1049
 
931
1050
  return {
932
1051
  program: program.slug,
933
1052
  request,
934
1053
  outDir,
935
- manifestPath,
936
- videoDir,
1054
+ manifestPath: null,
1055
+ videoDir: null,
1056
+ downloadableVideoCount,
937
1057
  count: manifest.length,
938
1058
  files: manifest,
939
1059
  message: summary,
@@ -941,6 +1061,22 @@ async function exportTranscriptsForRequest(agent: ExtensionAPI, ctx: any, args:
941
1061
  }
942
1062
 
943
1063
  export function registerTranscriptIntentInterceptor(pi: ExtensionAPI) {
1064
+ let exportSucceededThisTurn = false;
1065
+ let exportSucceededWithoutPersonThisTurn = false;
1066
+ const successfulPersonKeys = new Set<string>();
1067
+ const pendingExportArgs = new Map<string, { programSlug: string; person: string }>();
1068
+
1069
+ const resetExportGuardState = () => {
1070
+ exportSucceededThisTurn = false;
1071
+ exportSucceededWithoutPersonThisTurn = false;
1072
+ successfulPersonKeys.clear();
1073
+ pendingExportArgs.clear();
1074
+ };
1075
+
1076
+ pi.on("turn_start", () => {
1077
+ resetExportGuardState();
1078
+ });
1079
+
944
1080
  pi.registerTool({
945
1081
  name: "seedclub_export_transcripts",
946
1082
  label: "Export Transcripts",
@@ -953,9 +1089,14 @@ export function registerTranscriptIntentInterceptor(pi: ExtensionAPI) {
953
1089
  person: Type.Optional(Type.String({ description: "Optional exact guest/person text confirmed before calling this tool." })),
954
1090
  date: Type.Optional(Type.String({ description: "Optional event date in YYYY-MM-DD format." })),
955
1091
  time: Type.Optional(Type.String({ description: "Optional time qualifier, e.g. 2pm or 14:00." })),
956
- outDir: Type.Optional(Type.String({ description: "Optional output directory. Defaults to ~/Downloads/seedclub/transcripts/<program-slug>/." })),
1092
+ outDir: Type.Optional(Type.String({ description: "Optional output directory. Defaults to ~/Downloads/<program-slug>/ with a per-request subfolder." })),
957
1093
  latestCount: Type.Optional(Type.Number({ description: "Optional latest-result count, max 20." })),
958
1094
  }),
1095
+ renderCall: makeProgressCallRenderer("Exporting transcript files", (args) => args?.programSlug || args?.person || args?.date || undefined),
1096
+ renderResult: makeProgressResultRenderer("Transcript export complete", (details) => {
1097
+ const count = typeof details?.count === "number" ? details.count : 0;
1098
+ return `${count} file${count === 1 ? "" : "s"}`;
1099
+ }),
959
1100
  execute: async (_toolCallId, params, _signal, _onUpdate, ctx) => {
960
1101
  try {
961
1102
  const result = await exportTranscriptsForRequest(pi, ctx, params);
@@ -966,9 +1107,24 @@ export function registerTranscriptIntentInterceptor(pi: ExtensionAPI) {
966
1107
  isError: true,
967
1108
  };
968
1109
  }
1110
+ const previewFiles = Array.isArray((result as any).files)
1111
+ ? (result as any).files.slice(0, 8).map((row: any) => row?.file).filter(Boolean)
1112
+ : [];
969
1113
  return {
970
1114
  content: [{ type: "text" as const, text: result.message }],
971
- details: result,
1115
+ details: {
1116
+ program: (result as any).program ?? null,
1117
+ request: (result as any).request ?? null,
1118
+ outDir: (result as any).outDir ?? null,
1119
+ manifestPath: (result as any).manifestPath ?? null,
1120
+ videoDir: (result as any).videoDir ?? null,
1121
+ downloadableVideoCount: (result as any).downloadableVideoCount ?? 0,
1122
+ count: (result as any).count ?? 0,
1123
+ cancelled: (result as any).cancelled === true,
1124
+ filesPreview: previewFiles,
1125
+ moreFiles: Math.max(0, ((result as any).count ?? 0) - previewFiles.length),
1126
+ message: (result as any).message ?? null,
1127
+ },
972
1128
  };
973
1129
  } catch (error) {
974
1130
  const message = error instanceof Error ? error.message : String(error);
@@ -982,6 +1138,56 @@ export function registerTranscriptIntentInterceptor(pi: ExtensionAPI) {
982
1138
  },
983
1139
  });
984
1140
 
1141
+ pi.on("tool_call", async (event) => {
1142
+ if (event.toolName !== "seedclub_export_transcripts") return;
1143
+ const request = typeof event.input?.request === "string" ? event.input.request : "";
1144
+ if (!isExplicitTranscriptExportRequest(request)) {
1145
+ return {
1146
+ block: true,
1147
+ reason:
1148
+ "seedclub_export_transcripts is only for explicit transcript/VTT file download requests. Use transcript discovery/review tools for analysis or recap questions.",
1149
+ };
1150
+ }
1151
+
1152
+ const programSlug = typeof event.input?.programSlug === "string" ? event.input.programSlug.trim().toLowerCase() : "";
1153
+ const personRaw = typeof event.input?.person === "string" ? event.input.person : "";
1154
+ const personKey = normalizeFuzzyText(personRaw || "");
1155
+ const scopedKey = `${programSlug}::${personKey}`;
1156
+
1157
+ if (exportSucceededWithoutPersonThisTurn) {
1158
+ return {
1159
+ block: true,
1160
+ reason: "A transcript export already succeeded in this turn. Reuse the returned transcript instead of exporting again.",
1161
+ };
1162
+ }
1163
+
1164
+ if (exportSucceededThisTurn) {
1165
+ if (!personKey || successfulPersonKeys.has(scopedKey)) {
1166
+ return {
1167
+ block: true,
1168
+ reason: "Transcript already exported for this person in this turn.",
1169
+ };
1170
+ }
1171
+ }
1172
+
1173
+ pendingExportArgs.set(event.toolCallId, { programSlug, person: personKey });
1174
+ });
1175
+
1176
+ pi.on("tool_result", async (event) => {
1177
+ if (event.toolName !== "seedclub_export_transcripts") return;
1178
+ const pending = pendingExportArgs.get(event.toolCallId);
1179
+ pendingExportArgs.delete(event.toolCallId);
1180
+ if (event.isError) return;
1181
+ const count = typeof (event.details as any)?.count === "number" ? (event.details as any).count : 0;
1182
+ if (count <= 0) return;
1183
+ exportSucceededThisTurn = true;
1184
+ if (!pending?.person) {
1185
+ exportSucceededWithoutPersonThisTurn = true;
1186
+ return;
1187
+ }
1188
+ successfulPersonKeys.add(`${pending.programSlug}::${pending.person}`);
1189
+ });
1190
+
985
1191
  pi.on("before_agent_start", async (event) => {
986
1192
  if (!isLikelyTranscriptRequest(event.prompt)) return;
987
1193
  return {
@@ -992,6 +1198,9 @@ IMPORTANT SEED CLUB TRANSCRIPT EXPORT ROUTING:
992
1198
  - Keep the user's prompt in normal conversation context.
993
1199
  - If person/date/program constraints are not clear, ask or use metadata tools first; do not let seedclub_export_transcripts perform fuzzy disambiguation.
994
1200
  - Prefer calling seedclub_export_transcripts only after the constraints are exact, with request set to the user's original prompt.
1201
+ - Avoid chaining high-payload media/transcript tools for export asks when seedclub_export_transcripts can satisfy the request.
1202
+ - For transcript export requests, download transcripts only in the tool. If videos are available, ask as a follow-up in chat instead of prompting inside the tool.
1203
+ - Once a transcript export succeeds for the identified person in this turn, stop calling transcript export again.
995
1204
  - Do not answer by dumping transcript text or raw JSON into chat.`,
996
1205
  };
997
1206
  });