@1a35e1/sonar-cli 0.2.0 → 0.3.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +151 -166
- package/dist/commands/{inbox/archive.js → archive.js} +2 -2
- package/dist/commands/config/data/download.js +2 -2
- package/dist/commands/config/data/sync.js +2 -2
- package/dist/commands/config/nuke.js +20 -2
- package/dist/commands/feed.js +105 -155
- package/dist/commands/index.js +172 -4
- package/dist/commands/{inbox/later.js → later.js} +2 -2
- package/dist/commands/refresh.js +41 -0
- package/dist/commands/{inbox/skip.js → skip.js} +2 -2
- package/dist/commands/status.js +128 -0
- package/dist/commands/sync/bookmarks.js +35 -0
- package/dist/commands/topics/add.js +71 -0
- package/dist/commands/topics/delete.js +42 -0
- package/dist/commands/topics/edit.js +97 -0
- package/dist/commands/topics/index.js +54 -0
- package/dist/commands/topics/suggest.js +125 -0
- package/dist/commands/topics/view.js +48 -0
- package/dist/components/AccountCard.js +1 -1
- package/dist/components/Banner.js +11 -0
- package/dist/components/InteractiveSession.js +95 -210
- package/dist/components/Spinner.js +5 -4
- package/dist/components/TopicCard.js +15 -0
- package/dist/components/TweetCard.js +76 -0
- package/dist/lib/ai.js +85 -0
- package/dist/lib/client.js +66 -39
- package/dist/lib/config.js +3 -2
- package/dist/lib/data-queries.js +1 -3
- package/dist/lib/skill.js +66 -226
- package/package.json +13 -3
- package/dist/commands/account.js +0 -75
- package/dist/commands/inbox/index.js +0 -103
- package/dist/commands/inbox/read.js +0 -41
- package/dist/commands/ingest/bookmarks.js +0 -55
- package/dist/commands/ingest/index.js +0 -5
- package/dist/commands/ingest/tweets.js +0 -55
- package/dist/commands/interests/create.js +0 -107
- package/dist/commands/interests/index.js +0 -56
- package/dist/commands/interests/match.js +0 -33
- package/dist/commands/interests/update.js +0 -153
- package/dist/commands/monitor.js +0 -93
- 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 {
|
|
5
|
+
import { TweetCard } from './TweetCard.js';
|
|
8
6
|
import { getFeedWidth } from '../lib/config.js';
|
|
9
|
-
|
|
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
|
-
|
|
32
|
-
function
|
|
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
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
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 (
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
121
|
-
|
|
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
|
-
|
|
98
|
+
act('ARCHIVED', 'saved');
|
|
99
|
+
}
|
|
100
|
+
else if (input === 'u') {
|
|
101
|
+
undo();
|
|
132
102
|
}
|
|
133
|
-
else if (input === '
|
|
134
|
-
|
|
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:
|
|
140
|
-
|
|
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
|
|
161
|
-
|
|
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
|
-
|
|
168
|
-
|
|
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
|
-
|
|
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
|
-
},
|
|
11
|
+
}, 100);
|
|
11
12
|
return () => clearInterval(timer);
|
|
12
13
|
}, []);
|
|
13
|
-
return (_jsxs(Text, { children: [
|
|
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
|
+
}
|