@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.
Files changed (42) hide show
  1. package/README.md +151 -166
  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 -39
  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/components/InterestCard.js +0 -10
@@ -1,13 +1,12 @@
1
- import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
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 Link from 'ink-link';
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, getVendor } from '../lib/config.js';
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
- interactive: zod.boolean().default(false).describe('Interactive session mode'),
20
- vendor: zod.string().optional().describe('AI vendor: openai|anthropic'),
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 [data, setData] = useState(null);
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 run() {
54
+ async function poll() {
31
55
  try {
32
- const result = await gql(FEED_QUERY, {
33
- hours: flags.hours ?? null,
34
- days: flags.days ?? null,
35
- limit: flags.limit ?? 20,
36
- kind: flags.kind ?? 'default',
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 ingest 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
- process.stdout.write(`${JSON.stringify(result.feed, null, 2)}\n`);
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
- setData(result.feed);
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
- setError(err instanceof Error ? err.message : String(err));
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
- run();
62
- }, [flags.hours, flags.days, flags.limit, flags.json, flags.kind]);
63
- if (error) {
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 (!data) {
67
- return _jsx(Spinner, { label: "Fetching feed..." });
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
- if (data.length === 0) {
70
- const kind = flags.kind ?? 'default';
71
- return (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsx(Text, { color: "yellow", children: "No tweets found." }), kind === 'bookmarks' ? (_jsxs(Box, { flexDirection: "column", gap: 0, children: [_jsx(Text, { dimColor: true, children: "Your bookmarks feed is empty. Things to check:" }), _jsxs(Text, { dimColor: true, children: [" 1. Ingest bookmarks first: ", _jsx(Text, { color: "cyan", children: "sonar ingest bookmarks" })] }), _jsxs(Text, { dimColor: true, children: [" 2. Then monitor progress: ", _jsx(Text, { color: "cyan", children: "sonar ingest monitor --watch" })] })] })) : (_jsxs(Box, { flexDirection: "column", gap: 0, children: [_jsxs(Text, { dimColor: true, children: ["No ", kind === 'followers' ? 'follower' : kind === 'following' ? 'following' : 'network', " tweets matched your interests in the last ", _jsx(Text, { color: "white", children: windowLabel(flags.hours, flags.days) }), "."] }), _jsx(Text, { dimColor: true, children: "Things to check:" }), _jsxs(Text, { dimColor: true, children: [" 1. Widen the window: ", _jsx(Text, { color: "cyan", children: "sonar feed --hours 48" }), " or ", _jsx(Text, { color: "cyan", children: "--days 7" })] }), _jsxs(Text, { dimColor: true, children: [" 2. Check interests exist: ", _jsx(Text, { color: "cyan", children: "sonar interests" })] }), _jsxs(Text, { dimColor: true, children: [" 3. Trigger ingest if stale: ", _jsx(Text, { color: "cyan", children: "sonar ingest tweets" })] }), _jsxs(Text, { dimColor: true, children: [" 4. Check ingest progress: ", _jsx(Text, { color: "cyan", children: "sonar ingest monitor" })] }), _jsxs(Text, { dimColor: true, children: [" 5. Run matching: ", _jsx(Text, { color: "cyan", children: "sonar interests match" })] })] })), _jsxs(Text, { dimColor: true, children: ["Account status and quota: ", _jsx(Text, { color: "cyan", children: "sonar account" })] })] }));
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
- if (flags.interactive) {
74
- return _jsx(InteractiveFeedSession, { items: data, vendor: getVendor(flags.vendor) });
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
- const win = windowLabel(flags.hours, flags.days);
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
  }
@@ -1,5 +1,173 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Text, Box } from 'ink';
3
- export default function Index() {
4
- return (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsx(Text, { bold: true, children: "Sonar CLI" }), _jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "Commands:" }), _jsx(Text, { children: " feed Scored tweet feed from your network" }), _jsx(Text, { children: " inbox Suggestions matching your interests" }), _jsx(Text, { children: " interests Manage interests" }), _jsx(Text, { children: " \u2514\u2500\u2500 create Create a new interest" }), _jsx(Text, { children: " \u2514\u2500\u2500 update Update an interest" }), _jsx(Text, { children: " \u2514\u2500\u2500 match Match interests to ingested tweets" }), _jsx(Text, { children: " ingest Ingest tweets and bookmarks" }), _jsx(Text, { children: " \u2514\u2500\u2500 tweets Ingest recent tweets from social graph" }), _jsx(Text, { children: " \u2514\u2500\u2500 bookmarks Ingest X bookmarks" }), _jsx(Text, { children: " monitor Job queue monitor and account status" }), _jsx(Text, { children: " config Show or set CLI config" }), _jsx(Text, { children: " account Account info and plan usage" })] }), _jsxs(Text, { dimColor: true, children: ["Run ", _jsx(Text, { color: "cyan", children: "sonar <command> --help" }), " for command-specific options."] })] }));
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 '../../lib/client.js';
6
- import { Spinner } from '../../components/Spinner.js';
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 '../../lib/client.js';
6
- import { Spinner } from '../../components/Spinner.js';
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
  });