@clubnet/seedclub 0.2.32 → 0.2.34

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.
@@ -20,8 +20,6 @@ const DEFAULT_AUTH_BASE = "https://auth.seedclub.com";
20
20
  const LOCAL_API_BASE = "http://localhost:3001";
21
21
  const LOCAL_AUTH_BASE = "http://localhost:3000";
22
22
 
23
- export type SeedclubEnvironmentMode = "local" | "prod" | "custom";
24
-
25
23
  export interface StoredToken {
26
24
  token: string;
27
25
  email: string;
@@ -34,8 +32,6 @@ export interface StoredToken {
34
32
  export interface StoredBases {
35
33
  apiBase: string;
36
34
  authBase: string;
37
- mode: SeedclubEnvironmentMode;
38
- updatedAt: string;
39
35
  }
40
36
 
41
37
  let _cachedApiBase: string | null = null;
@@ -50,6 +46,23 @@ function shouldPreferLocalBases(): boolean {
50
46
  );
51
47
  }
52
48
 
49
+ function normalizeStoredBases(value: unknown): StoredBases | null {
50
+ if (!value || typeof value !== "object") {
51
+ return null;
52
+ }
53
+
54
+ const stored = value as Partial<StoredBases>;
55
+
56
+ if (typeof stored.apiBase !== "string" || typeof stored.authBase !== "string") {
57
+ return null;
58
+ }
59
+
60
+ return {
61
+ apiBase: stored.apiBase,
62
+ authBase: stored.authBase,
63
+ };
64
+ }
65
+
53
66
  function tryReadStoredBasesSync(): StoredBases | null {
54
67
  if (_cachedBases !== undefined) {
55
68
  return _cachedBases;
@@ -57,13 +70,8 @@ function tryReadStoredBasesSync(): StoredBases | null {
57
70
 
58
71
  try {
59
72
  const content = readFileSync(BASES_FILE, "utf-8");
60
- const stored = JSON.parse(content) as StoredBases;
61
- if (
62
- !stored ||
63
- typeof stored.apiBase !== "string" ||
64
- typeof stored.authBase !== "string" ||
65
- typeof stored.mode !== "string"
66
- ) {
73
+ const stored = normalizeStoredBases(JSON.parse(content));
74
+ if (!stored) {
67
75
  _cachedBases = null;
68
76
  return null;
69
77
  }
@@ -158,13 +166,8 @@ export async function getStoredToken(): Promise<StoredToken | null> {
158
166
  export async function getStoredBases(): Promise<StoredBases | null> {
159
167
  try {
160
168
  const content = await readFile(BASES_FILE, "utf-8");
161
- const stored = JSON.parse(content) as StoredBases;
162
- if (
163
- !stored ||
164
- typeof stored.apiBase !== "string" ||
165
- typeof stored.authBase !== "string" ||
166
- typeof stored.mode !== "string"
167
- ) {
169
+ const stored = normalizeStoredBases(JSON.parse(content));
170
+ if (!stored) {
168
171
  return null;
169
172
  }
170
173
  _cachedBases = stored;
@@ -183,13 +186,10 @@ export async function getToken(): Promise<string | null> {
183
186
  export async function storeBases(
184
187
  apiBase: string,
185
188
  authBase: string,
186
- mode: SeedclubEnvironmentMode = "custom",
187
189
  ): Promise<void> {
188
190
  const stored: StoredBases = {
189
191
  apiBase,
190
192
  authBase,
191
- mode,
192
- updatedAt: new Date().toISOString(),
193
193
  };
194
194
  _cachedApiBase = apiBase;
195
195
  _cachedAuthBase = authBase;
@@ -41,7 +41,6 @@ async function showSeedEnvironment(ctx: any) {
41
41
  const lines = [
42
42
  `effective api: ${getApiBase()}`,
43
43
  `effective auth: ${getAuthBase()}`,
44
- `stored mode: ${storedBases?.mode ?? "none"}`,
45
44
  `stored api: ${storedBases?.apiBase ?? "none"}`,
46
45
  `stored auth: ${storedBases?.authBase ?? "none"}`,
47
46
  ];
@@ -50,7 +49,7 @@ async function showSeedEnvironment(ctx: any) {
50
49
 
51
50
  async function setSeedEnvironment(mode: "local" | "prod", ctx: any) {
52
51
  const bases = getDefaultBases(mode);
53
- await storeBases(bases.apiBase, bases.authBase, mode);
52
+ await storeBases(bases.apiBase, bases.authBase);
54
53
  ctx.ui.notify(
55
54
  `Seed Club environment set to ${mode}.\napi: ${bases.apiBase}\nauth: ${bases.authBase}\nReconnect if your current token belongs to a different environment.`,
56
55
  "info",
@@ -113,13 +112,15 @@ export function registerSeedclubCommand(pi: ExtensionAPI, deps: SeedclubDeps) {
113
112
  const isConnected = !!stored || hasEnvToken;
114
113
 
115
114
  if (!isConnected) {
116
- return await deps.connect(args, ctx);
115
+ await deps.connect(args, ctx);
116
+ return;
117
117
  }
118
118
 
119
119
  const user = await getCurrentUser();
120
120
  if ("error" in user) {
121
121
  ctx.ui.notify("Session expired. Reconnecting...", "info");
122
- return await deps.connect(args, ctx);
122
+ await deps.connect(args, ctx);
123
+ return;
123
124
  }
124
125
 
125
126
  const session = await getSessionContext();
@@ -0,0 +1,354 @@
1
+ function normalizeWhitespace(value) {
2
+ return value.replace(/[\u2018\u2019]/g, "'").replace(/\s+/g, " ").trim();
3
+ }
4
+
5
+ const NON_PERSON_QUERIES = new Set([
6
+ "both",
7
+ "all",
8
+ "everyone",
9
+ "everybody",
10
+ "anyone",
11
+ "anybody",
12
+ "each",
13
+ "each of these",
14
+ "all of these",
15
+ "these",
16
+ "those",
17
+ "this",
18
+ "them",
19
+ "the above",
20
+ "above",
21
+ "interviews",
22
+ "calls",
23
+ "meetings",
24
+ "conversations",
25
+ "episodes",
26
+ ]);
27
+
28
+ const NON_PERSON_PREFIXES = new Set([
29
+ "i",
30
+ "we",
31
+ "download",
32
+ "get",
33
+ "grab",
34
+ "pull",
35
+ "save",
36
+ "export",
37
+ "need",
38
+ "want",
39
+ "send",
40
+ "give",
41
+ "retrieve",
42
+ "latest",
43
+ "last",
44
+ "most",
45
+ "recent",
46
+ "the",
47
+ ]);
48
+
49
+ const NON_PERSON_TOKENS = new Set([
50
+ "i",
51
+ "we",
52
+ "me",
53
+ "us",
54
+ "need",
55
+ "want",
56
+ "download",
57
+ "export",
58
+ "get",
59
+ "last",
60
+ "latest",
61
+ "recent",
62
+ "stream",
63
+ "show",
64
+ "11am",
65
+ ]);
66
+
67
+ function trimQuotes(value) {
68
+ return value.replace(/^['\"]|['\"]$/g, "");
69
+ }
70
+
71
+ function normalizePersonQuery(raw) {
72
+ if (!raw) return undefined;
73
+ const cleaned = normalizeWhitespace(raw)
74
+ .replace(/\b(?:interviews?|calls?|meetings?|conversations?|episodes?|transcripts?|vtts?|stream|show)\b/gi, "")
75
+ .replace(/\b(?:each|all)\s+of\s+(?:these|those|them)\b/gi, "")
76
+ .replace(/\b(?:on|at|in|from)\b\s*$/i, "")
77
+ .trim();
78
+ if (!cleaned) return undefined;
79
+
80
+ const lower = cleaned.toLowerCase();
81
+ if (NON_PERSON_QUERIES.has(lower)) return undefined;
82
+ if (
83
+ /\b(today(?:'s|s)?|yesterday(?:'s|s)?|tomorrow(?:'s|s)?|tonight|morning|afternoon|evening|monday|mon|tuesday|tues|tue|toesday|wednesday|wed|thursday|thurs|thu|friday|fri|saturday|sat|sunday|sun|latest|recent|last)\b/.test(
84
+ lower,
85
+ )
86
+ ) {
87
+ return undefined;
88
+ }
89
+ if (/^\d{1,2}(?::\d{2})?\s*(?:am|pm)$/.test(lower)) return undefined;
90
+ if (/^(?:these|those|this|them|each|the\s+above|above)$/.test(lower)) return undefined;
91
+
92
+ const parts = lower.split(/\s+/).filter(Boolean);
93
+ if (!parts.length) return undefined;
94
+
95
+ const firstWord = parts[0];
96
+ if (NON_PERSON_PREFIXES.has(firstWord)) return undefined;
97
+ if (parts.some((part) => NON_PERSON_TOKENS.has(part))) return undefined;
98
+
99
+ return cleaned;
100
+ }
101
+
102
+ function formatLocalDate(date) {
103
+ const year = date.getFullYear();
104
+ const month = String(date.getMonth() + 1).padStart(2, "0");
105
+ const day = String(date.getDate()).padStart(2, "0");
106
+ return `${year}-${month}-${day}`;
107
+ }
108
+
109
+ function addLocalDays(date, days) {
110
+ const next = new Date(date);
111
+ next.setDate(next.getDate() + days);
112
+ return next;
113
+ }
114
+
115
+ function parseRelativeDateHint(text) {
116
+ const lower = normalizeWhitespace(text).toLowerCase();
117
+ const today = new Date();
118
+
119
+ if (/\b(today(?:'s|s)?|tonight)\b/.test(lower)) return formatLocalDate(today);
120
+ if (/\byesterday(?:'s|s)?\b/.test(lower)) return formatLocalDate(addLocalDays(today, -1));
121
+ if (/\btomorrow(?:'s|s)?\b/.test(lower)) return formatLocalDate(addLocalDays(today, 1));
122
+
123
+ const weekdayMatch = lower.match(
124
+ /\b(?:(last|next)\s+)?(monday|mon|tuesday|tues|tue|toesday|wednesday|wed|thursday|thurs|thu|friday|fri|saturday|sat|sunday|sun)(?:'s|s)?\b/,
125
+ );
126
+ if (!weekdayMatch) return undefined;
127
+
128
+ const weekdayByName = {
129
+ sunday: 0,
130
+ sun: 0,
131
+ monday: 1,
132
+ mon: 1,
133
+ tuesday: 2,
134
+ tues: 2,
135
+ tue: 2,
136
+ toesday: 2,
137
+ wednesday: 3,
138
+ wed: 3,
139
+ thursday: 4,
140
+ thurs: 4,
141
+ thu: 4,
142
+ friday: 5,
143
+ fri: 5,
144
+ saturday: 6,
145
+ sat: 6,
146
+ };
147
+ const modifier = weekdayMatch[1];
148
+ const targetDay = weekdayByName[weekdayMatch[2]];
149
+ const currentDay = today.getDay();
150
+ let offset = targetDay - currentDay;
151
+
152
+ if (modifier === "next") {
153
+ offset = offset <= 0 ? offset + 7 : offset;
154
+ } else if (modifier === "last") {
155
+ offset = offset >= 0 ? offset - 7 : offset;
156
+ } else if (offset > 0) {
157
+ offset -= 7;
158
+ }
159
+
160
+ return formatLocalDate(addLocalDays(today, offset));
161
+ }
162
+
163
+ function parseDateHint(text) {
164
+ const iso = text.match(/\b(\d{4}-\d{2}-\d{2})\b/);
165
+ if (iso?.[1]) return iso[1];
166
+
167
+ const relative = parseRelativeDateHint(text);
168
+ if (relative) return relative;
169
+
170
+ const monthDay = text.match(/\b(?:on\s+)?(january|february|march|april|may|june|july|august|september|october|november|december)\s+(\d{1,2})(?:st|nd|rd|th)?(?:,?\s+(\d{4}))?\b/i);
171
+ if (!monthDay) return undefined;
172
+
173
+ const monthNames = ["january", "february", "march", "april", "may", "june", "july", "august", "september", "october", "november", "december"];
174
+ const monthIndex = monthNames.indexOf(monthDay[1].toLowerCase());
175
+ if (monthIndex < 0) return undefined;
176
+
177
+ const day = Number.parseInt(monthDay[2], 10);
178
+ if (!Number.isFinite(day) || day < 1 || day > 31) return undefined;
179
+
180
+ const year = monthDay[3] ? Number.parseInt(monthDay[3], 10) : new Date().getFullYear();
181
+ const date = new Date(Date.UTC(year, monthIndex, day));
182
+ if (Number.isNaN(date.getTime())) return undefined;
183
+ return date.toISOString().slice(0, 10);
184
+ }
185
+
186
+ function parseIntent(input) {
187
+ const text = normalizeWhitespace(input);
188
+ const intent = {};
189
+
190
+ const betweenPair = text.match(/\bbetween\s+([\w'. -]{2,80}?)\s*(?:and|&)\s*([\w'. -]{2,80}?)(?=\s+(?:from|on|in|at|for|transcripts?|vtts?|interviews?|calls?|meetings?|date|to)\b|[?.!,]|$)/i);
191
+ if (betweenPair?.[1] && betweenPair?.[2]) {
192
+ const first = normalizePersonQuery(betweenPair[1]);
193
+ const second = normalizePersonQuery(betweenPair[2]);
194
+ const people = [first, second].filter(Boolean);
195
+ if (people.length) intent.people = Array.from(new Set(people));
196
+ }
197
+
198
+ const personWith = text.match(/\bwith\s+([\w'. -]{2,80}?)(?=\s+(?:from|on|in|at|for|transcripts?|vtts?|interviews?|calls?|meetings?|date|to)\b|$)/i);
199
+ if (personWith?.[1]) intent.person = normalizePersonQuery(personWith[1]);
200
+
201
+ if (!intent.person) {
202
+ const personFor = text.match(/\bfor\s+([\w'. -]{2,80}?)(?=\s+(?:from|on|in|at|transcripts?|vtts?|interviews?|calls?|meetings?|date|to)\b|$)/i);
203
+ if (personFor?.[1]) intent.person = normalizePersonQuery(personFor[1]);
204
+ }
205
+
206
+ if (!intent.person) {
207
+ const verbBeforeTranscript = text.match(
208
+ /\b(?:download|get|export|save|pull|grab|retrieve|fetch|send|give)\s+([a-z][\w'.-]*(?:\s+[a-z][\w'.-]*){0,3})\s+transcripts?\b/i,
209
+ );
210
+ if (verbBeforeTranscript?.[1]) {
211
+ intent.person = normalizePersonQuery(verbBeforeTranscript[1]);
212
+ }
213
+ }
214
+
215
+ if (!intent.person) {
216
+ const beforeTranscript = text.match(/\b([a-z][\w'.-]*(?:\s+[a-z][\w'.-]*){0,3})\s+transcripts?\b/i);
217
+ if (beforeTranscript?.[1]) {
218
+ const candidate = normalizePersonQuery(beforeTranscript[1]);
219
+ if (candidate && !/\b(?:need|want|download|get|last|latest)\b/i.test(candidate)) {
220
+ intent.person = candidate;
221
+ }
222
+ }
223
+ }
224
+
225
+ if ((!intent.people || !intent.people.length) && intent.person) {
226
+ intent.people = [intent.person];
227
+ }
228
+
229
+ const latestN = text.match(/\b(?:last|latest|most recent)\s+(\d{1,2})\b/i);
230
+ if (latestN?.[1]) {
231
+ intent.latestCount = Math.max(1, Math.min(20, Number.parseInt(latestN[1], 10)));
232
+ } else if (/\b(?:last|latest|most recent)\b/i.test(text)) {
233
+ intent.latestCount = 1;
234
+ } else if (/\brecent\b/i.test(text)) {
235
+ intent.latestCount = 5;
236
+ }
237
+
238
+ intent.date = parseDateHint(text);
239
+ if ((intent.people?.length ?? 0) > 1 && /\b(?:both|between|across|and|&)\b/i.test(text)) {
240
+ intent.date = undefined;
241
+ }
242
+
243
+ 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);
244
+ 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);
245
+ const timeMatch = explicitTimeMatch ?? bareTimeMatch;
246
+ if (timeMatch?.[1]) {
247
+ const normalizedTime = timeMatch[1].replace(/\s+/g, "").toLowerCase();
248
+ if (explicitTimeMatch || normalizedTime !== "11am") intent.time = normalizedTime;
249
+ }
250
+
251
+ const outMatch = text.match(/\b(?:to|into|in)\s+(~\/\S+|\/\S+)/i);
252
+ if (outMatch?.[1]) intent.outDir = trimQuotes(outMatch[1]);
253
+
254
+ return intent;
255
+ }
256
+
257
+ function parseTimeFilter(input) {
258
+ if (!input) return null;
259
+ const value = input.trim().toLowerCase();
260
+ const twentyFourHour = value.match(/^([01]?\d|2[0-3]):([0-5]\d)$/);
261
+ if (twentyFourHour) {
262
+ return {
263
+ hour: Number.parseInt(twentyFourHour[1], 10),
264
+ minute: Number.parseInt(twentyFourHour[2], 10),
265
+ };
266
+ }
267
+
268
+ const match = value.match(/^(\d{1,2})(?::(\d{2}))?(am|pm)$/);
269
+ if (!match) return null;
270
+ let hour = Number.parseInt(match[1], 10);
271
+ const minute = match[2] ? Number.parseInt(match[2], 10) : undefined;
272
+ const period = match[3];
273
+ if (hour < 1 || hour > 12) return null;
274
+ if (minute !== undefined && (minute < 0 || minute > 59)) return null;
275
+ if (period === "am") {
276
+ hour = hour === 12 ? 0 : hour;
277
+ } else {
278
+ hour = hour === 12 ? 12 : hour + 12;
279
+ }
280
+ return { hour, minute };
281
+ }
282
+
283
+ function normalizeFuzzyText(value) {
284
+ return value
285
+ .toLowerCase()
286
+ .replace(/[^a-z0-9\s]/g, " ")
287
+ .replace(/\s+/g, " ")
288
+ .trim();
289
+ }
290
+
291
+ function exactNormalizedTextMatch(query, target) {
292
+ const q = normalizeFuzzyText(query);
293
+ const t = normalizeFuzzyText(target);
294
+ if (!q || !t) return false;
295
+ if (q === t) return true;
296
+ return new RegExp(`(^| )${q.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}( |$)`).test(t);
297
+ }
298
+
299
+ function normalizeTranscriptText(value) {
300
+ return value.replace(/\r\n/g, "\n").trim();
301
+ }
302
+
303
+ function isLikelyVtt(value) {
304
+ const text = value.trim();
305
+ if (!text) return false;
306
+ 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);
307
+ }
308
+
309
+ function toTimestamp(totalSeconds) {
310
+ const ms = Math.max(0, Math.floor(totalSeconds * 1000));
311
+ const h = Math.floor(ms / 3600000);
312
+ const m = Math.floor((ms % 3600000) / 60000);
313
+ const s = Math.floor((ms % 60000) / 1000);
314
+ const milli = ms % 1000;
315
+ return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}.${String(milli).padStart(3, "0")}`;
316
+ }
317
+
318
+ function textToSyntheticVtt(text) {
319
+ const normalized = normalizeTranscriptText(text);
320
+ if (!normalized) return null;
321
+
322
+ const sections = normalized
323
+ .split(/\n\s*\n+/)
324
+ .map((value) => value.trim())
325
+ .filter(Boolean)
326
+ .slice(0, 500);
327
+ if (!sections.length) return null;
328
+
329
+ let cursor = 0;
330
+ const cues = [];
331
+ for (const section of sections) {
332
+ const charCount = section.length;
333
+ const duration = Math.max(2, Math.min(10, Math.ceil(charCount / 28)));
334
+ const start = toTimestamp(cursor);
335
+ const end = toTimestamp(cursor + duration);
336
+ cues.push(`${start} --> ${end}\n${section}`);
337
+ cursor += duration;
338
+ }
339
+
340
+ return `WEBVTT\n\n${cues.join("\n\n")}`;
341
+ }
342
+
343
+ export {
344
+ exactNormalizedTextMatch,
345
+ isLikelyVtt,
346
+ normalizeFuzzyText,
347
+ normalizePersonQuery,
348
+ normalizeWhitespace,
349
+ parseDateHint,
350
+ parseIntent,
351
+ parseRelativeDateHint,
352
+ parseTimeFilter,
353
+ textToSyntheticVtt,
354
+ };
@@ -0,0 +1,59 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import {
4
+ exactNormalizedTextMatch,
5
+ normalizePersonQuery,
6
+ parseDateHint,
7
+ parseIntent,
8
+ parseTimeFilter,
9
+ textToSyntheticVtt,
10
+ } from "./transcript-export-helpers.js";
11
+
12
+ test("normalizePersonQuery strips transcript artifact words", () => {
13
+ assert.equal(normalizePersonQuery("Vibhu transcripts"), "Vibhu");
14
+ assert.equal(normalizePersonQuery("recent captions"), undefined);
15
+ });
16
+
17
+ test("parseDateHint handles explicit calendar dates", () => {
18
+ assert.equal(parseDateHint("captions for Vibhu on March 2, 2026"), "2026-03-02");
19
+ assert.equal(parseDateHint("2026-04-24 transcripts"), "2026-04-24");
20
+ });
21
+
22
+ test("parseIntent captures person, date, explicit time, and output path", () => {
23
+ const intent = parseIntent("download Vibhu transcripts on March 2, 2026 at 14:00 to ~/Downloads/custom");
24
+ assert.deepEqual(intent, {
25
+ person: "Vibhu",
26
+ people: ["Vibhu"],
27
+ date: "2026-03-02",
28
+ time: "14:00",
29
+ outDir: "~/Downloads/custom",
30
+ });
31
+ });
32
+
33
+ test("parseIntent does not treat bare 11am program mention as a time filter", () => {
34
+ const intent = parseIntent("download Vibhu transcripts from 11am");
35
+ assert.equal(intent.person, "Vibhu");
36
+ assert.equal(intent.time, undefined);
37
+ });
38
+
39
+ test("parseTimeFilter supports 24 hour and 12 hour formats", () => {
40
+ assert.deepEqual(parseTimeFilter("14:00"), { hour: 14, minute: 0 });
41
+ assert.deepEqual(parseTimeFilter("2pm"), { hour: 14, minute: undefined });
42
+ assert.equal(parseTimeFilter("25:00"), null);
43
+ });
44
+
45
+ test("exactNormalizedTextMatch avoids partial-name false positives", () => {
46
+ assert.equal(exactNormalizedTextMatch("dan", "Jordan Walker on 11am"), false);
47
+ assert.equal(exactNormalizedTextMatch("rob", "Robert Leshner on 11am"), false);
48
+ assert.equal(exactNormalizedTextMatch("vibhu", "Vibhu Norby on 11am"), true);
49
+ assert.equal(exactNormalizedTextMatch("vibhu norby", "Vibhu Norby on 11am"), true);
50
+ });
51
+
52
+ test("textToSyntheticVtt generates a valid VTT envelope from transcript text", () => {
53
+ const vtt = textToSyntheticVtt("Host: Welcome back.\n\nGuest: Thanks for having me.");
54
+ assert.ok(vtt);
55
+ assert.match(vtt, /^WEBVTT\b/);
56
+ assert.match(vtt, /-->/);
57
+ assert.match(vtt, /Host: Welcome back\./);
58
+ assert.match(vtt, /Guest: Thanks for having me\./);
59
+ });