@clubnet/seedclub 0.2.0

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.
@@ -0,0 +1,601 @@
1
+ /**
2
+ * /add — add signals from URLs, @handles, or your Twitter activity.
3
+ *
4
+ * Every signal is a URL. Content on the internet.
5
+ *
6
+ * URLs/@handles → created instantly
7
+ * "my bookmarks" → each bookmarked tweet is a signal (the tweet URL)
8
+ * "my likes" → each liked tweet is a signal (the tweet URL)
9
+ * "who I follow" → each account is a signal (the profile URL)
10
+ */
11
+
12
+ import { randomBytes } from "node:crypto";
13
+ import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
14
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
15
+ import { api } from "../api-client.js";
16
+ import { getApiBase } from "../auth.js";
17
+ import { createSignal, importSignals } from "../tools/signals.js";
18
+ import { checkTwitterCredentials, getTwitterClient } from "../twitter-client.js";
19
+
20
+ interface Signal {
21
+ type: string;
22
+ name: string;
23
+ externalUrl: string;
24
+ description?: string;
25
+ metadata?: Record<string, unknown>;
26
+ }
27
+
28
+ // ────────────────────────────────────────
29
+ // URL/handle parsing
30
+ // ────────────────────────────────────────
31
+
32
+ function tryParseUrls(raw: string): Signal[] | null {
33
+ const tokens = raw
34
+ .split(/[,\n]+/)
35
+ .flatMap((s) => s.trim().split(/\s+/))
36
+ .filter(Boolean);
37
+
38
+ const results: Signal[] = [];
39
+ for (const token of tokens) {
40
+ const signal = classifyUrl(token);
41
+ if (!signal) return null; // one bad token = not a URL list
42
+ results.push(signal);
43
+ }
44
+ return results.length > 0 ? results : null;
45
+ }
46
+
47
+ function classifyUrl(s: string): Signal | null {
48
+ s = s.trim();
49
+
50
+ // @handle
51
+ if (s.startsWith("@")) {
52
+ const handle = s.slice(1).replace(/\/$/, "");
53
+ return { type: "twitter_account", name: handle, externalUrl: `https://x.com/${handle}`, metadata: { handle } };
54
+ }
55
+
56
+ // x.com / twitter.com tweet URLs (must check before profile)
57
+ const tweet = s.match(/(?:x\.com|twitter\.com)\/([a-zA-Z0-9_]+)\/status\/(\d+)/);
58
+ if (tweet) {
59
+ const url = `https://x.com/${tweet[1]}/status/${tweet[2]}`;
60
+ return {
61
+ type: "twitter_account",
62
+ name: `Tweet by @${tweet[1]}`,
63
+ externalUrl: url,
64
+ metadata: { handle: tweet[1], tweetId: tweet[2] },
65
+ };
66
+ }
67
+
68
+ // x.com / twitter.com profile URLs
69
+ const tw = s.match(/(?:x\.com|twitter\.com)\/([a-zA-Z0-9_]+)\/?(?:\?.*)?$/);
70
+ if (tw) {
71
+ return {
72
+ type: "twitter_account",
73
+ name: tw[1],
74
+ externalUrl: `https://x.com/${tw[1]}`,
75
+ metadata: { handle: tw[1] },
76
+ };
77
+ }
78
+
79
+ // GitHub
80
+ const gh = s.match(/github\.com\/([a-zA-Z0-9_-]+(?:\/[a-zA-Z0-9_.-]+)?)/);
81
+ if (gh) return { type: "github_profile", name: gh[1], externalUrl: `https://github.com/${gh[1]}` };
82
+
83
+ // Substack
84
+ const ss = s.match(/([a-zA-Z0-9-]+)\.substack\.com/);
85
+ if (ss)
86
+ return {
87
+ type: "newsletter",
88
+ name: ss[1].replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()),
89
+ externalUrl: s.startsWith("http") ? s : `https://${s}`,
90
+ };
91
+
92
+ // Reddit
93
+ const rd = s.match(/r\/([a-zA-Z0-9_]+)/);
94
+ if (rd) return { type: "subreddit", name: `r/${rd[1]}`, externalUrl: `https://reddit.com/r/${rd[1]}` };
95
+
96
+ // Any other URL
97
+ if (s.includes("://") || s.match(/^[a-zA-Z0-9-]+\.[a-z]{2,}/)) {
98
+ const url = s.startsWith("http") ? s : `https://${s}`;
99
+ const domain = url
100
+ .replace(/https?:\/\//, "")
101
+ .replace(/\/.*/, "")
102
+ .replace(/^www\./, "");
103
+ const name = domain
104
+ .split(".")[0]
105
+ .replace(/-/g, " ")
106
+ .replace(/\b\w/g, (c) => c.toUpperCase());
107
+ return { type: "blog", name, externalUrl: url };
108
+ }
109
+
110
+ return null;
111
+ }
112
+
113
+ // ────────────────────────────────────────
114
+ // Twitter query parsing
115
+ // ────────────────────────────────────────
116
+
117
+ interface TwitterQuery {
118
+ action: "likes" | "bookmarks" | "following" | "search";
119
+ count?: number;
120
+ query?: string;
121
+ since?: Date;
122
+ author?: string; // filter by @username
123
+ }
124
+
125
+ const MONTHS = [
126
+ "january",
127
+ "february",
128
+ "march",
129
+ "april",
130
+ "may",
131
+ "june",
132
+ "july",
133
+ "august",
134
+ "september",
135
+ "october",
136
+ "november",
137
+ "december",
138
+ ];
139
+
140
+ const WORD_NUMBERS: Record<string, number> = {
141
+ one: 1,
142
+ two: 2,
143
+ three: 3,
144
+ four: 4,
145
+ five: 5,
146
+ six: 6,
147
+ seven: 7,
148
+ eight: 8,
149
+ nine: 9,
150
+ ten: 10,
151
+ eleven: 11,
152
+ twelve: 12,
153
+ };
154
+
155
+ function _parseNumber(s: string): number | undefined {
156
+ const digitMatch = s.match(/(\d+)/);
157
+ if (digitMatch) return parseInt(digitMatch[1], 10);
158
+ for (const [word, num] of Object.entries(WORD_NUMBERS)) {
159
+ if (s.includes(word)) return num;
160
+ }
161
+ return undefined;
162
+ }
163
+
164
+ function parseDateFilter(s: string): Date | undefined {
165
+ const now = new Date();
166
+
167
+ // "from 2024" / "from 2025" — just a year
168
+ const justYear = s.match(/(?:from|since|in)\s+(20\d{2})(?:\s|$)/);
169
+ if (justYear) return new Date(parseInt(justYear[1], 10), 0, 1);
170
+
171
+ // "from november 2025" / "since march"
172
+ for (let i = 0; i < MONTHS.length; i++) {
173
+ if (s.includes(MONTHS[i])) {
174
+ const yearMatch = s.match(/\b(20\d{2})\b/);
175
+ const year = yearMatch
176
+ ? parseInt(yearMatch[1], 10)
177
+ : i > now.getMonth()
178
+ ? now.getFullYear() - 1
179
+ : now.getFullYear();
180
+ return new Date(year, i, 1);
181
+ }
182
+ }
183
+
184
+ // "last 3 months" / "last three months"
185
+ const monthsAgo = s.match(/last\s+(\d+|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve)\s+months?/);
186
+ if (monthsAgo) {
187
+ const n = WORD_NUMBERS[monthsAgo[1]] || parseInt(monthsAgo[1], 10);
188
+ const d = new Date(now);
189
+ d.setMonth(d.getMonth() - n);
190
+ return d;
191
+ }
192
+
193
+ // "last 2 weeks" / "last two weeks"
194
+ const weeksAgo = s.match(/last\s+(\d+|one|two|three|four)\s+weeks?/);
195
+ if (weeksAgo) {
196
+ const n = WORD_NUMBERS[weeksAgo[1]] || parseInt(weeksAgo[1], 10);
197
+ return new Date(now.getTime() - n * 7 * 86400000);
198
+ }
199
+
200
+ if (s.includes("last week")) return new Date(now.getTime() - 7 * 86400000);
201
+ if (s.includes("last month")) {
202
+ const d = new Date(now);
203
+ d.setMonth(d.getMonth() - 1);
204
+ return d;
205
+ }
206
+ if (s.includes("last year")) {
207
+ const d = new Date(now);
208
+ d.setFullYear(d.getFullYear() - 1);
209
+ return d;
210
+ }
211
+ return undefined;
212
+ }
213
+
214
+ function parseTwitterQuery(input: string): TwitterQuery | null {
215
+ const s = input.toLowerCase().trim();
216
+
217
+ // Likes
218
+ if (s.match(/likes?/)) {
219
+ const since = parseDateFilter(s);
220
+ const author = parseAuthorFilter(s);
221
+ // Only grab a number as count if it's NOT a year and NOT part of "N months/weeks"
222
+ const countMatch = s.match(/(?:last\s+)?(\d+)\s*(?:likes?|bookmarks?)/);
223
+ const count = since ? 200 : countMatch ? parseInt(countMatch[1], 10) : 20;
224
+ return { action: "likes", count, since, author };
225
+ }
226
+
227
+ // Bookmarks
228
+ if (s.match(/bookmarks?/)) {
229
+ const since = parseDateFilter(s);
230
+ const author = parseAuthorFilter(s);
231
+ const countMatch = s.match(/(?:last\s+)?(\d+)\s*(?:likes?|bookmarks?)/);
232
+ const count = since ? 200 : countMatch ? parseInt(countMatch[1], 10) : 20;
233
+ return { action: "bookmarks", count, since, author };
234
+ }
235
+
236
+ // Following
237
+ if (s.match(/who\s+i\s+follow|my\s+following|following/)) {
238
+ return { action: "following", count: 50 };
239
+ }
240
+
241
+ // Search
242
+ const searchMatch = s.match(/(?:search|tweets?\s+about)\s+(.+)/);
243
+ if (searchMatch) {
244
+ return { action: "search", query: searchMatch[1], count: 20 };
245
+ }
246
+
247
+ return null;
248
+ }
249
+
250
+ function parseAuthorFilter(s: string): string | undefined {
251
+ const m = s.match(/(?:from|by)\s+@?([a-zA-Z0-9_]+)/);
252
+ if (
253
+ m &&
254
+ ![
255
+ "last",
256
+ "the",
257
+ "my",
258
+ "november",
259
+ "december",
260
+ "january",
261
+ "february",
262
+ "march",
263
+ "april",
264
+ "may",
265
+ "june",
266
+ "july",
267
+ "august",
268
+ "september",
269
+ "october",
270
+ "2024",
271
+ "2025",
272
+ "2026",
273
+ ].includes(m[1].toLowerCase())
274
+ ) {
275
+ return m[1];
276
+ }
277
+ return undefined;
278
+ }
279
+
280
+ // ────────────────────────────────────────
281
+ // Tweet → Signal conversion
282
+ // ────────────────────────────────────────
283
+
284
+ /** Each tweet becomes a signal — the URL is the tweet itself. */
285
+ function tweetsToSignals(tweets: any[]): Signal[] {
286
+ const seen = new Set<string>();
287
+ const signals: Signal[] = [];
288
+
289
+ for (const tweet of tweets) {
290
+ const username = tweet.author?.username || tweet.authorUsername;
291
+ const tweetId = tweet.id || tweet.tweetId;
292
+ if (!username || !tweetId) continue;
293
+
294
+ const url = `https://x.com/${username}/status/${tweetId}`;
295
+ if (seen.has(url)) continue;
296
+ seen.add(url);
297
+
298
+ const text = tweet.text || tweet.fullText || "";
299
+
300
+ signals.push({
301
+ type: "twitter_account",
302
+ name: `@${username}`,
303
+ externalUrl: url,
304
+ description: text || undefined,
305
+ metadata: {
306
+ handle: username,
307
+ tweetId,
308
+ authorName: tweet.author?.name || tweet.authorName,
309
+ createdAt: tweet.createdAt,
310
+ likeCount: tweet.likeCount,
311
+ retweetCount: tweet.retweetCount,
312
+ },
313
+ });
314
+ }
315
+ return signals;
316
+ }
317
+
318
+ /** Following/followers → account signals */
319
+ function usersToSignals(users: any[]): Signal[] {
320
+ return users.map((u: any) => ({
321
+ type: "twitter_account",
322
+ name: u.username || u.name,
323
+ externalUrl: `https://x.com/${u.username}`,
324
+ metadata: { handle: u.username },
325
+ }));
326
+ }
327
+
328
+ function filterTweets(tweets: any[], query: TwitterQuery): any[] {
329
+ let result = tweets;
330
+ if (query.since) result = result.filter((t: any) => new Date(t.createdAt) >= query.since!);
331
+ if (query.author) {
332
+ const a = query.author.toLowerCase();
333
+ result = result.filter((t: any) => (t.author?.username || t.authorUsername || "").toLowerCase() === a);
334
+ }
335
+ return result;
336
+ }
337
+
338
+ function filterLabel(query: TwitterQuery): string {
339
+ const parts: string[] = [];
340
+ if (query.since) parts.push(`since ${query.since.toLocaleDateString()}`);
341
+ if (query.author) parts.push(`from @${query.author}`);
342
+ return parts.length ? ` (${parts.join(", ")})` : "";
343
+ }
344
+
345
+ // ────────────────────────────────────────
346
+ // Twitter auth
347
+ // ────────────────────────────────────────
348
+
349
+ async function ensureTwitterAuth(pi: ExtensionAPI, ctx: any): Promise<boolean> {
350
+ let twitterConnected = false;
351
+ try {
352
+ const user = await api.get<any>("/user");
353
+ twitterConnected = !!user.user?.twitterConnected;
354
+ } catch {}
355
+
356
+ if (!twitterConnected) {
357
+ ctx.ui.notify("Connecting your Twitter account...", "info");
358
+ const connected = await connectTwitterViaBrowser(pi, ctx);
359
+ if (!connected) return false;
360
+ }
361
+
362
+ const check = await checkTwitterCredentials();
363
+ if (!check.valid) {
364
+ ctx.ui.notify("Log in to x.com in your default browser, then try again.", "info");
365
+ return false;
366
+ }
367
+
368
+ return true;
369
+ }
370
+
371
+ async function connectTwitterViaBrowser(pi: ExtensionAPI, ctx: any): Promise<boolean> {
372
+ const apiBase = getApiBase();
373
+ const port = await findAvailablePort();
374
+ const state = randomBytes(16).toString("hex");
375
+ const authUrl = `${apiBase}/auth/cli/twitter?port=${port}&state=${state}`;
376
+
377
+ const openCmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
378
+ pi.exec(openCmd, [authUrl]).catch(() => {
379
+ ctx.ui.notify(`Open this link to connect Twitter:\n${authUrl}`, "info");
380
+ });
381
+
382
+ try {
383
+ await waitForTwitterCallback(port, state);
384
+ ctx.ui.notify("Twitter connected.", "info");
385
+ return true;
386
+ } catch (err: any) {
387
+ if (err.message === "cancelled") {
388
+ ctx.ui.notify("Twitter connection cancelled.", "info");
389
+ } else {
390
+ ctx.ui.notify(err.message || "Twitter connection failed.", "error");
391
+ }
392
+ return false;
393
+ }
394
+ }
395
+
396
+ function findAvailablePort(): Promise<number> {
397
+ return new Promise((resolve, reject) => {
398
+ const server = createServer();
399
+ server.listen(0, "127.0.0.1", () => {
400
+ const addr = server.address();
401
+ if (addr && typeof addr === "object") {
402
+ server.close(() => resolve(addr.port));
403
+ } else {
404
+ reject(new Error("Could not find available port"));
405
+ }
406
+ });
407
+ server.on("error", reject);
408
+ });
409
+ }
410
+
411
+ function waitForTwitterCallback(port: number, state: string): Promise<void> {
412
+ return new Promise((resolve, reject) => {
413
+ const timeout = setTimeout(() => {
414
+ server.close();
415
+ reject(new Error("Timed out."));
416
+ }, 300_000);
417
+
418
+ const server = createServer((req: IncomingMessage, res: ServerResponse) => {
419
+ const url = new URL(req.url || "/", `http://127.0.0.1:${port}`);
420
+ if (url.pathname !== "/callback") {
421
+ res.writeHead(404);
422
+ res.end();
423
+ return;
424
+ }
425
+
426
+ const done = (body: string) => {
427
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
428
+ res.end(body);
429
+ clearTimeout(timeout);
430
+ server.close();
431
+ };
432
+
433
+ if (url.searchParams.get("state") !== state) {
434
+ done("<h1>Invalid state</h1>");
435
+ reject(new Error("Invalid state"));
436
+ return;
437
+ }
438
+ if (url.searchParams.get("error")) {
439
+ done("<h1>Cancelled</h1><p>You can close this tab.</p>");
440
+ reject(new Error(url.searchParams.get("error")!));
441
+ return;
442
+ }
443
+
444
+ done("<h1>Twitter connected</h1><p>You can close this tab.</p>");
445
+ resolve();
446
+ });
447
+
448
+ server.listen(port, "127.0.0.1");
449
+ server.on("error", (err) => {
450
+ clearTimeout(timeout);
451
+ reject(err);
452
+ });
453
+ });
454
+ }
455
+
456
+ // ────────────────────────────────────────
457
+ // Create signals (batch or single)
458
+ // ────────────────────────────────────────
459
+
460
+ async function addSignals(signals: Signal[], ctx: any): Promise<void> {
461
+ if (signals.length === 0) {
462
+ ctx.ui.notify("Nothing to add.", "info");
463
+ return;
464
+ }
465
+
466
+ // If signals have metadata/description, use individual creates to preserve it
467
+ const hasRichData = signals.some((s) => s.description || s.metadata);
468
+
469
+ if (!hasRichData && signals.length >= 3) {
470
+ // Plain URL import (fast batch)
471
+ const input = signals.map((s) => s.externalUrl).join("\n");
472
+ const result = await importSignals({ input });
473
+ if ("error" in result) {
474
+ ctx.ui.notify(result.error, "error");
475
+ } else {
476
+ ctx.ui.notify(`${result.created} added, ${result.skipped} skipped`, "info");
477
+ }
478
+ return;
479
+ }
480
+
481
+ // Individual creates to preserve description + metadata
482
+ let created = 0;
483
+ let skipped = 0;
484
+ for (const signal of signals) {
485
+ const result = await createSignal(signal as any);
486
+ if ("error" in result) {
487
+ skipped++;
488
+ } else {
489
+ created++;
490
+ }
491
+ }
492
+ ctx.ui.notify(`${created} added${skipped > 0 ? `, ${skipped} skipped` : ""}`, "info");
493
+ }
494
+
495
+ // ────────────────────────────────────────
496
+ // Execute Twitter query
497
+ // ────────────────────────────────────────
498
+
499
+ async function executeTwitterQuery(query: TwitterQuery, ctx: any): Promise<void> {
500
+ const client = await getTwitterClient();
501
+ ctx.ui.notify(`Fetching ${query.action}...`, "info");
502
+
503
+ switch (query.action) {
504
+ case "likes": {
505
+ const r = await client.getLikes(query.count || 20);
506
+ if (!r.success) {
507
+ ctx.ui.notify(r.error || "Failed to fetch likes", "error");
508
+ return;
509
+ }
510
+ const tweets = filterTweets(r.tweets || [], query);
511
+ ctx.ui.notify(`Found ${tweets.length} likes${filterLabel(query)}`, "info");
512
+ await addSignals(tweetsToSignals(tweets), ctx);
513
+ return;
514
+ }
515
+
516
+ case "bookmarks": {
517
+ const r = await client.getBookmarks(query.count || 20);
518
+ if (!r.success) {
519
+ ctx.ui.notify(r.error || "Failed to fetch bookmarks", "error");
520
+ return;
521
+ }
522
+ const tweets = filterTweets(r.tweets || [], query);
523
+ ctx.ui.notify(`Found ${tweets.length} bookmarks${filterLabel(query)}`, "info");
524
+ await addSignals(tweetsToSignals(tweets), ctx);
525
+ return;
526
+ }
527
+
528
+ case "following": {
529
+ const me = await client.getCurrentUser();
530
+ if (!me.success || !me.user) {
531
+ ctx.ui.notify("Failed to get current user", "error");
532
+ return;
533
+ }
534
+ const r = await client.getFollowing(me.user.id, query.count || 50);
535
+ if (!r.success) {
536
+ ctx.ui.notify(r.error || "Failed to fetch following", "error");
537
+ return;
538
+ }
539
+ await addSignals(usersToSignals(r.users || []), ctx);
540
+ return;
541
+ }
542
+
543
+ case "search": {
544
+ const r = await client.search(query.query!, query.count || 20);
545
+ if (!r.success) {
546
+ ctx.ui.notify(r.error || "Search failed", "error");
547
+ return;
548
+ }
549
+ await addSignals(tweetsToSignals(r.tweets || []), ctx);
550
+ return;
551
+ }
552
+ }
553
+ }
554
+
555
+ // ────────────────────────────────────────
556
+ // Registration
557
+ // ────────────────────────────────────────
558
+
559
+ export function registerAddInterceptor(pi: ExtensionAPI) {
560
+ pi.on("input", async (event, ctx) => {
561
+ if (!event.text.startsWith("/add ") && event.text !== "/add") {
562
+ return { action: "continue" as const };
563
+ }
564
+
565
+ const raw = event.text.slice(4).trim();
566
+ if (!raw) {
567
+ ctx.ui.notify(
568
+ "Usage:\n /add @handle\n /add https://example.com\n /add my bookmarks\n /add my last 10 likes\n /add who I follow",
569
+ "info",
570
+ );
571
+ return { action: "handled" as const };
572
+ }
573
+
574
+ // URLs and @handles — instant
575
+ const urlSignals = tryParseUrls(raw);
576
+ if (urlSignals) {
577
+ await addSignals(urlSignals, ctx);
578
+ return { action: "handled" as const };
579
+ }
580
+
581
+ // Twitter natural language
582
+ const twitterQuery = parseTwitterQuery(raw);
583
+ if (twitterQuery) {
584
+ const authed = await ensureTwitterAuth(pi, ctx);
585
+ if (!authed) return { action: "handled" as const };
586
+ try {
587
+ await executeTwitterQuery(twitterQuery, ctx);
588
+ } catch (err: any) {
589
+ ctx.ui.notify(err.message || "Twitter request failed", "error");
590
+ }
591
+ return { action: "handled" as const };
592
+ }
593
+
594
+ // Unknown
595
+ ctx.ui.notify(
596
+ "Not sure what that is. Try:\n /add @handle\n /add https://example.com\n /add my bookmarks\n /add my last 10 likes\n /add who I follow",
597
+ "info",
598
+ );
599
+ return { action: "handled" as const };
600
+ });
601
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * /seedclub — the main menu.
3
+ */
4
+
5
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
6
+ import { getStoredToken } from "../auth.js";
7
+ import { getUnsortedSignals } from "../tools/signals.js";
8
+ import { getCurrentUser } from "../tools/utility.js";
9
+ import { runSortFlow } from "./sort.js";
10
+
11
+ interface SeedclubDeps {
12
+ connect: (args: string | undefined, ctx: any) => Promise<void>;
13
+ disconnect: (ctx: any) => Promise<void>;
14
+ }
15
+
16
+ export function registerSeedclubCommand(pi: ExtensionAPI, deps: SeedclubDeps) {
17
+ pi.registerCommand("seedclub", {
18
+ description: "Seed Club",
19
+ handler: async (args, ctx) => {
20
+ const stored = await getStoredToken();
21
+ const hasEnvToken = !!process.env.SEEDCLUB_TOKEN || !!process.env.SEED_NETWORK_TOKEN;
22
+ const isConnected = !!stored || hasEnvToken;
23
+
24
+ if (!isConnected) {
25
+ return await deps.connect(args, ctx);
26
+ }
27
+
28
+ const user = await getCurrentUser();
29
+ if ("error" in user) {
30
+ ctx.ui.notify("Session expired. Reconnecting...", "info");
31
+ return await deps.connect(args, ctx);
32
+ }
33
+
34
+ // Main menu
35
+ let unsortedCount = "?";
36
+ let unsortedResult: any = null;
37
+ try {
38
+ const result = await getUnsortedSignals();
39
+ if (!("error" in result)) {
40
+ unsortedResult = result;
41
+ unsortedCount = String(result.unsortedCount ?? result.unsorted?.length ?? 0);
42
+ }
43
+ } catch {}
44
+
45
+ const sortLabel =
46
+ unsortedCount === "0" ? "Sort signals (all sorted)" : `Sort signals (${unsortedCount} unsorted)`;
47
+
48
+ const choice = await ctx.ui.select(user.name, ["Add signals", sortLabel, "---", "Disconnect"]);
49
+
50
+ if (!choice || choice === "---") return;
51
+
52
+ if (choice.startsWith("Sort signals")) {
53
+ return await runSortFlow(pi, ctx, unsortedResult);
54
+ }
55
+
56
+ switch (choice) {
57
+ case "Add signals":
58
+ await new Promise((r) => setTimeout(r, 300));
59
+ ctx.ui.setEditorText("/add ");
60
+ break;
61
+ case "Disconnect":
62
+ await deps.disconnect(ctx);
63
+ break;
64
+ }
65
+ },
66
+ });
67
+ }