@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.
- package/assets/extensions/seedclub/branding.ts +16 -16
- package/assets/extensions/seedclub/commands/transcript-intent.ts +338 -129
- package/assets/extensions/seedclub/commands/transcripts.ts +1 -3
- package/assets/extensions/seedclub/tool-utils.ts +74 -0
- package/assets/extensions/seedclub/tools/crm.ts +35 -1
- package/assets/extensions/seedclub/tools/media.ts +121 -6
- package/assets/extensions/seedclub/tools/meetings.ts +80 -6
- package/assets/extensions/seedclub/tools/utility.ts +12 -1
- package/assets/extensions/seedclub-ui/editor.ts +6 -1
- package/assets/extensions/seedclub-ui/index.ts +2 -0
- package/assets/extensions/seedclub-ui/tool-progress.ts +39 -0
- package/assets/extensions/seedclub-ui/welcome.ts +23 -2
- package/assets/theme/dark.json +1 -1
- package/assets/theme/light.json +1 -1
- package/bin/cli.js +2 -2
- package/bin/pi-main-launcher.js +20 -6
- package/package.json +1 -1
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
|
-
import {
|
|
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
|
-
|
|
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
|
|
453
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
536
|
-
if (!
|
|
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
|
-
|
|
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(
|
|
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
|
|
598
|
-
if (!
|
|
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
|
|
693
|
-
const
|
|
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 (
|
|
700
|
-
rows.push(`- ...and ${
|
|
835
|
+
if (planned.length > rows.length) {
|
|
836
|
+
rows.push(`- ...and ${planned.length - rows.length} more`);
|
|
701
837
|
}
|
|
702
838
|
|
|
703
839
|
return [
|
|
704
|
-
`Found ${
|
|
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
|
-
|
|
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
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
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
|
|
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
|
|
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(
|
|
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:
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
|
|
929
|
-
|
|
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
|
|
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:
|
|
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
|
});
|