@1a35e1/sonar-cli 0.2.1 → 0.3.5

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 (43) hide show
  1. package/README.md +151 -265
  2. package/dist/commands/{inbox/archive.js → archive.js} +2 -2
  3. package/dist/commands/config/data/download.js +2 -2
  4. package/dist/commands/config/data/sync.js +2 -2
  5. package/dist/commands/config/nuke.js +20 -2
  6. package/dist/commands/feed.js +105 -155
  7. package/dist/commands/index.js +172 -4
  8. package/dist/commands/{inbox/later.js → later.js} +2 -2
  9. package/dist/commands/refresh.js +41 -0
  10. package/dist/commands/{inbox/skip.js → skip.js} +2 -2
  11. package/dist/commands/status.js +128 -0
  12. package/dist/commands/sync/bookmarks.js +35 -0
  13. package/dist/commands/topics/add.js +71 -0
  14. package/dist/commands/topics/delete.js +42 -0
  15. package/dist/commands/topics/edit.js +97 -0
  16. package/dist/commands/topics/index.js +54 -0
  17. package/dist/commands/topics/suggest.js +125 -0
  18. package/dist/commands/topics/view.js +48 -0
  19. package/dist/components/AccountCard.js +1 -1
  20. package/dist/components/Banner.js +11 -0
  21. package/dist/components/InteractiveSession.js +95 -210
  22. package/dist/components/Spinner.js +5 -4
  23. package/dist/components/TopicCard.js +15 -0
  24. package/dist/components/TweetCard.js +76 -0
  25. package/dist/lib/ai.js +85 -0
  26. package/dist/lib/client.js +66 -40
  27. package/dist/lib/config.js +3 -2
  28. package/dist/lib/data-queries.js +1 -3
  29. package/dist/lib/skill.js +66 -226
  30. package/package.json +13 -3
  31. package/dist/commands/account.js +0 -75
  32. package/dist/commands/inbox/index.js +0 -103
  33. package/dist/commands/inbox/read.js +0 -41
  34. package/dist/commands/ingest/bookmarks.js +0 -55
  35. package/dist/commands/ingest/index.js +0 -5
  36. package/dist/commands/ingest/tweets.js +0 -55
  37. package/dist/commands/interests/create.js +0 -107
  38. package/dist/commands/interests/index.js +0 -56
  39. package/dist/commands/interests/match.js +0 -33
  40. package/dist/commands/interests/update.js +0 -153
  41. package/dist/commands/monitor.js +0 -93
  42. package/dist/commands/quickstart.js +0 -231
  43. package/dist/components/InterestCard.js +0 -10
@@ -1,12 +1,11 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useState, useCallback } from 'react';
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useState, useCallback, useEffect } from 'react';
3
3
  import { Box, Text, useInput, useStdout } from 'ink';
4
- import { Spinner } from './Spinner.js';
5
- import { TweetCard } from '../commands/feed.js';
6
4
  import { gql } from '../lib/client.js';
7
- import { generateReply } from '../lib/ai.js';
5
+ import { TweetCard } from './TweetCard.js';
8
6
  import { getFeedWidth } from '../lib/config.js';
9
- const UPDATE_SUGGESTION_MUTATION = `
7
+ import { execSync } from 'child_process';
8
+ const UPDATE_MUTATION = `
10
9
  mutation UpdateSuggestion($suggestionId: ID!, $status: SuggestionStatus!) {
11
10
  updateSuggestion(input: { suggestionId: $suggestionId, status: $status }) {
12
11
  suggestionId
@@ -14,228 +13,114 @@ const UPDATE_SUGGESTION_MUTATION = `
14
13
  }
15
14
  }
16
15
  `;
17
- // ─── Helpers ──────────────────────────────────────────────────────────────────
18
- function relativeTime(dateStr) {
19
- const diff = Date.now() - new Date(dateStr).getTime();
20
- const mins = Math.floor(diff / 60000);
21
- if (mins < 60)
22
- return `${mins}m`;
23
- const hours = Math.floor(mins / 60);
24
- if (hours < 24)
25
- return `${hours}h`;
26
- return `${Math.floor(hours / 24)}d`;
27
- }
28
16
  function Divider({ width }) {
29
17
  return _jsx(Text, { dimColor: true, children: '─'.repeat(Math.min(width - 2, 72)) });
30
18
  }
31
- // ─── Shared hook ──────────────────────────────────────────────────────────────
32
- function useInteractiveState(total, vendor) {
33
- const [currentIndex, setCurrentIndex] = useState(0);
34
- const [mode, setMode] = useState('view');
35
- const [replyInput, setReplyInput] = useState('');
36
- const [replyDraft, setReplyDraft] = useState('');
37
- const [statusMessage, setStatusMessage] = useState('');
38
- const goNext = useCallback(() => {
39
- setCurrentIndex((i) => Math.min(i + 1, total - 1));
40
- setMode('view');
41
- setReplyDraft('');
42
- setStatusMessage('');
43
- }, [total]);
44
- const goPrev = useCallback(() => {
45
- setCurrentIndex((i) => Math.max(i - 1, 0));
46
- setMode('view');
47
- setReplyDraft('');
48
- setStatusMessage('');
49
- }, []);
50
- const startReply = useCallback(() => {
51
- setReplyInput('');
52
- setMode('reply-input');
53
- }, []);
54
- const dismissDraft = useCallback(() => {
55
- setReplyDraft('');
56
- setMode('view');
57
- }, []);
58
- const handleReply = useCallback(async (tweetText, angle) => {
59
- setMode('reply-loading');
60
- try {
61
- const result = await generateReply(tweetText, angle, vendor);
62
- setReplyDraft(result.reply);
63
- setMode('reply-draft');
64
- }
65
- catch (err) {
66
- setStatusMessage(`Error: ${err instanceof Error ? err.message : String(err)}`);
67
- setMode('view');
68
- }
69
- }, [vendor]);
70
- return {
71
- currentIndex,
72
- mode,
73
- replyInput,
74
- replyDraft,
75
- statusMessage,
76
- setReplyInput,
77
- setMode,
78
- setStatusMessage,
79
- goNext,
80
- goPrev,
81
- startReply,
82
- dismissDraft,
83
- handleReply,
84
- };
85
- }
86
- export function InteractiveFeedSession({ items, vendor }) {
19
+ const UNDO_WINDOW_MS = 10_000;
20
+ export function TriageSession({ items: initialItems, total: initialTotal, fetchMore }) {
87
21
  const { stdout } = useStdout();
88
22
  const termWidth = stdout.columns ?? 100;
89
23
  const cardWidth = getFeedWidth();
90
- const { currentIndex, mode, replyInput, replyDraft, statusMessage, setReplyInput, setMode, setStatusMessage, goNext, goPrev, startReply, dismissDraft, handleReply, } = useInteractiveState(items.length, vendor);
91
- const current = items[currentIndex];
92
- useInput((input, key) => {
93
- if (mode === 'reply-loading')
24
+ const [items, setItems] = useState(initialItems);
25
+ const [total, setTotal] = useState(initialTotal ?? initialItems.length);
26
+ const [index, setIndex] = useState(0);
27
+ const [lastAction, setLastAction] = useState(null);
28
+ const [acting, setActing] = useState(false);
29
+ const [loading, setLoading] = useState(false);
30
+ const [pending, setPending] = useState(null);
31
+ // Fetch next page when 3 items from the end
32
+ useEffect(() => {
33
+ if (!fetchMore || loading)
94
34
  return;
95
- if (mode === 'reply-input') {
96
- if (key.return) {
97
- handleReply(current.tweet.text, replyInput);
98
- }
99
- else if (key.escape) {
100
- setMode('view');
101
- setReplyInput('');
102
- }
103
- else if (key.backspace || key.delete) {
104
- setReplyInput((s) => s.slice(0, -1));
105
- }
106
- else if (input && !key.ctrl && !key.meta) {
107
- setReplyInput((s) => s + input);
108
- }
35
+ if (index >= items.length - 3 && items.length < total) {
36
+ setLoading(true);
37
+ fetchMore(items.length)
38
+ .then(more => {
39
+ if (more.length > 0) {
40
+ setItems(prev => [...prev, ...more]);
41
+ }
42
+ })
43
+ .catch(() => { })
44
+ .finally(() => setLoading(false));
45
+ }
46
+ }, [index, items.length, total, loading]);
47
+ // Flush pending on unmount
48
+ useEffect(() => {
49
+ return () => { if (pending) {
50
+ clearTimeout(pending.timer);
51
+ commitAction(pending);
52
+ } };
53
+ }, [pending]);
54
+ const done = index >= items.length && items.length >= total;
55
+ const current = items[index];
56
+ function commitAction(action) {
57
+ gql(UPDATE_MUTATION, { suggestionId: action.suggestionId, status: action.status })
58
+ .catch(() => { });
59
+ }
60
+ const act = useCallback((status, label) => {
61
+ const item = items[index];
62
+ // Flush any previous pending action immediately
63
+ if (pending) {
64
+ clearTimeout(pending.timer);
65
+ commitAction(pending);
66
+ }
67
+ if (item.suggestionId) {
68
+ // Defer the mutation — can be undone within the window
69
+ const timer = setTimeout(() => {
70
+ commitAction({ timer: 0, suggestionId: item.suggestionId, status, index });
71
+ setPending(null);
72
+ }, UNDO_WINDOW_MS);
73
+ setPending({ timer, suggestionId: item.suggestionId, status, index });
74
+ }
75
+ setLastAction(label);
76
+ setIndex((i) => i + 1);
77
+ }, [index, items, pending]);
78
+ const undo = useCallback(() => {
79
+ if (!pending)
109
80
  return;
110
- }
111
- if (mode === 'reply-draft') {
112
- if (input === 'r') {
113
- handleReply(current.tweet.text, '');
114
- }
115
- else if (key.escape) {
116
- dismissDraft();
117
- }
81
+ clearTimeout(pending.timer);
82
+ setPending(null);
83
+ setIndex(pending.index);
84
+ setLastAction(null);
85
+ }, [pending]);
86
+ useInput((input, key) => {
87
+ if (done) {
88
+ if (input === 'q')
89
+ process.exit(0);
90
+ if (input === 'u')
91
+ undo();
118
92
  return;
119
93
  }
120
- // view mode
121
- if (input === 'n' || key.rightArrow || input === ' ') {
122
- goNext();
123
- }
124
- else if (input === 'p' || key.leftArrow) {
125
- goPrev();
126
- }
127
- else if (input === 'r') {
128
- startReply();
94
+ if (key.return || input === ' ' || input === 'd' || input === 'n') {
95
+ act('SKIPPED', 'dismissed');
129
96
  }
130
97
  else if (input === 's') {
131
- setStatusMessage('star — coming soon');
98
+ act('ARCHIVED', 'saved');
99
+ }
100
+ else if (input === 'u') {
101
+ undo();
132
102
  }
133
- else if (input === 'a') {
134
- setStatusMessage('analyze coming soon');
103
+ else if (input === 'o') {
104
+ const handle = current.tweet.user.username ?? current.tweet.user.displayName;
105
+ const url = `https://x.com/${handle}/status/${current.tweet.id}`;
106
+ try {
107
+ execSync(`open "${url}"`);
108
+ }
109
+ catch { }
135
110
  }
136
111
  else if (input === 'q') {
137
112
  process.exit(0);
138
113
  }
139
- }, { isActive: mode !== 'reply-loading' });
140
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { dimColor: true, children: [' ', currentIndex + 1, " / ", items.length, ' · ', "feed --interactive"] }) }), _jsx(TweetCard, { item: current, termWidth: termWidth, cardWidth: cardWidth, isLast: true }), mode === 'reply-draft' && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Divider, { width: termWidth }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "Draft reply:" }), _jsx(Box, { marginTop: 1, paddingLeft: 2, width: Math.min(termWidth, 80), children: _jsx(Text, { wrap: "wrap", children: replyDraft }) })] })] })), statusMessage && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "green", children: statusMessage }) })), _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Divider, { width: termWidth }), mode === 'reply-input' ? (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { dimColor: true, children: "Angle (Enter to auto-generate, Esc to cancel): " }), _jsx(Text, { children: replyInput }), _jsx(Text, { color: "cyan", children: "\u2588" })] })) : mode === 'reply-loading' ? (_jsx(Box, { marginTop: 1, children: _jsx(Spinner, { label: "Generating reply..." }) })) : mode === 'reply-draft' ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "[r] new draft [Esc] dismiss" }) })) : (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "[n]ext [p]rev [s]tar [r]eply [a]nalyze [q]uit" }) }))] })] }));
141
- }
142
- // ─── Suggestion Card ──────────────────────────────────────────────────────────
143
- function scoreColor(score) {
144
- if (score >= 0.7)
145
- return 'green';
146
- if (score >= 0.4)
147
- return 'yellow';
148
- return 'white';
149
- }
150
- function statusColor(status) {
151
- switch (status.toLowerCase()) {
152
- case 'inbox': return 'cyan';
153
- case 'read': return 'green';
154
- case 'skipped': return 'gray';
155
- case 'later': return 'yellow';
156
- case 'archived': return 'magenta';
157
- default: return 'white';
114
+ }, { isActive: !acting });
115
+ if (done) {
116
+ return (_jsxs(Box, { flexDirection: "column", gap: 1, marginTop: 1, children: [_jsx(Text, { color: "green", children: "\u2713 Inbox zero" }), lastAction && _jsxs(Text, { dimColor: true, children: ["last: ", lastAction] }), _jsx(Text, { dimColor: true, children: "q to quit" })] }));
158
117
  }
118
+ const canTriage = !!current.suggestionId;
119
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { marginBottom: 1, gap: 3, children: [_jsxs(Text, { dimColor: true, children: [index + 1, " / ", total] }), lastAction && _jsxs(Text, { color: "green", children: ["\u2713 ", lastAction] })] }), _jsx(TweetCard, { item: { score: current.score, matchedKeywords: current.matchedKeywords, tweet: current.tweet }, termWidth: termWidth, cardWidth: cardWidth, isLast: true }), _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Divider, { width: termWidth }), _jsx(Box, { marginTop: 1, gap: 3, children: canTriage ? (_jsxs(_Fragment, { children: [_jsxs(Text, { dimColor: true, children: [_jsx(Text, { color: "white", children: "n" }), " next"] }), _jsxs(Text, { dimColor: true, children: [_jsx(Text, { color: "white", children: "s" }), " save"] }), _jsxs(Text, { dimColor: true, children: [_jsx(Text, { color: "white", children: "o" }), " open"] }), _jsxs(Text, { dimColor: true, children: [_jsx(Text, { color: "white", children: "q" }), " quit"] })] })) : (_jsxs(_Fragment, { children: [_jsxs(Text, { dimColor: true, children: [_jsx(Text, { color: "white", children: "n" }), " next"] }), _jsxs(Text, { dimColor: true, children: [_jsx(Text, { color: "white", children: "o" }), " open"] }), _jsxs(Text, { dimColor: true, children: [_jsx(Text, { color: "white", children: "q" }), " quit"] })] })) })] })] }));
159
120
  }
160
- function SuggestionCard({ item, termWidth }) {
161
- const handle = item.tweet.user.username ?? item.tweet.user.displayName;
162
- const author = `@${handle}`;
163
- const profileUrl = `https://x.com/${handle}`;
164
- const tweetUrl = `https://x.com/${handle}/status/${item.tweet.xid}`;
165
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: "cyan", bold: true, children: relativeTime(item.tweet.createdAt) }), _jsx(Text, { dimColor: true, children: " \u00B7 " }), _jsx(Text, { color: scoreColor(item.score), children: item.score.toFixed(2) }), _jsx(Text, { dimColor: true, children: " \u00B7 " }), _jsx(Text, { color: statusColor(item.status), children: item.status.toLowerCase() }), item.projectsMatched > 0 && (_jsxs(Text, { dimColor: true, children: [" \u00B7 ", item.projectsMatched, " interest", item.projectsMatched !== 1 ? 's' : ''] }))] }), _jsxs(Box, { children: [_jsxs(Text, { color: "gray", children: ['└', " "] }), _jsx(Text, { color: "blueBright", bold: true, children: author })] }), _jsx(Box, { paddingLeft: 2, width: Math.min(termWidth, 82), marginTop: 1, children: _jsx(Text, { wrap: "wrap", children: item.tweet.text }) }), _jsx(Box, { marginLeft: 2, marginTop: 1, children: _jsxs(Text, { dimColor: true, children: [profileUrl, " \u00B7 ", tweetUrl] }) })] }));
121
+ export function InteractiveFeedSession({ items }) {
122
+ return _jsx(TriageSession, { items: items });
166
123
  }
167
- const INBOX_STATUS_KEYS = {
168
- R: 'READ',
169
- S: 'SKIPPED',
170
- L: 'LATER',
171
- A: 'ARCHIVED',
172
- };
173
- export function InteractiveInboxSession({ items, vendor }) {
174
- const { stdout } = useStdout();
175
- const termWidth = stdout.columns ?? 100;
176
- const [isActing, setIsActing] = useState(false);
177
- const { currentIndex, mode, replyInput, replyDraft, statusMessage, setReplyInput, setMode, setStatusMessage, goNext, goPrev, startReply, dismissDraft, handleReply, } = useInteractiveState(items.length, vendor);
178
- const current = items[currentIndex];
179
- const handleStatusUpdate = useCallback(async (status) => {
180
- setIsActing(true);
181
- try {
182
- await gql(UPDATE_SUGGESTION_MUTATION, { suggestionId: current.suggestionId, status });
183
- setStatusMessage(`✓ marked as ${status.toLowerCase()}`);
184
- }
185
- catch (err) {
186
- setStatusMessage(`Error: ${err instanceof Error ? err.message : String(err)}`);
187
- }
188
- finally {
189
- setIsActing(false);
190
- }
191
- }, [current.suggestionId, setStatusMessage]);
192
- useInput((input, key) => {
193
- if (isActing || mode === 'reply-loading')
194
- return;
195
- if (mode === 'reply-input') {
196
- if (key.return) {
197
- handleReply(current.tweet.text, replyInput);
198
- }
199
- else if (key.escape) {
200
- setMode('view');
201
- setReplyInput('');
202
- }
203
- else if (key.backspace || key.delete) {
204
- setReplyInput((s) => s.slice(0, -1));
205
- }
206
- else if (input && !key.ctrl && !key.meta) {
207
- setReplyInput((s) => s + input);
208
- }
209
- return;
210
- }
211
- if (mode === 'reply-draft') {
212
- if (input === 'r') {
213
- handleReply(current.tweet.text, '');
214
- }
215
- else if (key.escape) {
216
- dismissDraft();
217
- }
218
- return;
219
- }
220
- // view mode
221
- if (input === 'n' || key.rightArrow || input === ' ') {
222
- goNext();
223
- }
224
- else if (input === 'p' || key.leftArrow) {
225
- goPrev();
226
- }
227
- else if (input === 'r') {
228
- startReply();
229
- }
230
- else if (input === 'a') {
231
- setStatusMessage('analyze — coming soon');
232
- }
233
- else if (input === 'q') {
234
- process.exit(0);
235
- }
236
- else if (INBOX_STATUS_KEYS[input]) {
237
- handleStatusUpdate(INBOX_STATUS_KEYS[input]);
238
- }
239
- }, { isActive: !isActing && mode !== 'reply-loading' });
240
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { dimColor: true, children: [' ', currentIndex + 1, " / ", items.length, ' · ', "inbox --interactive"] }) }), _jsx(SuggestionCard, { item: current, termWidth: termWidth }), mode === 'reply-draft' && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Divider, { width: termWidth }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "Draft reply:" }), _jsx(Box, { marginTop: 1, paddingLeft: 2, width: Math.min(termWidth, 80), children: _jsx(Text, { wrap: "wrap", children: replyDraft }) })] })] })), statusMessage && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "green", children: statusMessage }) })), _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Divider, { width: termWidth }), isActing ? (_jsx(Box, { marginTop: 1, children: _jsx(Spinner, { label: "Updating..." }) })) : mode === 'reply-input' ? (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { dimColor: true, children: "Angle (Enter to auto-generate, Esc to cancel): " }), _jsx(Text, { children: replyInput }), _jsx(Text, { color: "cyan", children: "\u2588" })] })) : mode === 'reply-loading' ? (_jsx(Box, { marginTop: 1, children: _jsx(Spinner, { label: "Generating reply..." }) })) : mode === 'reply-draft' ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "[r] new draft [Esc] dismiss" }) })) : (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "[n]ext [p]rev [r]eply [a]nalyze [R]ead [S]kip [L]ater [A]rchive [q]uit" }) }))] })] }));
124
+ export function InteractiveInboxSession({ items }) {
125
+ return _jsx(TriageSession, { items: items });
241
126
  }
@@ -1,14 +1,15 @@
1
- import { jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useState, useEffect } from 'react';
3
3
  import { Text } from 'ink';
4
- const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
4
+ // Sonar ping radiates outward, resets
5
+ const FRAMES = [' ', ' ', '·', '•', '●', '◉', '◎', '○', ' '];
5
6
  export function Spinner({ label }) {
6
7
  const [frame, setFrame] = useState(0);
7
8
  useEffect(() => {
8
9
  const timer = setInterval(() => {
9
10
  setFrame((f) => (f + 1) % FRAMES.length);
10
- }, 80);
11
+ }, 100);
11
12
  return () => clearInterval(timer);
12
13
  }, []);
13
- return (_jsxs(Text, { children: [_jsxs(Text, { color: "cyan", children: [FRAMES[frame], " "] }), label ?? 'Loading...'] }));
14
+ return (_jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: FRAMES[frame] }), label ? _jsxs(Text, { children: [" ", label] }) : null] }));
14
15
  }
@@ -0,0 +1,15 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ export function TopicCard({ topic, termWidth, isLast }) {
4
+ const updatedAt = new Date(topic.updatedAt).toLocaleDateString('en-US', {
5
+ month: 'short',
6
+ day: 'numeric',
7
+ year: 'numeric',
8
+ });
9
+ const desc = topic.description
10
+ ? topic.description.length > 160
11
+ ? topic.description.slice(0, 160).trimEnd() + '...'
12
+ : topic.description
13
+ : null;
14
+ return (_jsxs(Box, { flexDirection: "column", marginBottom: isLast ? 0 : 0, children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: "cyan", bold: true, children: topic.name }), _jsxs(Text, { dimColor: true, children: ["v", topic.version] }), _jsx(Text, { dimColor: true, children: "\u00B7" }), _jsx(Text, { dimColor: true, children: topic.id }), _jsx(Text, { dimColor: true, children: "\u00B7" }), _jsx(Text, { dimColor: true, children: updatedAt })] }), desc && (_jsx(Box, { paddingLeft: 2, children: _jsx(Text, { dimColor: true, wrap: "wrap", children: desc }) }))] }));
15
+ }
@@ -0,0 +1,76 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import Link from 'ink-link';
4
+ import { Table } from './Table.js';
5
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
6
+ export function formatTimestamp(dateStr) {
7
+ const d = new Date(dateStr);
8
+ const month = d.toLocaleString('en-US', { month: 'short' });
9
+ const day = d.getDate();
10
+ const hours = d.getHours();
11
+ const mins = d.getMinutes().toString().padStart(2, '0');
12
+ const ampm = hours >= 12 ? 'pm' : 'am';
13
+ const h = hours % 12 || 12;
14
+ return `${month} ${day} · ${h}:${mins}${ampm}`;
15
+ }
16
+ export function relativeTime(dateStr) {
17
+ const diff = Date.now() - new Date(dateStr).getTime();
18
+ const mins = Math.floor(diff / 60000);
19
+ if (mins < 60)
20
+ return `${mins}m`;
21
+ const hours = Math.floor(mins / 60);
22
+ if (hours < 24)
23
+ return `${hours}h`;
24
+ return `${Math.floor(hours / 24)}d`;
25
+ }
26
+ function formatCount(n) {
27
+ if (n == null)
28
+ return null;
29
+ if (n >= 1_000_000)
30
+ return `${(n / 1_000_000).toFixed(1)}M`;
31
+ if (n >= 1_000)
32
+ return `${(n / 1_000).toFixed(1)}k`;
33
+ return String(n);
34
+ }
35
+ function scoreColor(score) {
36
+ if (score >= 0.7)
37
+ return 'green';
38
+ if (score >= 0.4)
39
+ return 'yellow';
40
+ return 'white';
41
+ }
42
+ function linkifyMentions(text) {
43
+ return text.replace(/@(\w+)/g, (match, handle) => {
44
+ const url = `https://x.com/${handle}`;
45
+ return `\x1b]8;;${url}\x07\x1b[94m${match}\x1b[39m\x1b]8;;\x07`;
46
+ });
47
+ }
48
+ function TweetText({ text }) {
49
+ return _jsx(Text, { wrap: "wrap", children: linkifyMentions(text) });
50
+ }
51
+ export function TweetCard({ item, termWidth, cardWidth, isLast }) {
52
+ const { tweet, score } = item;
53
+ const handle = tweet.user.username ?? tweet.user.displayName;
54
+ const author = `@${handle}`;
55
+ const bodyBoxWidth = Math.min(cardWidth + 2, termWidth);
56
+ const profileUrl = `https://x.com/${handle}`;
57
+ const tweetUrl = `https://x.com/${handle}/status/${tweet.id}`;
58
+ return (_jsxs(Box, { flexDirection: "column", marginBottom: isLast ? 0 : 1, width: termWidth, children: [_jsxs(Box, { children: [_jsx(Link, { url: tweetUrl, fallback: false, children: _jsx(Text, { color: "cyan", bold: true, children: formatTimestamp(tweet.createdAt) }) }), _jsxs(Text, { dimColor: true, children: [" ", relativeTime(tweet.createdAt)] }), score > 0 && (_jsxs(_Fragment, { children: [_jsx(Text, { dimColor: true, children: " \u00B7 " }), _jsx(Text, { color: scoreColor(score), children: score.toFixed(2) })] }))] }), _jsxs(Box, { children: [_jsxs(Text, { color: "gray", children: ['└', " "] }), _jsx(Link, { url: profileUrl, fallback: false, children: _jsx(Text, { color: "blueBright", bold: true, children: author }) }), formatCount(tweet.user.followersCount) && (_jsxs(_Fragment, { children: [_jsxs(Text, { dimColor: true, children: [" ", formatCount(tweet.user.followersCount), " followers"] }), formatCount(tweet.user.followingCount) && (_jsxs(Text, { dimColor: true, children: [" \u00B7 ", formatCount(tweet.user.followingCount), " following"] }))] }))] }), _jsx(Box, { paddingLeft: 2, width: bodyBoxWidth, marginTop: 1, children: _jsx(TweetText, { text: tweet.text }) }), _jsxs(Box, { marginLeft: 2, marginTop: 1, children: [_jsxs(Text, { color: "red", children: ["\u2665 ", tweet.likeCount] }), _jsx(Text, { dimColor: true, children: " " }), _jsxs(Text, { color: "green", children: ["\u21BA ", tweet.retweetCount] }), tweet.replyCount > 0 && (_jsxs(_Fragment, { children: [_jsx(Text, { dimColor: true, children: " " }), _jsxs(Text, { dimColor: true, children: ["\u21A9 ", tweet.replyCount] })] }))] }), item.matchedKeywords.length > 0 && (_jsxs(Box, { marginLeft: 2, children: [_jsx(Text, { dimColor: true, children: "keywords " }), _jsx(Text, { color: "yellow", children: item.matchedKeywords.join(' ') })] })), _jsxs(Box, { marginLeft: 2, children: [_jsx(Link, { url: profileUrl, fallback: false, children: _jsx(Text, { dimColor: true, children: profileUrl }) }), _jsx(Text, { dimColor: true, children: " \u00B7 " }), _jsx(Link, { url: tweetUrl, fallback: false, children: _jsx(Text, { dimColor: true, children: tweetUrl }) })] }), !isLast && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: '─'.repeat(Math.min(termWidth - 2, 72)) }) }))] }));
59
+ }
60
+ // ─── FeedTable ────────────────────────────────────────────────────────────────
61
+ function osc8Link(url, label) {
62
+ return `\x1b]8;;${url}\x07${label}\x1b]8;;\x07`;
63
+ }
64
+ export function FeedTable({ data }) {
65
+ const rows = data.map((item) => {
66
+ const handle = item.tweet.user.username ?? item.tweet.user.displayName;
67
+ const tweetUrl = `https://x.com/${handle}/status/${item.tweet.id}`;
68
+ return {
69
+ age: osc8Link(tweetUrl, relativeTime(item.tweet.createdAt)),
70
+ score: item.score > 0 ? item.score.toFixed(2) : '—',
71
+ author: `@${handle}`,
72
+ tweet: item.tweet.text.replace(/\n/g, ' ').slice(0, 80),
73
+ };
74
+ });
75
+ return _jsx(Table, { rows: rows, columns: ['age', 'score', 'author', 'tweet'] });
76
+ }
package/dist/lib/ai.js CHANGED
@@ -8,6 +8,14 @@ function extractJSON(text) {
8
8
  throw new Error('No JSON object found in response');
9
9
  return stripped.slice(start, end + 1);
10
10
  }
11
+ function extractJSONArray(text) {
12
+ const stripped = text.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/i, '').trim();
13
+ const start = stripped.indexOf('[');
14
+ const end = stripped.lastIndexOf(']');
15
+ if (start === -1 || end === -1)
16
+ throw new Error('No JSON array found in response');
17
+ return stripped.slice(start, end + 1);
18
+ }
11
19
  const SYSTEM_PROMPT = `You generate structured interest profiles for a social intelligence tool. These profiles are embedded into a vector database and matched against tweets and people on X (Twitter). Every field must be optimised for semantic similarity search — not for human reading.
12
20
 
13
21
  Before generating the profile, research what is currently topical and actively being discussed in this space: recent releases, emerging tools, ongoing debates, notable events, new protocols, and people generating buzz right now. Weave this current signal throughout every field.
@@ -200,3 +208,80 @@ export async function generateInterest(prompt, vendor) {
200
208
  }
201
209
  throw new Error(`Unknown vendor: ${vendor}. Supported: openai, anthropic`);
202
210
  }
211
+ // ─── Topic Suggestions ───────────────────────────────────────────────────────
212
+ const SUGGEST_SYSTEM_PROMPT = `You suggest new topics for a social intelligence tool that tracks interests on X (Twitter). Given the user's existing topics and a sample of recent tweets from their feed, suggest new topics that are adjacent to but distinct from what they already track.
213
+
214
+ For each suggestion, return a JSON object with:
215
+ - name: short, specific interest name (3-6 words, title case)
216
+ - description: a dense, jargon-rich passage written in the voice of a practitioner deeply embedded in this space. Pack it with domain-specific terminology, key concepts, tools, notable figures, and current developments.
217
+ - keywords: 12-20 specific, high-signal terms used by practitioners
218
+ - relatedTopics: 6-10 adjacent topic areas
219
+
220
+ Respond ONLY with a valid JSON array of objects. No markdown, no explanation.`;
221
+ export async function generateTopicSuggestions(existingTopics, recentTweets, count, vendor) {
222
+ const topicList = existingTopics.length > 0
223
+ ? `My current topics:\n${existingTopics.map(t => `- ${t}`).join('\n')}`
224
+ : 'I have no topics yet.';
225
+ const tweetSample = recentTweets.length > 0
226
+ ? `\n\nRecent tweets from my feed:\n${recentTweets.slice(0, 15).map(t => `- ${t.slice(0, 200)}`).join('\n')}`
227
+ : '';
228
+ const prompt = `${topicList}${tweetSample}\n\nSuggest exactly ${count} new topics I should track. Return a JSON array.`;
229
+ if (vendor === 'openai') {
230
+ const apiKey = process.env.OPENAI_API_KEY;
231
+ if (!apiKey)
232
+ throw new Error('OPENAI_API_KEY is not set');
233
+ return fetchWithTimeout('https://api.openai.com/v1/responses', {
234
+ method: 'POST',
235
+ headers: {
236
+ 'Content-Type': 'application/json',
237
+ Authorization: `Bearer ${apiKey}`,
238
+ },
239
+ body: JSON.stringify({
240
+ model: 'gpt-4o',
241
+ tools: [{ type: 'web_search_preview' }],
242
+ instructions: SUGGEST_SYSTEM_PROMPT,
243
+ input: prompt,
244
+ }),
245
+ }, OPENAI_TIMEOUT_MS, 'OpenAI', async (res) => {
246
+ if (!res.ok) {
247
+ const err = await res.json().catch(() => ({}));
248
+ throw new Error(`OpenAI error: ${err?.error?.message ?? res.status}`);
249
+ }
250
+ const data = await res.json();
251
+ const text = data.output
252
+ ?.filter((b) => b.type === 'message')
253
+ .flatMap((b) => b.content)
254
+ .filter((c) => c.type === 'output_text')
255
+ .map((c) => c.text)
256
+ .join('') ?? '';
257
+ return JSON.parse(extractJSONArray(text));
258
+ });
259
+ }
260
+ if (vendor === 'anthropic') {
261
+ const apiKey = process.env.ANTHROPIC_API_KEY;
262
+ if (!apiKey)
263
+ throw new Error('ANTHROPIC_API_KEY is not set');
264
+ return fetchWithTimeout('https://api.anthropic.com/v1/messages', {
265
+ method: 'POST',
266
+ headers: {
267
+ 'Content-Type': 'application/json',
268
+ 'x-api-key': apiKey,
269
+ 'anthropic-version': '2023-06-01',
270
+ },
271
+ body: JSON.stringify({
272
+ model: 'claude-haiku-4-5-20251001',
273
+ max_tokens: 4096,
274
+ system: SUGGEST_SYSTEM_PROMPT,
275
+ messages: [{ role: 'user', content: prompt }],
276
+ }),
277
+ }, ANTHROPIC_TIMEOUT_MS, 'Anthropic', async (res) => {
278
+ if (!res.ok) {
279
+ const err = await res.json().catch(() => ({}));
280
+ throw new Error(`Anthropic error: ${err?.error?.message ?? res.status}`);
281
+ }
282
+ const data = await res.json();
283
+ return JSON.parse(extractJSONArray(data.content[0].text));
284
+ });
285
+ }
286
+ throw new Error(`Unknown vendor: ${vendor}. Supported: openai, anthropic`);
287
+ }