@clubnet/seedclub 0.2.18 → 0.2.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +32 -23
  2. package/assets/extensions/seedclub/api-client.ts +24 -6
  3. package/assets/extensions/seedclub/auth.ts +181 -10
  4. package/assets/extensions/seedclub/branding.ts +13 -0
  5. package/assets/extensions/seedclub/commands/clip-status.ts +187 -0
  6. package/assets/extensions/seedclub/commands/seedclub.ts +169 -25
  7. package/assets/extensions/seedclub/commands/transcript-intent.ts +998 -0
  8. package/assets/extensions/seedclub/commands/transcripts.ts +383 -0
  9. package/assets/extensions/seedclub/index.ts +218 -27
  10. package/assets/extensions/seedclub/package-lock.json +8 -1
  11. package/assets/extensions/seedclub/package.json +5 -2
  12. package/assets/extensions/seedclub/tool-utils.ts +45 -3
  13. package/assets/extensions/seedclub/tools/crm.ts +183 -0
  14. package/assets/extensions/seedclub/tools/media.ts +217 -0
  15. package/assets/extensions/seedclub/tools/meetings.ts +1053 -0
  16. package/assets/extensions/seedclub/tools/utility.ts +106 -9
  17. package/assets/extensions/seedclub-ui/editor.ts +16 -29
  18. package/assets/extensions/seedclub-ui/footer.ts +1 -3
  19. package/assets/extensions/seedclub-ui/state.ts +10 -1
  20. package/assets/extensions/seedclub-ui/welcome.ts +295 -94
  21. package/assets/theme/dark.json +47 -59
  22. package/assets/theme/light.json +49 -61
  23. package/bin/cli.js +386 -138
  24. package/bin/pi-main-launcher.js +29 -0
  25. package/package.json +6 -2
  26. package/postinstall.js +1 -1
  27. package/assets/extensions/seedclub/commands/add.ts +0 -601
  28. package/assets/extensions/seedclub/commands/extract.ts +0 -123
  29. package/assets/extensions/seedclub/commands/signals.ts +0 -86
  30. package/assets/extensions/seedclub/commands/sort.ts +0 -91
  31. package/assets/extensions/seedclub/dia-cookies.ts +0 -126
  32. package/assets/extensions/seedclub/extraction/capture.ts +0 -47
  33. package/assets/extensions/seedclub/extraction/schema.ts +0 -117
  34. package/assets/extensions/seedclub/tools/extractions.ts +0 -517
  35. package/assets/extensions/seedclub/tools/signals.ts +0 -275
  36. package/assets/extensions/seedclub/twitter-client.ts +0 -277
package/README.md CHANGED
@@ -1,12 +1,12 @@
1
1
  # seedclub
2
2
 
3
- A branded command-line agent wrapper around pi, with integrated Seed Club commands, tools, and app actions.
3
+ Seed Club's pi-based agent for authenticated access to Seed Club programs, CRM, meetings, media, and headlines.
4
4
 
5
5
  Requirements: Node.js 22+
6
6
 
7
7
  ## What it is
8
8
 
9
- `seedclub` is the Seed Club distribution of pi. This repo provides the `seedclub` launcher, installs the Seed Club theme and extensions, and adds Seed Club-specific workflows for connecting your account, capturing signals, sorting them, and interacting with Seed Club-backed app surfaces.
9
+ `seedclub` is the Seed Club distribution of pi. This repo provides the `seedclub` launcher, installs the Seed Club theme and extensions, and adds Seed Club-specific workflows for connecting your account, using CRM/meeting/media tools, retrieving transcripts, and working with Seed Club-backed app surfaces.
10
10
 
11
11
  This repo is the source of truth for the published package:
12
12
 
@@ -46,21 +46,11 @@ curl -fsSL https://raw.githubusercontent.com/seedclub/seedclub-agent/main/instal
46
46
 
47
47
  `@clubnet/seedclub` is currently a private npm package. This auth is only for installing or updating the package from npm. It is separate from `/login` and `/connect` inside the app.
48
48
 
49
- Fast path:
50
-
51
- ```bash
52
- SEEDCLUB_NPM_TOKEN=YOUR_NPM_TOKEN curl -fsSL https://raw.githubusercontent.com/seedclub/seedclub-agent/main/install.sh | bash
53
- seedclub setup-auth
54
- ```
55
-
56
- Manual one-time `.npmrc` setup:
57
-
58
49
  ```bash
59
- echo "@clubnet:registry=https://registry.npmjs.org/" >> ~/.npmrc
60
- echo "//registry.npmjs.org/:_authToken=YOUR_NPM_TOKEN" >> ~/.npmrc
50
+ npm login
61
51
  ```
62
52
 
63
- Then `npm install -g @clubnet/seedclub` works.
53
+ Then `npm install -g @clubnet/seedclub` and `seedclub update` work.
64
54
 
65
55
  ## Core workflow
66
56
 
@@ -69,7 +59,7 @@ The normal interactive flow is:
69
59
  1. Start the app with `seedclub`
70
60
  2. Complete `/login`, `/model`, and `/connect` if this is your first run
71
61
  3. Open `/seedclub`
72
- 4. Choose whether to add signals or sort unsorted signals
62
+ 4. Choose the CRM, meetings, media, recordings, or transcript workflow you need
73
63
 
74
64
  ## Commands
75
65
 
@@ -78,10 +68,10 @@ The normal interactive flow is:
78
68
  | `/login` | Sign in to a model provider for the underlying agent |
79
69
  | `/model` | Choose which model to use |
80
70
  | `/connect` | Connect your Seed Club account |
81
- | `/seedclub` | Open the main Seed Club menu; if not connected yet, it starts the connect flow |
82
- | `/add <url|@handle|query>` | Add signals from URLs, X handles, bookmarks, likes, following, or search |
83
- | `/sort` | Review unsorted signals and sort them automatically, in the browser, or delete them |
84
- | `seedclub setup-auth` | Configure npm auth for npmjs private package access in `~/.npmrc` |
71
+ | `/seedclub` | Main menu connect, inspect access, and jump into CRM/meetings/media/headlines workflows |
72
+ | `/transcripts` | Export transcript VTT files with filters (date, person, time, output dir) |
73
+
74
+ Natural-language transcript retrieval is also supported (no slash command required). Examples: `download vibhu transcripts from 11am`, `i need transcripts for all guests on 11am last week`. Seed Club will run metadata-first export confirmation and then write VTT files.
85
75
 
86
76
  ## Auth
87
77
 
@@ -94,6 +84,23 @@ There are two separate auth layers in the product:
94
84
 
95
85
  `/seedclub` is the main entry point for Seed Club actions. If you are not connected yet, it will start the Seed Club connect flow automatically.
96
86
 
87
+ Power-user env overrides:
88
+
89
+ ```bash
90
+ export SEEDCLUB_API_URL=http://localhost:3001
91
+ export SEEDCLUB_AUTH_URL=http://localhost:3000
92
+ export SEEDCLUB_ACCESS_TOKEN=<bearer-token>
93
+ ```
94
+
95
+ Production defaults are already built into the auth extension:
96
+
97
+ ```bash
98
+ SEEDCLUB_API_URL=https://api.seedclub.com
99
+ SEEDCLUB_AUTH_URL=https://auth.seedclub.com
100
+ ```
101
+
102
+ You do not need to export those for normal use. Only set env vars when you want to override them for local/dev.
103
+
97
104
  ## How it works
98
105
 
99
106
  seedclub is an npm package (`@clubnet/seedclub`) that wraps [pi](https://github.com/badlogic/pi-mono) as a dependency. Installing the package globally gives you the `seedclub` command.
@@ -129,9 +136,9 @@ seedclub pins versions in `package.json`:
129
136
 
130
137
  ```json
131
138
  {
132
- "version": "0.2.0",
139
+ "version": "0.2.19",
133
140
  "dependencies": {
134
- "@mariozechner/pi-coding-agent": "0.52.12"
141
+ "@mariozechner/pi-coding-agent": "0.65.2"
135
142
  }
136
143
  }
137
144
  ```
@@ -155,6 +162,8 @@ It's a standard pi theme with 51 color tokens. Edit it to change any visual aspe
155
162
 
156
163
  The `vars` block at the top defines reusable colors (e.g. `brand: "#00C853"`) that are referenced throughout `colors`. To change the brand color, just update `vars.brand`.
157
164
 
165
+ Seed Club keeps product-level names in `vars` (`editorBg`, `messageBg`, `successBg`, `errorBg`) and maps pi's required tokens to those names (`selectedBg`, `userMessageBg`, `toolPendingBg`, etc.). The CLI probes the terminal background with OSC 11 and sets `COLORFGBG` before pi starts when no explicit theme is configured, so light/dark defaults track the actual terminal window instead of pi's fallback.
166
+
158
167
  **Hot reload:** Edit the theme file while seedclub is running and it reloads instantly.
159
168
 
160
169
  Colors can be hex (`"#00C853"`), 256-color palette index (`242`), a reference to a `vars` entry (`"brand"`), or empty string (`""`) for the terminal default.
@@ -249,8 +258,8 @@ seedclub # test
249
258
  # When ready, bump package version and publish
250
259
  git add -A
251
260
  git commit -m "update extensions"
252
- git push
253
- npm publish
261
+ npm version patch|minor|major
262
+ git push --follow-tags
254
263
  ```
255
264
 
256
265
  For reproducible extension dependency installs, commit `assets/extensions/seedclub/package-lock.json` and keep it in sync when changing extension deps.
@@ -52,12 +52,26 @@ interface RequestOptions {
52
52
  params?: Record<string, string | number | undefined>;
53
53
  }
54
54
 
55
+ async function shouldClearCredentialsAfterUnauthorized(apiBase: string, token: string): Promise<boolean> {
56
+ try {
57
+ const url = new URL("/session/context", apiBase.endsWith("/") ? apiBase : `${apiBase}/`);
58
+ const response = await fetch(url.toString(), {
59
+ method: "GET",
60
+ headers: { Authorization: `Bearer ${token}` },
61
+ });
62
+ return response.status === 401;
63
+ } catch {
64
+ return false;
65
+ }
66
+ }
67
+
55
68
  async function apiRequest<T>(endpoint: string, options: RequestOptions = {}): Promise<T> {
56
69
  const { method = "GET", body, params } = options;
57
70
  const token = await getAuthToken();
58
71
  const apiBase = cachedApiBase || getApiBase();
59
72
 
60
- const url = new URL(`/api/mcp${endpoint}`, apiBase);
73
+ const path = endpoint.startsWith("/") ? endpoint : `/${endpoint}`;
74
+ const url = new URL(path, apiBase.endsWith("/") ? apiBase : `${apiBase}/`);
61
75
  if (params) {
62
76
  for (const [key, value] of Object.entries(params)) {
63
77
  if (value !== undefined) url.searchParams.set(key, String(value));
@@ -70,11 +84,6 @@ async function apiRequest<T>(endpoint: string, options: RequestOptions = {}): Pr
70
84
  body: body ? JSON.stringify(body) : undefined,
71
85
  });
72
86
 
73
- if (response.status === 401) {
74
- await clearCredentials();
75
- throw new ApiError(401, "Token expired or revoked. Run /seedclub to reconnect.");
76
- }
77
-
78
87
  const text = await response.text();
79
88
  let data: any;
80
89
  try {
@@ -86,6 +95,15 @@ async function apiRequest<T>(endpoint: string, options: RequestOptions = {}): Pr
86
95
  throw new ApiError(response.status, `Invalid JSON response from server: ${text.slice(0, 200)}`);
87
96
  }
88
97
 
98
+ if (response.status === 401) {
99
+ const shouldClear = await shouldClearCredentialsAfterUnauthorized(apiBase, token);
100
+ if (shouldClear) {
101
+ await clearCredentials();
102
+ throw new ApiError(401, "Token expired or revoked. Run /seedclub to reconnect.");
103
+ }
104
+ throw new ApiError(401, data?.error || "Request failed (401)", data?.details);
105
+ }
106
+
89
107
  if (!response.ok) {
90
108
  throw new ApiError(response.status, data.error || `Request failed (${response.status})`, data.details);
91
109
  }
@@ -1,19 +1,26 @@
1
1
  /**
2
2
  * Token storage for Seed Club.
3
3
  *
4
- * Priority: SEEDCLUB_TOKEN env var > stored token file.
4
+ * Priority: SEEDCLUB_ACCESS_TOKEN / SEEDCLUB_TOKEN env var > stored token file.
5
5
  * Use /seedclub to connect.
6
6
  */
7
7
 
8
8
  import { chmod, mkdir, readFile, unlink, writeFile } from "node:fs/promises";
9
+ import { readFileSync } from "node:fs";
9
10
  import { homedir } from "node:os";
10
11
  import { join } from "node:path";
11
12
 
12
13
  const CONFIG_DIR = join(homedir(), ".config", "seedclub");
13
14
  const TOKEN_FILE = join(CONFIG_DIR, "token");
15
+ const BASES_FILE = join(CONFIG_DIR, "bases.json");
14
16
  const LEGACY_CONFIG_DIR = join(homedir(), ".config", "looseleaf");
15
17
  const LEGACY_TOKEN_FILE = join(LEGACY_CONFIG_DIR, "token");
16
- const DEFAULT_API_BASE = "https://looseleaf-rouge.vercel.app";
18
+ const DEFAULT_API_BASE = "https://api.seedclub.com";
19
+ const DEFAULT_AUTH_BASE = "https://auth.seedclub.com";
20
+ const LOCAL_API_BASE = "http://localhost:3001";
21
+ const LOCAL_AUTH_BASE = "http://localhost:3000";
22
+
23
+ export type SeedclubEnvironmentMode = "local" | "prod" | "custom";
17
24
 
18
25
  export interface StoredToken {
19
26
  token: string;
@@ -21,33 +28,134 @@ export interface StoredToken {
21
28
  name?: string;
22
29
  createdAt: string;
23
30
  apiBase: string;
31
+ authBase?: string;
32
+ }
33
+
34
+ export interface StoredBases {
35
+ apiBase: string;
36
+ authBase: string;
37
+ mode: SeedclubEnvironmentMode;
38
+ updatedAt: string;
24
39
  }
25
40
 
26
41
  let _cachedApiBase: string | null = null;
42
+ let _cachedAuthBase: string | null = null;
43
+ let _cachedBases: StoredBases | null | undefined = undefined;
44
+
45
+ function shouldPreferLocalBases(): boolean {
46
+ return (
47
+ process.env.SEEDCLUB_LOCAL === "1" ||
48
+ process.env.SEEDCLUB_LOCAL === "true" ||
49
+ process.env.SEEDCLUB_ENV === "local"
50
+ );
51
+ }
52
+
53
+ function tryReadStoredBasesSync(): StoredBases | null {
54
+ if (_cachedBases !== undefined) {
55
+ return _cachedBases;
56
+ }
57
+
58
+ try {
59
+ 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
+ ) {
67
+ _cachedBases = null;
68
+ return null;
69
+ }
70
+ _cachedBases = stored;
71
+ return stored;
72
+ } catch {
73
+ _cachedBases = null;
74
+ return null;
75
+ }
76
+ }
27
77
 
28
78
  export function getApiBase(): string {
29
- if (process.env.SEEDCLUB_API || process.env.SEED_NETWORK_API)
30
- return process.env.SEEDCLUB_API || process.env.SEED_NETWORK_API!;
79
+ if (
80
+ process.env.SEEDCLUB_API_URL ||
81
+ process.env.SEEDCLUB_API ||
82
+ process.env.SEED_NETWORK_API
83
+ )
84
+ return process.env.SEEDCLUB_API_URL || process.env.SEEDCLUB_API || process.env.SEED_NETWORK_API!;
85
+ if (shouldPreferLocalBases()) return LOCAL_API_BASE;
86
+ const storedBases = tryReadStoredBasesSync();
87
+ if (storedBases?.apiBase) return storedBases.apiBase;
31
88
  if (_cachedApiBase) return _cachedApiBase;
32
89
  return DEFAULT_API_BASE;
33
90
  }
34
91
 
92
+ export function getAuthBase(): string {
93
+ if (
94
+ process.env.SEEDCLUB_AUTH_URL ||
95
+ process.env.SEEDCLUB_AUTH ||
96
+ process.env.SEED_NETWORK_AUTH
97
+ )
98
+ return process.env.SEEDCLUB_AUTH_URL || process.env.SEEDCLUB_AUTH || process.env.SEED_NETWORK_AUTH!;
99
+ if (shouldPreferLocalBases()) return LOCAL_AUTH_BASE;
100
+ const storedBases = tryReadStoredBasesSync();
101
+ if (storedBases?.authBase) return storedBases.authBase;
102
+ if (_cachedAuthBase) return _cachedAuthBase;
103
+ return DEFAULT_AUTH_BASE;
104
+ }
105
+
35
106
  export function setCachedApiBase(apiBase: string): void {
36
107
  _cachedApiBase = apiBase;
37
108
  }
38
109
 
110
+ export function setCachedAuthBase(authBase: string): void {
111
+ _cachedAuthBase = authBase;
112
+ }
113
+
39
114
  export function clearCachedApiBase(): void {
40
115
  _cachedApiBase = null;
41
116
  }
42
117
 
118
+ export function clearCachedAuthBase(): void {
119
+ _cachedAuthBase = null;
120
+ }
121
+
122
+ export function getDefaultBases(mode: "local" | "prod"): { apiBase: string; authBase: string } {
123
+ if (mode === "local") {
124
+ return {
125
+ apiBase: LOCAL_API_BASE,
126
+ authBase: LOCAL_AUTH_BASE,
127
+ };
128
+ }
129
+
130
+ return {
131
+ apiBase: DEFAULT_API_BASE,
132
+ authBase: DEFAULT_AUTH_BASE,
133
+ };
134
+ }
135
+
43
136
  async function tryReadTokenFile(path: string): Promise<StoredToken | null> {
44
137
  try {
45
138
  const content = await readFile(path, "utf-8");
46
139
  const stored = JSON.parse(content) as StoredToken;
47
- if (!stored.token || !stored.token.startsWith("sn_")) return null;
48
- if (stored.apiBase && !process.env.SEEDCLUB_API) {
140
+ if (!stored.token || typeof stored.token !== "string" || !stored.token.trim()) return null;
141
+ if (
142
+ stored.apiBase &&
143
+ !shouldPreferLocalBases() &&
144
+ !process.env.SEEDCLUB_API_URL &&
145
+ !process.env.SEEDCLUB_API &&
146
+ !process.env.SEED_NETWORK_API
147
+ ) {
49
148
  _cachedApiBase = stored.apiBase;
50
149
  }
150
+ if (
151
+ stored.authBase &&
152
+ !shouldPreferLocalBases() &&
153
+ !process.env.SEEDCLUB_AUTH_URL &&
154
+ !process.env.SEEDCLUB_AUTH &&
155
+ !process.env.SEED_NETWORK_AUTH
156
+ ) {
157
+ _cachedAuthBase = stored.authBase;
158
+ }
51
159
  return stored;
52
160
  } catch {
53
161
  return null;
@@ -61,24 +169,85 @@ export async function getStoredToken(): Promise<StoredToken | null> {
61
169
  return await tryReadTokenFile(LEGACY_TOKEN_FILE);
62
170
  }
63
171
 
172
+ export async function getStoredBases(): Promise<StoredBases | null> {
173
+ try {
174
+ const content = await readFile(BASES_FILE, "utf-8");
175
+ const stored = JSON.parse(content) as StoredBases;
176
+ if (
177
+ !stored ||
178
+ typeof stored.apiBase !== "string" ||
179
+ typeof stored.authBase !== "string" ||
180
+ typeof stored.mode !== "string"
181
+ ) {
182
+ return null;
183
+ }
184
+ _cachedBases = stored;
185
+ return stored;
186
+ } catch {
187
+ return null;
188
+ }
189
+ }
190
+
64
191
  export async function getToken(): Promise<string | null> {
65
- if (process.env.SEEDCLUB_TOKEN || process.env.SEED_NETWORK_TOKEN)
66
- return (process.env.SEEDCLUB_TOKEN || process.env.SEED_NETWORK_TOKEN)!;
192
+ if (process.env.SEEDCLUB_ACCESS_TOKEN || process.env.SEEDCLUB_TOKEN || process.env.SEED_NETWORK_TOKEN)
193
+ return (process.env.SEEDCLUB_ACCESS_TOKEN || process.env.SEEDCLUB_TOKEN || process.env.SEED_NETWORK_TOKEN)!;
67
194
  const stored = await getStoredToken();
68
195
  return stored?.token ?? null;
69
196
  }
70
197
 
198
+ export async function storeBases(
199
+ apiBase: string,
200
+ authBase: string,
201
+ mode: SeedclubEnvironmentMode = "custom",
202
+ ): Promise<void> {
203
+ const stored: StoredBases = {
204
+ apiBase,
205
+ authBase,
206
+ mode,
207
+ updatedAt: new Date().toISOString(),
208
+ };
209
+ _cachedApiBase = apiBase;
210
+ _cachedAuthBase = authBase;
211
+ _cachedBases = stored;
212
+ await mkdir(CONFIG_DIR, { recursive: true, mode: 0o700 });
213
+ await writeFile(BASES_FILE, JSON.stringify(stored, null, 2), { mode: 0o600 });
214
+ try {
215
+ await chmod(BASES_FILE, 0o600);
216
+ } catch {}
217
+ }
218
+
219
+ export async function clearStoredBases(): Promise<boolean> {
220
+ _cachedBases = null;
221
+ try {
222
+ await unlink(BASES_FILE);
223
+ return true;
224
+ } catch {
225
+ return false;
226
+ }
227
+ }
228
+
71
229
  export async function storeToken(
72
230
  token: string,
73
231
  email: string,
74
232
  apiBase: string,
75
- options?: { name?: string },
233
+ options?: { authBase?: string; name?: string },
76
234
  ): Promise<void> {
235
+ _cachedApiBase = apiBase;
236
+ if (options?.authBase) {
237
+ _cachedAuthBase = options.authBase;
238
+ }
77
239
  await mkdir(CONFIG_DIR, { recursive: true, mode: 0o700 });
78
240
  await writeFile(
79
241
  TOKEN_FILE,
80
242
  JSON.stringify(
81
- { token, email, name: options?.name, createdAt: new Date().toISOString(), apiBase },
243
+ {
244
+ token,
245
+ email,
246
+ name: options?.name,
247
+ createdAt: new Date().toISOString(),
248
+ apiBase,
249
+ authBase: options?.authBase,
250
+ },
82
251
  null,
83
252
  2,
84
253
  ),
@@ -90,6 +259,8 @@ export async function storeToken(
90
259
  }
91
260
 
92
261
  export async function clearStoredToken(): Promise<boolean> {
262
+ _cachedApiBase = null;
263
+ _cachedAuthBase = null;
93
264
  try {
94
265
  await unlink(TOKEN_FILE);
95
266
  return true;
@@ -6,6 +6,19 @@ IMPORTANT BRANDING RULES:
6
6
  - Never mention the name \"pi\" in any response.
7
7
  - If the user mentions \"pi\", interpret it as Seed Club and respond using \"Seed Club\".
8
8
  - When summarizing intent or describing the system, use Seed Club wording only.
9
+
10
+ IMPORTANT SEED CLUB API ROUTING:
11
+ - For transcript-first discovery/review asks (not file retrieval), call seedclub_list_meeting_transcripts first with the program slug and limit 20.
12
+ - For transcript inventory questions like "what was the last day we have transcripts for 11am?", keep the user prompt in the normal agent flow and answer with the date plus the full transcript_for names from returned rows.
13
+ - Treat conversation-level transcripts as valid 11am interview/show transcripts. For "latest/last transcript" questions, use seedclub_list_meeting_transcripts.latest.overall first; only use latest.meeting when the user explicitly asks for meeting-level records.
14
+ - Treat "on 11am" or "for 11am" as the 11am program slug when that program is accessible, not as an 11am time filter.
15
+ - If the user asks for "all transcripts" and the tool returns a limited page, summarize the returned transcript rows and say you can fetch full text for selected rows. Do not claim exhaustive coverage unless the API result proves it.
16
+ - Use includeFullText only after narrowing to rows the user wants to inspect.
17
+ - Use seedclub_list_program_media_assets(assetKind=full_conversation) only when the user asks for full conversations or media assets. Do not use it as the default source for transcript inventory.
18
+ - For transcript retrieval asks (e.g., "I need transcripts...", "get transcripts...", guest/date transcript pulls), first use chat and metadata tools to establish exact constraints when needed, then call seedclub_export_transcripts with the user's original request. This preserves the user prompt in context, confirms destination, then writes VTT files.
19
+ - Treat short transcript artifact prompts like "today's transcripts", "2pm transcripts", "Tuesday VTTs", or "recent captions" as local transcript export tool requests, not inline transcript display requests.
20
+ - Do not paste full transcript JSON or raw session context into the reply unless the user explicitly asks for raw data.
21
+ - Use seedclub_list_show_recordings for show recording URLs, then follow transcript event_date only when needed.
9
22
  `;
10
23
 
11
24
  const BRAND_REGEX = /\bpi\b/gi;
@@ -0,0 +1,187 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import { api } from "../api-client.js";
3
+ import { getSessionContext } from "../tools/utility.js";
4
+
5
+ const DEFAULT_LIMIT = 25;
6
+ const MAX_LIMIT = 25;
7
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
8
+
9
+ interface ClipStatusOptions {
10
+ programSlug?: string;
11
+ from?: string;
12
+ to?: string;
13
+ limit: number;
14
+ }
15
+
16
+ function isoDate(value: Date) {
17
+ return value.toISOString().slice(0, 10);
18
+ }
19
+
20
+ function startOfDayUTC(value: Date) {
21
+ return new Date(Date.UTC(value.getUTCFullYear(), value.getUTCMonth(), value.getUTCDate(), 0, 0, 0, 0));
22
+ }
23
+
24
+ function addDays(value: Date, days: number) {
25
+ return new Date(value.getTime() + days * MS_PER_DAY);
26
+ }
27
+
28
+ function normalizeLimit(value?: number) {
29
+ if (!Number.isFinite(value)) return DEFAULT_LIMIT;
30
+ return Math.max(1, Math.min(MAX_LIMIT, Math.trunc(value!)));
31
+ }
32
+
33
+ function parseArgs(raw?: string): ClipStatusOptions {
34
+ const text = (raw ?? "").trim();
35
+ const now = new Date();
36
+ const weekStart = startOfDayUTC(addDays(now, -6));
37
+ const tomorrow = startOfDayUTC(addDays(now, 1));
38
+
39
+ const options: ClipStatusOptions = {
40
+ from: isoDate(weekStart),
41
+ to: isoDate(tomorrow),
42
+ limit: DEFAULT_LIMIT,
43
+ };
44
+
45
+ const from = text.match(/(?:--from|from)\s+(\d{4}-\d{2}-\d{2})/i);
46
+ if (from?.[1]) options.from = from[1];
47
+
48
+ const to = text.match(/(?:--to|to)\s+(\d{4}-\d{2}-\d{2})/i);
49
+ if (to?.[1]) options.to = to[1];
50
+
51
+ const date = text.match(/(?:--date|date|on)\s+(\d{4}-\d{2}-\d{2})/i);
52
+ if (date?.[1]) {
53
+ options.from = date[1];
54
+ const next = addDays(new Date(`${date[1]}T00:00:00.000Z`), 1);
55
+ options.to = isoDate(next);
56
+ }
57
+
58
+ const limit = text.match(/(?:--limit|limit)\s+(\d{1,2})/i);
59
+ if (limit?.[1]) options.limit = normalizeLimit(Number.parseInt(limit[1], 10));
60
+
61
+ const lastDays = text.match(/(?:last)\s+(\d{1,2})\s*(?:d|day|days)/i);
62
+ if (lastDays?.[1]) {
63
+ const days = Math.max(1, Number.parseInt(lastDays[1], 10));
64
+ const rangeStart = startOfDayUTC(addDays(now, -(days - 1)));
65
+ options.from = isoDate(rangeStart);
66
+ options.to = isoDate(tomorrow);
67
+ }
68
+
69
+ const program = text.match(/(?:--program|program)\s+([a-z0-9-]+)/i);
70
+ if (program?.[1]) options.programSlug = program[1];
71
+
72
+ return options;
73
+ }
74
+
75
+ function parseLeadingProgramSlug(raw: string, accessibleSlugs: string[]): string | undefined {
76
+ const firstToken = raw.trim().split(/\s+/).find(Boolean);
77
+ if (!firstToken) return undefined;
78
+ const token = firstToken.toLowerCase();
79
+ return accessibleSlugs.find((slug) => slug.toLowerCase() === token);
80
+ }
81
+
82
+ async function resolveProgramSlug(ctx: any, session: any, options: ClipStatusOptions, rawArgs: string): Promise<string | null> {
83
+ const slugs: string[] = (session.program_access ?? []).map((entry: any) => entry?.program?.slug).filter(Boolean);
84
+ if (!slugs.length) return null;
85
+ if (options.programSlug && slugs.includes(options.programSlug)) return options.programSlug;
86
+
87
+ const fromLeadingToken = parseLeadingProgramSlug(rawArgs, slugs);
88
+ if (fromLeadingToken) return fromLeadingToken;
89
+
90
+ if (slugs.length === 1) return slugs[0];
91
+ const selected = await ctx.ui.select("Choose a program for clip status", [...slugs, "Cancel"]);
92
+ if (!selected || selected === "Cancel") return null;
93
+ return selected;
94
+ }
95
+
96
+ function titleForMeeting(row: any) {
97
+ return row?.meeting?.title ?? "Untitled meeting";
98
+ }
99
+
100
+ function startsAtForMeeting(row: any) {
101
+ return row?.meeting?.starts_at ?? null;
102
+ }
103
+
104
+ function formatRow(prefix: string, row: any, reason?: string) {
105
+ const startsAt = startsAtForMeeting(row);
106
+ const date = typeof startsAt === "string" ? startsAt.slice(0, 10) : "unknown-date";
107
+ const title = titleForMeeting(row);
108
+ return reason ? `${prefix} ${date} — ${title} (${reason})` : `${prefix} ${date} — ${title}`;
109
+ }
110
+
111
+ export function registerClipStatusCommand(pi: ExtensionAPI) {
112
+ pi.registerCommand("clip-status", {
113
+ description:
114
+ "Show clip readiness for a program/date range. Defaults to last 7 days. Example: /clip-status 11am last 10 days",
115
+ handler: async (args, ctx) => {
116
+ try {
117
+ const rawArgs = (args ?? "").trim();
118
+ const options = parseArgs(rawArgs);
119
+
120
+ const session = await getSessionContext();
121
+ if ("error" in session) {
122
+ ctx.ui.notify(`Unable to load session context: ${session.error}`, "error");
123
+ return;
124
+ }
125
+
126
+ const programSlug = await resolveProgramSlug(ctx, session, options, rawArgs);
127
+ if (!programSlug) {
128
+ ctx.ui.notify("No program selected. Try /clip-status <program-slug>", "warning");
129
+ return;
130
+ }
131
+
132
+ const meetingsResponse = await api.get<any>("/meetings", {
133
+ program_slug: programSlug,
134
+ from: options.from ? `${options.from}T00:00:00Z` : undefined,
135
+ to: options.to ? `${options.to}T00:00:00Z` : undefined,
136
+ limit: normalizeLimit(options.limit),
137
+ });
138
+
139
+ const rows = Array.isArray(meetingsResponse?.data) ? meetingsResponse.data : [];
140
+ if (!rows.length) {
141
+ ctx.ui.notify(
142
+ `No meetings found for ${programSlug} in range ${options.from ?? "?"} → ${options.to ?? "?"}.`,
143
+ "info",
144
+ );
145
+ return;
146
+ }
147
+
148
+ const transcriptResults = await Promise.all(
149
+ rows.map(async (row: any) => {
150
+ const meetingId = row?.meeting?.id;
151
+ if (!meetingId) return { row, transcriptAvailable: false, reason: "missing_meeting_id" };
152
+ try {
153
+ const transcriptResponse = await api.get<any>(`/meetings/${meetingId}/transcript`);
154
+ const transcriptAvailable = !!transcriptResponse?.transcript;
155
+ return { row, transcriptAvailable, reason: transcriptAvailable ? null : "no_transcript" };
156
+ } catch {
157
+ return { row, transcriptAvailable: false, reason: "transcript_lookup_failed" };
158
+ }
159
+ }),
160
+ );
161
+
162
+ const ready = transcriptResults.filter((item) => item.transcriptAvailable);
163
+ const blocked = transcriptResults.filter((item) => !item.transcriptAvailable);
164
+
165
+ ctx.ui.setStatus("clips", `${ready.length}/${rows.length} ready`);
166
+
167
+ const readyLines = ready.slice(0, 12).map((item) => formatRow("✓", item.row));
168
+ const blockedLines = blocked.slice(0, 12).map((item) => formatRow("•", item.row, item.reason ?? undefined));
169
+
170
+ const summary = [
171
+ `Clip status for ${programSlug}`,
172
+ `range: ${options.from ?? "?"} → ${options.to ?? "?"}`,
173
+ `ready: ${ready.length}`,
174
+ `blocked: ${blocked.length}`,
175
+ readyLines.length ? `\nReady\n${readyLines.join("\n")}` : "",
176
+ blockedLines.length ? `\nBlocked\n${blockedLines.join("\n")}` : "",
177
+ rows.length > 12 ? `\nShowing first 12 of ${rows.length} meetings.` : "",
178
+ ].filter(Boolean);
179
+
180
+ ctx.ui.notify(summary.join("\n"), "info");
181
+ } catch (error) {
182
+ const message = error instanceof Error ? error.message : String(error);
183
+ ctx.ui.notify(`Clip status failed: ${message}`, "error");
184
+ }
185
+ },
186
+ });
187
+ }