@1a35e1/sonar-cli 0.2.1 → 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 -40
- 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/commands/quickstart.js +0 -231
- package/dist/components/InterestCard.js +0 -10
package/dist/commands/feed.js
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
|
-
import { jsxs as _jsxs, jsx as _jsx
|
|
2
|
-
import { useEffect, useState } from 'react';
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useRef, useState } from 'react';
|
|
3
3
|
import zod from 'zod';
|
|
4
|
-
import { Box, Text, useStdout } from 'ink';
|
|
5
|
-
import
|
|
4
|
+
import { Box, Text, useApp, useInput, useStdout } from 'ink';
|
|
5
|
+
import { Banner } from '../components/Banner.js';
|
|
6
6
|
import { Spinner } from '../components/Spinner.js';
|
|
7
|
-
import { Table } from '../components/Table.js';
|
|
8
|
-
import { InteractiveFeedSession } from '../components/InteractiveSession.js';
|
|
9
7
|
import { gql } from '../lib/client.js';
|
|
10
|
-
import { getFeedRender, getFeedWidth
|
|
8
|
+
import { getFeedRender, getFeedWidth } from '../lib/config.js';
|
|
9
|
+
import { TweetCard, FeedTable } from '../components/TweetCard.js';
|
|
11
10
|
export const options = zod.object({
|
|
12
11
|
hours: zod.number().optional().describe('Look back N hours (default: 12)'),
|
|
13
12
|
days: zod.number().optional().describe('Look back N days'),
|
|
@@ -16,171 +15,122 @@ export const options = zod.object({
|
|
|
16
15
|
render: zod.string().optional().describe('Output layout: card|table'),
|
|
17
16
|
width: zod.number().optional().describe('Card width in columns'),
|
|
18
17
|
json: zod.boolean().default(false).describe('Raw JSON output'),
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
follow: zod.boolean().default(false).describe('Continuously poll for new items'),
|
|
19
|
+
interval: zod.number().optional().describe('Poll interval in seconds (default: 30)'),
|
|
21
20
|
});
|
|
21
|
+
const FEED_QUERY = `
|
|
22
|
+
query Feed($hours: Int, $days: Int, $limit: Int, $kind: String) {
|
|
23
|
+
feed(hours: $hours, days: $days, limit: $limit, kind: $kind) {
|
|
24
|
+
score
|
|
25
|
+
matchedKeywords
|
|
26
|
+
tweet {
|
|
27
|
+
id xid text createdAt likeCount retweetCount replyCount
|
|
28
|
+
user { displayName username followersCount followingCount }
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
`;
|
|
33
|
+
const HAS_INTERESTS_QUERY = `query HasInterests { topics { id: nanoId } }`;
|
|
22
34
|
export default function Feed({ options: flags }) {
|
|
23
|
-
const
|
|
24
|
-
const [error, setError] = useState(null);
|
|
35
|
+
const { exit } = useApp();
|
|
25
36
|
const { stdout } = useStdout();
|
|
26
37
|
const termWidth = stdout.columns ?? 100;
|
|
27
|
-
const render = getFeedRender(flags.render);
|
|
28
38
|
const cardWidth = getFeedWidth(flags.width);
|
|
39
|
+
const render = getFeedRender(flags.render);
|
|
40
|
+
const pollInterval = Math.max(5, flags.interval ?? 30) * 1000;
|
|
41
|
+
const [items, setItems] = useState([]);
|
|
42
|
+
const [noInterests, setNoInterests] = useState(false);
|
|
43
|
+
const [error, setError] = useState(null);
|
|
44
|
+
const [initialLoad, setInitialLoad] = useState(true);
|
|
45
|
+
const [pollCount, setPollCount] = useState(0);
|
|
46
|
+
const seenRef = useRef(new Set());
|
|
47
|
+
const feedVars = {
|
|
48
|
+
hours: flags.hours ?? null,
|
|
49
|
+
days: flags.days ?? null,
|
|
50
|
+
limit: flags.limit ?? 20,
|
|
51
|
+
kind: flags.kind ?? 'default',
|
|
52
|
+
};
|
|
29
53
|
useEffect(() => {
|
|
30
|
-
async function
|
|
54
|
+
async function poll() {
|
|
31
55
|
try {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
});
|
|
38
|
-
if (flags.json) {
|
|
39
|
-
if (result.feed.length === 0) {
|
|
40
|
-
const kind = flags.kind ?? 'default';
|
|
41
|
-
process.stderr.write([
|
|
42
|
-
'[sonar feed] Empty result — possible causes:',
|
|
43
|
-
kind === 'bookmarks'
|
|
44
|
-
? ' • No bookmarks ingested yet. Run: sonar ingest bookmarks'
|
|
45
|
-
: ` • No tweets matched your interests in the last ${windowLabel(flags.hours, flags.days)}.`,
|
|
46
|
-
' • Check interests are configured: sonar interests',
|
|
47
|
-
' • Ingest may be stale: sonar ingest tweets && sonar monitor',
|
|
48
|
-
' • Widen the window: sonar feed --hours 48',
|
|
49
|
-
' • Account/quota status: sonar account',
|
|
50
|
-
].join('\n') + '\n');
|
|
56
|
+
if (initialLoad) {
|
|
57
|
+
const { topics } = await gql(HAS_INTERESTS_QUERY);
|
|
58
|
+
if (topics.length === 0) {
|
|
59
|
+
setNoInterests(true);
|
|
60
|
+
return;
|
|
51
61
|
}
|
|
52
|
-
|
|
62
|
+
}
|
|
63
|
+
const res = await gql(FEED_QUERY, feedVars);
|
|
64
|
+
const newItems = res.feed.filter(f => !seenRef.current.has(f.tweet.xid));
|
|
65
|
+
for (const f of newItems)
|
|
66
|
+
seenRef.current.add(f.tweet.xid);
|
|
67
|
+
if (flags.json && flags.follow) {
|
|
68
|
+
// NDJSON: one line per new item
|
|
69
|
+
for (const item of newItems) {
|
|
70
|
+
process.stdout.write(JSON.stringify(item) + '\n');
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
else if (flags.json && initialLoad) {
|
|
74
|
+
// Single-shot JSON
|
|
75
|
+
process.stdout.write(JSON.stringify(res.feed, null, 2) + '\n');
|
|
53
76
|
process.exit(0);
|
|
54
77
|
}
|
|
55
|
-
|
|
78
|
+
else {
|
|
79
|
+
setItems(prev => [...prev, ...newItems]);
|
|
80
|
+
}
|
|
81
|
+
setInitialLoad(false);
|
|
82
|
+
setPollCount(c => c + 1);
|
|
83
|
+
setError(null);
|
|
56
84
|
}
|
|
57
85
|
catch (err) {
|
|
58
|
-
|
|
86
|
+
if (flags.follow) {
|
|
87
|
+
// In follow mode, log to stderr and keep polling
|
|
88
|
+
process.stderr.write(`poll error: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
92
|
+
}
|
|
93
|
+
setInitialLoad(false);
|
|
59
94
|
}
|
|
60
95
|
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
96
|
+
poll();
|
|
97
|
+
if (!flags.follow)
|
|
98
|
+
return;
|
|
99
|
+
const timer = setInterval(poll, pollInterval);
|
|
100
|
+
return () => clearInterval(timer);
|
|
101
|
+
}, []);
|
|
102
|
+
// Exit after first render in non-follow mode
|
|
103
|
+
useEffect(() => {
|
|
104
|
+
if (!flags.follow && !initialLoad && !noInterests && !error) {
|
|
105
|
+
// Let React render one frame then exit
|
|
106
|
+
}
|
|
107
|
+
}, [initialLoad]);
|
|
108
|
+
// Quit with 'q' in follow mode
|
|
109
|
+
useInput((input) => {
|
|
110
|
+
if (input === 'q')
|
|
111
|
+
exit();
|
|
112
|
+
}, { isActive: flags.follow });
|
|
113
|
+
if (error)
|
|
64
114
|
return _jsxs(Text, { color: "red", children: ["Error: ", error] });
|
|
115
|
+
if (noInterests) {
|
|
116
|
+
return (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsx(Banner, {}), _jsx(Text, { dimColor: true, children: "No topics yet. Add one to get started:" }), _jsxs(Box, { flexDirection: "column", gap: 0, children: [_jsx(Text, { color: "cyan", children: " sonar topics add \"AI agents\"" }), _jsx(Text, { color: "cyan", children: " sonar topics add \"Rust and systems programming\"" })] })] }));
|
|
65
117
|
}
|
|
66
|
-
if (
|
|
67
|
-
return _jsx(Spinner, { label: "
|
|
118
|
+
if (initialLoad)
|
|
119
|
+
return _jsx(Spinner, { label: "Loading feed..." });
|
|
120
|
+
if (items.length === 0 && !flags.follow) {
|
|
121
|
+
return (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsx(Text, { color: "yellow", children: "Nothing to show yet." }), _jsxs(Box, { flexDirection: "column", gap: 0, children: [_jsxs(Text, { dimColor: true, children: [" 1. Refresh pipeline: ", _jsx(Text, { color: "cyan", children: "sonar refresh" })] }), _jsxs(Text, { dimColor: true, children: [" 2. Widen window: ", _jsx(Text, { color: "cyan", children: "sonar feed --hours 48" })] }), _jsxs(Text, { dimColor: true, children: [" 3. Check status: ", _jsx(Text, { color: "cyan", children: "sonar status" })] })] })] }));
|
|
68
122
|
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
return (
|
|
123
|
+
// Follow mode with JSON handled in useEffect (writes directly to stdout)
|
|
124
|
+
if (flags.follow && flags.json) {
|
|
125
|
+
return (_jsx(Box, { children: _jsx(Spinner, { label: `streaming · ${seenRef.current.size} items · q to quit` }) }));
|
|
72
126
|
}
|
|
73
|
-
|
|
74
|
-
|
|
127
|
+
const kindLabel = flags.kind === 'bookmarks' ? 'Bookmarks'
|
|
128
|
+
: flags.kind === 'followers' ? 'Followers'
|
|
129
|
+
: flags.kind === 'following' ? 'Following'
|
|
130
|
+
: 'For you';
|
|
131
|
+
const win = flags.days ? `${flags.days}d` : `${flags.hours ?? 12}h`;
|
|
132
|
+
if (render === 'table') {
|
|
133
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { bold: true, color: "white", children: kindLabel }), _jsxs(Text, { dimColor: true, children: [" \u00B7 last ", win, " (", items.length, ")"] })] }), _jsx(FeedTable, { data: items }), flags.follow && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { dimColor: true, children: ["polling every ", pollInterval / 1000, "s \u00B7 ", items.length, " items \u00B7 ", _jsx(Text, { color: "cyan", children: "q" }), " to quit"] }) }))] }));
|
|
75
134
|
}
|
|
76
|
-
|
|
77
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "white", children: flags.kind === 'bookmarks'
|
|
78
|
-
? 'Bookmarks Feed'
|
|
79
|
-
: flags.kind === 'followers'
|
|
80
|
-
? 'Followers Feed'
|
|
81
|
-
: flags.kind === 'following'
|
|
82
|
-
? 'Following Feed'
|
|
83
|
-
: 'Network Feed' }), flags.kind !== 'bookmarks' && (_jsxs(Text, { dimColor: true, children: [' · ', "last ", win] })), _jsxs(Text, { dimColor: true, children: [" (", data.length, ")"] })] }), _jsx(Text, { dimColor: true, children: '─'.repeat(Math.min(termWidth - 2, 72)) })] }), render === 'table' ? (_jsx(FeedTable, { data: data })) : (_jsx(Box, { flexDirection: "column", children: data.map((item, i) => (_jsx(TweetCard, { item: item, termWidth: termWidth, cardWidth: cardWidth, isLast: i === data.length - 1 }, item.tweet.id))) })), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { dimColor: true, children: "tip adjust window \u2192 " }), _jsx(Text, { color: "cyan", children: "sonar feed --hours 24" })] })] }));
|
|
84
|
-
}
|
|
85
|
-
// ─── Query ────────────────────────────────────────────────────────────────────
|
|
86
|
-
const FEED_QUERY = `
|
|
87
|
-
query Feed($hours: Int, $days: Int, $limit: Int, $kind: String) {
|
|
88
|
-
feed(hours: $hours, days: $days, limit: $limit, kind: $kind) {
|
|
89
|
-
score
|
|
90
|
-
matchedKeywords
|
|
91
|
-
tweet {
|
|
92
|
-
id
|
|
93
|
-
xid
|
|
94
|
-
text
|
|
95
|
-
createdAt
|
|
96
|
-
likeCount
|
|
97
|
-
retweetCount
|
|
98
|
-
replyCount
|
|
99
|
-
user {
|
|
100
|
-
displayName
|
|
101
|
-
username
|
|
102
|
-
followersCount
|
|
103
|
-
followingCount
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
`;
|
|
109
|
-
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
110
|
-
function windowLabel(hours, days) {
|
|
111
|
-
if (hours)
|
|
112
|
-
return `${hours}h`;
|
|
113
|
-
if (days)
|
|
114
|
-
return `${days}d`;
|
|
115
|
-
return '12h';
|
|
116
|
-
}
|
|
117
|
-
function formatTimestamp(dateStr) {
|
|
118
|
-
const d = new Date(dateStr);
|
|
119
|
-
const month = d.toLocaleString('en-US', { month: 'short' });
|
|
120
|
-
const day = d.getDate();
|
|
121
|
-
const hours = d.getHours();
|
|
122
|
-
const mins = d.getMinutes().toString().padStart(2, '0');
|
|
123
|
-
const ampm = hours >= 12 ? 'pm' : 'am';
|
|
124
|
-
const h = hours % 12 || 12;
|
|
125
|
-
return `${month} ${day} · ${h}:${mins}${ampm}`;
|
|
126
|
-
}
|
|
127
|
-
function relativeTime(dateStr) {
|
|
128
|
-
const diff = Date.now() - new Date(dateStr).getTime();
|
|
129
|
-
const mins = Math.floor(diff / 60000);
|
|
130
|
-
if (mins < 60)
|
|
131
|
-
return `${mins}m`;
|
|
132
|
-
const hours = Math.floor(mins / 60);
|
|
133
|
-
if (hours < 24)
|
|
134
|
-
return `${hours}h`;
|
|
135
|
-
return `${Math.floor(hours / 24)}d`;
|
|
136
|
-
}
|
|
137
|
-
function formatCount(n) {
|
|
138
|
-
if (n == null)
|
|
139
|
-
return null;
|
|
140
|
-
if (n >= 1_000_000)
|
|
141
|
-
return `${(n / 1_000_000).toFixed(1)}M`;
|
|
142
|
-
if (n >= 1_000)
|
|
143
|
-
return `${(n / 1_000).toFixed(1)}k`;
|
|
144
|
-
return String(n);
|
|
145
|
-
}
|
|
146
|
-
function scoreColor(score) {
|
|
147
|
-
if (score >= 0.7)
|
|
148
|
-
return 'green';
|
|
149
|
-
if (score >= 0.4)
|
|
150
|
-
return 'yellow';
|
|
151
|
-
return 'white';
|
|
152
|
-
}
|
|
153
|
-
function linkifyMentions(text) {
|
|
154
|
-
return text.replace(/@(\w+)/g, (match, handle) => {
|
|
155
|
-
const url = `https://x.com/${handle}`;
|
|
156
|
-
return `\x1b]8;;${url}\x07\x1b[94m${match}\x1b[39m\x1b]8;;\x07`;
|
|
157
|
-
});
|
|
158
|
-
}
|
|
159
|
-
function TweetText({ text }) {
|
|
160
|
-
return _jsx(Text, { wrap: "wrap", children: linkifyMentions(text) });
|
|
161
|
-
}
|
|
162
|
-
export function TweetCard({ item, termWidth, cardWidth, isLast }) {
|
|
163
|
-
const { tweet, score } = item;
|
|
164
|
-
const handle = tweet.user.username ?? tweet.user.displayName;
|
|
165
|
-
const author = `@${handle}`;
|
|
166
|
-
const bodyBoxWidth = Math.min(cardWidth + 2, termWidth);
|
|
167
|
-
const profileUrl = `https://x.com/${handle}`;
|
|
168
|
-
const tweetUrl = `https://x.com/${handle}/status/${tweet.id}`;
|
|
169
|
-
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)) }) }))] }));
|
|
170
|
-
}
|
|
171
|
-
function osc8Link(url, label) {
|
|
172
|
-
return `\x1b]8;;${url}\x07${label}\x1b]8;;\x07`;
|
|
173
|
-
}
|
|
174
|
-
function FeedTable({ data }) {
|
|
175
|
-
const rows = data.map((item) => {
|
|
176
|
-
const handle = item.tweet.user.username ?? item.tweet.user.displayName;
|
|
177
|
-
const tweetUrl = `https://x.com/${handle}/status/${item.tweet.id}`;
|
|
178
|
-
return {
|
|
179
|
-
age: osc8Link(tweetUrl, relativeTime(item.tweet.createdAt)),
|
|
180
|
-
score: item.score > 0 ? item.score.toFixed(2) : '—',
|
|
181
|
-
author: `@${handle}`,
|
|
182
|
-
tweet: item.tweet.text.replace(/\n/g, ' ').slice(0, 80),
|
|
183
|
-
};
|
|
184
|
-
});
|
|
185
|
-
return _jsx(Table, { rows: rows, columns: ['age', 'score', 'author', 'tweet'] });
|
|
135
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "white", children: kindLabel }), _jsxs(Text, { dimColor: true, children: [" \u00B7 last ", win] }), _jsxs(Text, { dimColor: true, children: [" (", items.length, ")"] })] }), _jsx(Text, { dimColor: true, children: '─'.repeat(Math.min(termWidth - 2, 72)) })] }), _jsx(Box, { flexDirection: "column", children: items.map((item, i) => (_jsx(TweetCard, { item: item, termWidth: termWidth, cardWidth: cardWidth, isLast: i === items.length - 1 }, item.tweet.xid))) }), flags.follow ? (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { dimColor: true, children: ["polling every ", pollInterval / 1000, "s \u00B7 ", items.length, " items \u00B7 ", _jsx(Text, { color: "cyan", children: "q" }), " to quit"] }) })) : (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { dimColor: true, children: "tip refresh \u2192 " }), _jsx(Text, { color: "cyan", children: "sonar refresh" }), _jsx(Text, { dimColor: true, children: " \u00B7 follow \u2192 " }), _jsx(Text, { color: "cyan", children: "sonar feed --follow" })] }))] }));
|
|
186
136
|
}
|
package/dist/commands/index.js
CHANGED
|
@@ -1,5 +1,173 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useState } from 'react';
|
|
3
|
+
import zod from 'zod';
|
|
4
|
+
import { Box, Text, useStdout } from 'ink';
|
|
5
|
+
import { Banner } from '../components/Banner.js';
|
|
6
|
+
import { Spinner } from '../components/Spinner.js';
|
|
7
|
+
import { TriageSession } from '../components/InteractiveSession.js';
|
|
8
|
+
import { gql } from '../lib/client.js';
|
|
9
|
+
import { getFeedRender, getFeedWidth } from '../lib/config.js';
|
|
10
|
+
import { TweetCard } from '../components/TweetCard.js';
|
|
11
|
+
export const args = zod.tuple([]).rest(zod.string());
|
|
12
|
+
export const options = zod.object({
|
|
13
|
+
hours: zod.number().optional().describe('Look back N hours (default: 12)'),
|
|
14
|
+
days: zod.number().optional().describe('Look back N days'),
|
|
15
|
+
limit: zod.number().optional().describe('Result limit (default: 20)'),
|
|
16
|
+
kind: zod.string().optional().describe('Feed source: default|bookmarks|followers|following'),
|
|
17
|
+
render: zod.string().optional().describe('Output layout: card|table'),
|
|
18
|
+
width: zod.number().optional().describe('Card width in columns'),
|
|
19
|
+
json: zod.boolean().default(false).describe('Raw JSON output'),
|
|
20
|
+
interactive: zod.boolean().default(true).describe('Interactive session mode (default: on, use --no-interactive to disable)'),
|
|
21
|
+
vendor: zod.string().optional().describe('AI vendor: openai|anthropic'),
|
|
22
|
+
});
|
|
23
|
+
const FEED_QUERY = `
|
|
24
|
+
query Feed($hours: Int, $days: Int, $limit: Int, $kind: String) {
|
|
25
|
+
feed(hours: $hours, days: $days, limit: $limit, kind: $kind) {
|
|
26
|
+
score
|
|
27
|
+
matchedKeywords
|
|
28
|
+
tweet {
|
|
29
|
+
id xid text createdAt likeCount retweetCount replyCount
|
|
30
|
+
user { displayName username followersCount followingCount }
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
`;
|
|
35
|
+
const INBOX_QUERY = `
|
|
36
|
+
query Inbox($status: SuggestionStatus, $limit: Int, $offset: Int) {
|
|
37
|
+
suggestions(status: $status, limit: $limit, offset: $offset) {
|
|
38
|
+
suggestionId score
|
|
39
|
+
tweet {
|
|
40
|
+
id xid text createdAt likeCount retweetCount replyCount
|
|
41
|
+
user { displayName username followersCount followingCount }
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
suggestionCounts { inbox }
|
|
45
|
+
}
|
|
46
|
+
`;
|
|
47
|
+
const HAS_INTERESTS_QUERY = `query HasInterests { topics { id: nanoId } }`;
|
|
48
|
+
export default function Sonar({ options: flags, args: positionalArgs }) {
|
|
49
|
+
const [items, setItems] = useState(null);
|
|
50
|
+
const [total, setTotal] = useState(0);
|
|
51
|
+
const [noInterests, setNoInterests] = useState(false);
|
|
52
|
+
const [error, setError] = useState(null);
|
|
53
|
+
// Unknown subcommand — show help hint
|
|
54
|
+
if (positionalArgs && positionalArgs.length > 0) {
|
|
55
|
+
return (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsxs(Text, { color: "red", children: ["Unknown command: ", positionalArgs.join(' ')] }), _jsxs(Text, { dimColor: true, children: ["Run ", _jsx(Text, { color: "cyan", children: "sonar --help" }), " to see available commands."] })] }));
|
|
56
|
+
}
|
|
57
|
+
const { stdout } = useStdout();
|
|
58
|
+
const termWidth = stdout.columns ?? 100;
|
|
59
|
+
const cardWidth = getFeedWidth(flags.width);
|
|
60
|
+
const render = getFeedRender(flags.render);
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
async function run() {
|
|
63
|
+
try {
|
|
64
|
+
const limit = flags.limit ?? 20;
|
|
65
|
+
const { topics } = await gql(HAS_INTERESTS_QUERY);
|
|
66
|
+
if (topics.length === 0) {
|
|
67
|
+
setNoInterests(true);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const [feedRes, inboxRes] = await Promise.all([
|
|
71
|
+
gql(FEED_QUERY, {
|
|
72
|
+
hours: flags.hours ?? null,
|
|
73
|
+
days: flags.days ?? null,
|
|
74
|
+
limit,
|
|
75
|
+
kind: flags.kind ?? 'default',
|
|
76
|
+
}),
|
|
77
|
+
gql(INBOX_QUERY, { status: 'INBOX', limit, offset: 0 }),
|
|
78
|
+
]);
|
|
79
|
+
const inboxTotal = inboxRes.suggestionCounts.inbox;
|
|
80
|
+
// Merge: deduplicate by xid, suggestions take priority, sort by score
|
|
81
|
+
const seen = new Set();
|
|
82
|
+
const merged = [];
|
|
83
|
+
for (const s of inboxRes.suggestions) {
|
|
84
|
+
if (!seen.has(s.tweet.xid)) {
|
|
85
|
+
seen.add(s.tweet.xid);
|
|
86
|
+
merged.push({
|
|
87
|
+
key: s.tweet.xid,
|
|
88
|
+
score: s.score,
|
|
89
|
+
source: 'suggestion',
|
|
90
|
+
suggestionId: s.suggestionId,
|
|
91
|
+
matchedKeywords: [],
|
|
92
|
+
tweet: {
|
|
93
|
+
id: s.tweet.id,
|
|
94
|
+
xid: s.tweet.xid,
|
|
95
|
+
text: s.tweet.text,
|
|
96
|
+
createdAt: s.tweet.createdAt,
|
|
97
|
+
likeCount: s.tweet.likeCount,
|
|
98
|
+
retweetCount: s.tweet.retweetCount,
|
|
99
|
+
replyCount: s.tweet.replyCount,
|
|
100
|
+
user: s.tweet.user,
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
for (const f of feedRes.feed) {
|
|
106
|
+
if (!seen.has(f.tweet.xid)) {
|
|
107
|
+
seen.add(f.tweet.xid);
|
|
108
|
+
merged.push({
|
|
109
|
+
key: f.tweet.xid,
|
|
110
|
+
score: f.score,
|
|
111
|
+
source: 'feed',
|
|
112
|
+
matchedKeywords: f.matchedKeywords,
|
|
113
|
+
tweet: f.tweet,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
merged.sort((a, b) => b.score - a.score);
|
|
118
|
+
if (flags.json) {
|
|
119
|
+
process.stdout.write(JSON.stringify(merged, null, 2) + '\n');
|
|
120
|
+
process.exit(0);
|
|
121
|
+
}
|
|
122
|
+
setItems(merged);
|
|
123
|
+
setTotal(inboxTotal);
|
|
124
|
+
}
|
|
125
|
+
catch (err) {
|
|
126
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
run();
|
|
130
|
+
}, [flags.hours, flags.days, flags.limit, flags.kind, flags.json]);
|
|
131
|
+
if (error)
|
|
132
|
+
return _jsxs(Text, { color: "red", children: ["Error: ", error] });
|
|
133
|
+
if (noInterests) {
|
|
134
|
+
return (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsx(Banner, {}), _jsx(Text, { dimColor: true, children: "No topics yet. Add one to get started:" }), _jsxs(Box, { flexDirection: "column", gap: 0, children: [_jsx(Text, { color: "cyan", children: " sonar topics add \"AI agents\"" }), _jsx(Text, { color: "cyan", children: " sonar topics add \"Rust and systems programming\"" })] })] }));
|
|
135
|
+
}
|
|
136
|
+
if (!items)
|
|
137
|
+
return _jsx(Spinner, { label: "Loading..." });
|
|
138
|
+
if (items.length === 0) {
|
|
139
|
+
return (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsx(Text, { color: "yellow", children: "Nothing to show yet." }), _jsxs(Box, { flexDirection: "column", gap: 0, children: [_jsxs(Text, { dimColor: true, children: [" 1. Refresh pipeline: ", _jsx(Text, { color: "cyan", children: "sonar refresh" })] }), _jsxs(Text, { dimColor: true, children: [" 2. Widen window: ", _jsx(Text, { color: "cyan", children: "sonar --hours 48" })] }), _jsxs(Text, { dimColor: true, children: [" 3. Check status: ", _jsx(Text, { color: "cyan", children: "sonar status" })] })] })] }));
|
|
140
|
+
}
|
|
141
|
+
if (flags.interactive) {
|
|
142
|
+
const pageSize = flags.limit ?? 20;
|
|
143
|
+
const fetchMore = async (offset) => {
|
|
144
|
+
const res = await gql(INBOX_QUERY, {
|
|
145
|
+
status: 'INBOX', limit: pageSize, offset,
|
|
146
|
+
});
|
|
147
|
+
return res.suggestions.map(s => ({
|
|
148
|
+
key: s.tweet.xid,
|
|
149
|
+
score: s.score,
|
|
150
|
+
source: 'suggestion',
|
|
151
|
+
suggestionId: s.suggestionId,
|
|
152
|
+
matchedKeywords: [],
|
|
153
|
+
tweet: {
|
|
154
|
+
id: s.tweet.id,
|
|
155
|
+
xid: s.tweet.xid,
|
|
156
|
+
text: s.tweet.text,
|
|
157
|
+
createdAt: s.tweet.createdAt,
|
|
158
|
+
likeCount: s.tweet.likeCount,
|
|
159
|
+
retweetCount: s.tweet.retweetCount,
|
|
160
|
+
replyCount: s.tweet.replyCount,
|
|
161
|
+
user: s.tweet.user,
|
|
162
|
+
},
|
|
163
|
+
}));
|
|
164
|
+
};
|
|
165
|
+
return _jsx(TriageSession, { items: items, total: total, fetchMore: fetchMore });
|
|
166
|
+
}
|
|
167
|
+
const kindLabel = flags.kind === 'bookmarks' ? 'Bookmarks'
|
|
168
|
+
: flags.kind === 'followers' ? 'Followers'
|
|
169
|
+
: flags.kind === 'following' ? 'Following'
|
|
170
|
+
: 'For you';
|
|
171
|
+
const win = flags.days ? `${flags.days}d` : `${flags.hours ?? 12}h`;
|
|
172
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "white", children: kindLabel }), _jsxs(Text, { dimColor: true, children: [" \u00B7 last ", win] }), _jsxs(Text, { dimColor: true, children: [" (", items.length, ")"] })] }), _jsx(Text, { dimColor: true, children: '─'.repeat(Math.min(termWidth - 2, 72)) })] }), _jsx(Box, { flexDirection: "column", children: items.map((item, i) => (_jsxs(Box, { flexDirection: "column", children: [_jsx(TweetCard, { item: { score: item.score, matchedKeywords: item.matchedKeywords, tweet: item.tweet }, termWidth: termWidth, cardWidth: cardWidth, isLast: i === items.length - 1 && !item.suggestionId }), item.suggestionId && (_jsx(Box, { marginLeft: 2, marginBottom: i === items.length - 1 ? 0 : 1, children: _jsxs(Text, { dimColor: true, children: [item.suggestionId.slice(0, 8), ' · ', "sonar archive --id ", item.suggestionId.slice(0, 8), ' · ', "sonar later --id ", item.suggestionId.slice(0, 8), ' · ', "sonar skip --id ", item.suggestionId.slice(0, 8)] }) }))] }, item.key))) }), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { dimColor: true, children: "tip refresh \u2192 " }), _jsx(Text, { color: "cyan", children: "sonar refresh" }), _jsx(Text, { dimColor: true, children: " \u00B7 widen window \u2192 " }), _jsx(Text, { color: "cyan", children: "sonar --hours 48" })] })] }));
|
|
5
173
|
}
|
|
@@ -2,8 +2,8 @@ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
|
2
2
|
import { useEffect, useState } from 'react';
|
|
3
3
|
import zod from 'zod';
|
|
4
4
|
import { Text } from 'ink';
|
|
5
|
-
import { gql } from '
|
|
6
|
-
import { Spinner } from '
|
|
5
|
+
import { gql } from '../lib/client.js';
|
|
6
|
+
import { Spinner } from '../components/Spinner.js';
|
|
7
7
|
export const options = zod.object({
|
|
8
8
|
id: zod.string().describe('Suggestion ID to save for later'),
|
|
9
9
|
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useState } from 'react';
|
|
3
|
+
import { Box, Text, useApp } from 'ink';
|
|
4
|
+
import { gql } from '../lib/client.js';
|
|
5
|
+
import { Spinner } from '../components/Spinner.js';
|
|
6
|
+
export default function Refresh() {
|
|
7
|
+
const { exit } = useApp();
|
|
8
|
+
const [status, setStatus] = useState('pending');
|
|
9
|
+
const [error, setError] = useState(null);
|
|
10
|
+
const [batchId, setBatchId] = useState(null);
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
async function run() {
|
|
13
|
+
setStatus('running');
|
|
14
|
+
try {
|
|
15
|
+
const result = await gql('mutation Refresh { refresh(days: 1) }');
|
|
16
|
+
setBatchId(result.refresh);
|
|
17
|
+
setStatus('ok');
|
|
18
|
+
}
|
|
19
|
+
catch (err) {
|
|
20
|
+
setStatus('failed');
|
|
21
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
run();
|
|
25
|
+
}, []);
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
if (status === 'ok' || status === 'failed')
|
|
28
|
+
exit();
|
|
29
|
+
}, [status]);
|
|
30
|
+
if (status === 'running') {
|
|
31
|
+
return _jsx(Spinner, { label: "Queuing refresh pipeline..." });
|
|
32
|
+
}
|
|
33
|
+
if (status === 'failed') {
|
|
34
|
+
const isAuthError = error?.includes('Re-authorize') || error?.includes('not connected');
|
|
35
|
+
if (isAuthError) {
|
|
36
|
+
return (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsx(Text, { color: "yellow", children: "X authorization required" }), _jsxs(Text, { dimColor: true, children: ["Connect your X account at ", _jsx(Text, { color: "cyan", children: "https://sonar.8640p.info/account" })] })] }));
|
|
37
|
+
}
|
|
38
|
+
return _jsxs(Text, { color: "red", children: ["Error: ", error] });
|
|
39
|
+
}
|
|
40
|
+
return (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsx(Text, { color: "green", children: "\u2713 Refresh pipeline queued" }), batchId && _jsxs(Text, { dimColor: true, children: ["batch: ", batchId] }), _jsxs(Text, { dimColor: true, children: ["Run ", _jsx(Text, { color: "cyan", children: "sonar status --watch" }), " to monitor progress."] })] }));
|
|
41
|
+
}
|
|
@@ -2,8 +2,8 @@ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
|
2
2
|
import { useEffect, useState } from 'react';
|
|
3
3
|
import zod from 'zod';
|
|
4
4
|
import { Text } from 'ink';
|
|
5
|
-
import { gql } from '
|
|
6
|
-
import { Spinner } from '
|
|
5
|
+
import { gql } from '../lib/client.js';
|
|
6
|
+
import { Spinner } from '../components/Spinner.js';
|
|
7
7
|
export const options = zod.object({
|
|
8
8
|
id: zod.string().describe('Suggestion ID to skip'),
|
|
9
9
|
});
|