@1a35e1/sonar-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +422 -0
  2. package/dist/cli.js +4 -0
  3. package/dist/commands/account.js +75 -0
  4. package/dist/commands/config/data/download.js +53 -0
  5. package/dist/commands/config/data/path.js +11 -0
  6. package/dist/commands/config/data/sql.js +12 -0
  7. package/dist/commands/config/data/sync.js +85 -0
  8. package/dist/commands/config/env.js +15 -0
  9. package/dist/commands/config/index.js +12 -0
  10. package/dist/commands/config/nuke.js +19 -0
  11. package/dist/commands/config/set.js +38 -0
  12. package/dist/commands/config/setup.js +29 -0
  13. package/dist/commands/config/skill.js +15 -0
  14. package/dist/commands/feed.js +172 -0
  15. package/dist/commands/inbox/archive.js +41 -0
  16. package/dist/commands/inbox/index.js +92 -0
  17. package/dist/commands/inbox/later.js +41 -0
  18. package/dist/commands/inbox/read.js +41 -0
  19. package/dist/commands/inbox/skip.js +41 -0
  20. package/dist/commands/index.js +5 -0
  21. package/dist/commands/ingest/bookmarks.js +31 -0
  22. package/dist/commands/ingest/index.js +5 -0
  23. package/dist/commands/ingest/tweets.js +31 -0
  24. package/dist/commands/interests/create.js +94 -0
  25. package/dist/commands/interests/index.js +56 -0
  26. package/dist/commands/interests/match.js +33 -0
  27. package/dist/commands/interests/update.js +142 -0
  28. package/dist/commands/monitor.js +81 -0
  29. package/dist/components/AccountCard.js +6 -0
  30. package/dist/components/InteractiveSession.js +241 -0
  31. package/dist/components/InterestCard.js +10 -0
  32. package/dist/components/RefreshTip.js +5 -0
  33. package/dist/components/Spinner.js +14 -0
  34. package/dist/components/Table.js +23 -0
  35. package/dist/lib/ai.js +160 -0
  36. package/dist/lib/client.js +33 -0
  37. package/dist/lib/config.js +74 -0
  38. package/dist/lib/data-queries.js +61 -0
  39. package/dist/lib/db.js +73 -0
  40. package/dist/lib/skill.js +290 -0
  41. package/dist/types/sonar.js +42 -0
  42. package/package.json +47 -0
@@ -0,0 +1,94 @@
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 } from 'ink';
5
+ import { gql } from '../../lib/client.js';
6
+ import { generateInterest } from '../../lib/ai.js';
7
+ import { getVendor } from '../../lib/config.js';
8
+ import { Spinner } from '../../components/Spinner.js';
9
+ export const options = zod.object({
10
+ name: zod.string().optional().describe('Interest name'),
11
+ description: zod.string().optional().describe('Interest description'),
12
+ keywords: zod.string().optional().describe('Comma-separated keywords'),
13
+ topics: zod.string().optional().describe('Comma-separated related topics'),
14
+ fromPrompt: zod.string().optional().describe('Generate fields from a natural language prompt'),
15
+ vendor: zod.string().optional().describe('AI vendor: openai|anthropic'),
16
+ json: zod.boolean().default(false).describe('Raw JSON output'),
17
+ });
18
+ const CREATE_MUTATION = `
19
+ mutation CreateOrUpdateInterest(
20
+ $nanoId: String
21
+ $name: String!
22
+ $description: String
23
+ $keywords: [String!]
24
+ $relatedTopics: [String!]
25
+ ) {
26
+ createOrUpdateProject(input: {
27
+ nanoId: $nanoId
28
+ name: $name
29
+ description: $description
30
+ keywords: $keywords
31
+ relatedTopics: $relatedTopics
32
+ }) {
33
+ id: nanoId
34
+ name
35
+ description
36
+ keywords
37
+ relatedTopics
38
+ version
39
+ createdAt
40
+ updatedAt
41
+ }
42
+ }
43
+ `;
44
+ export default function InterestsCreate({ options: flags }) {
45
+ const [data, setData] = useState(null);
46
+ const [error, setError] = useState(null);
47
+ useEffect(() => {
48
+ async function run() {
49
+ try {
50
+ let name = flags.name;
51
+ let description = flags.description ?? null;
52
+ let keywords = flags.keywords ? flags.keywords.split(',').map((k) => k.trim()) : null;
53
+ let relatedTopics = flags.topics ? flags.topics.split(',').map((t) => t.trim()) : null;
54
+ if (flags.fromPrompt) {
55
+ const vendor = getVendor(flags.vendor);
56
+ const generated = await generateInterest(flags.fromPrompt, vendor);
57
+ name = generated.name;
58
+ description = generated.description;
59
+ keywords = generated.keywords;
60
+ relatedTopics = generated.relatedTopics;
61
+ }
62
+ if (!name) {
63
+ setError('--name or --from-prompt is required');
64
+ return;
65
+ }
66
+ const result = await gql(CREATE_MUTATION, {
67
+ nanoId: null,
68
+ name,
69
+ description,
70
+ keywords,
71
+ relatedTopics,
72
+ });
73
+ if (flags.json) {
74
+ process.stdout.write(JSON.stringify(result.createOrUpdateProject, null, 2) + '\n');
75
+ process.exit(0);
76
+ }
77
+ setData(result.createOrUpdateProject);
78
+ }
79
+ catch (err) {
80
+ setError(err instanceof Error ? err.message : String(err));
81
+ }
82
+ }
83
+ run();
84
+ }, []);
85
+ if (error)
86
+ return _jsxs(Text, { color: "red", children: ["Error: ", error] });
87
+ if (!data) {
88
+ const label = flags.fromPrompt
89
+ ? `Generating interest via ${getVendor(flags.vendor)}...`
90
+ : 'Creating interest...';
91
+ return _jsx(Spinner, { label: label });
92
+ }
93
+ return (_jsxs(Box, { flexDirection: "column", gap: 0, children: [_jsxs(Box, { gap: 2, children: [_jsx(Text, { bold: true, color: "cyan", children: data.name }), _jsxs(Text, { dimColor: true, children: ["v", data.version, " \u00B7 ", data.id, " \u00B7 created"] })] }), data.description && _jsx(Text, { dimColor: true, children: data.description }), data.keywords && data.keywords.length > 0 && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "keywords:" }), _jsx(Text, { children: data.keywords.join(', ') })] })), data.relatedTopics && data.relatedTopics.length > 0 && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "topics: " }), _jsx(Text, { children: data.relatedTopics.join(', ') })] }))] }));
94
+ }
@@ -0,0 +1,56 @@
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 { gql } from '../../lib/client.js';
6
+ import { Spinner } from '../../components/Spinner.js';
7
+ import { InterestCard } from '../../components/InterestCard.js';
8
+ export const options = zod.object({
9
+ json: zod.boolean().default(false).describe('Raw JSON output'),
10
+ });
11
+ const QUERY = `
12
+ query Interests {
13
+ projects {
14
+ id: nanoId
15
+ name
16
+ description
17
+ keywords
18
+ relatedTopics
19
+ version
20
+ createdAt
21
+ updatedAt
22
+ }
23
+ }
24
+ `;
25
+ export default function Interests({ options: flags }) {
26
+ const [data, setData] = useState(null);
27
+ const [error, setError] = useState(null);
28
+ const { stdout } = useStdout();
29
+ const termWidth = stdout.columns ?? 100;
30
+ useEffect(() => {
31
+ async function run() {
32
+ try {
33
+ const result = await gql(QUERY);
34
+ if (flags.json) {
35
+ process.stdout.write(JSON.stringify(result.projects, null, 2) + '\n');
36
+ process.exit(0);
37
+ }
38
+ setData(result.projects);
39
+ }
40
+ catch (err) {
41
+ setError(err instanceof Error ? err.message : String(err));
42
+ }
43
+ }
44
+ run();
45
+ }, []);
46
+ if (error) {
47
+ return _jsxs(Text, { color: "red", children: ["Error: ", error] });
48
+ }
49
+ if (!data) {
50
+ return _jsx(Spinner, { label: "Fetching interests..." });
51
+ }
52
+ if (data.length === 0) {
53
+ return (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsx(Text, { children: "No interests found. Create one from a prompt:" }), _jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: " sonar interests create --from-prompt \"I want to follow the AI agents ecosystem\"" }), _jsx(Text, { dimColor: true, children: " sonar interests create --from-prompt \"Rust and systems programming\" --vendor anthropic" }), _jsx(Text, { dimColor: true, children: " sonar interests create --from-prompt \"DeFi protocols and on-chain finance\"" }), _jsx(Text, { dimColor: true, children: " sonar interests create --from-prompt \"Climate tech and carbon markets\"" })] }), _jsx(Text, { dimColor: true, children: "Or manually: sonar interests create --name \"My Interest\" --keywords \"kw1,kw2\" --topics \"topic1\"" })] }));
54
+ }
55
+ return (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: "Interests" }), _jsxs(Text, { dimColor: true, children: [" (", data.length, ")"] })] }), data.map((p, i) => (_jsx(InterestCard, { interest: p, termWidth: termWidth, isLast: i === data.length - 1 }, p.id))), _jsx(Text, { dimColor: true, children: "tip: --json for raw output \u00B7 match: sonar interests match --days 3 \u00B7 update: sonar interests update --id <id> --from-prompt \"...\"" })] }));
56
+ }
@@ -0,0 +1,33 @@
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 } from 'ink';
5
+ import { gql } from '../../lib/client.js';
6
+ import { Spinner } from '../../components/Spinner.js';
7
+ import { RefreshTip } from '../../components/RefreshTip.js';
8
+ export const options = zod.object({
9
+ days: zod.number().optional().describe('Tweet window in days (default: 1, capped by plan)'),
10
+ });
11
+ export default function InterestsMatch({ options: flags }) {
12
+ const [queued, setQueued] = useState(null);
13
+ const [error, setError] = useState(null);
14
+ useEffect(() => {
15
+ async function run() {
16
+ try {
17
+ const res = await gql(`mutation RegenerateSuggestions($days: Int) {
18
+ regenerateSuggestions(days: $days)
19
+ }`, { days: flags.days ?? 1 });
20
+ setQueued(res.regenerateSuggestions);
21
+ }
22
+ catch (err) {
23
+ setError(err instanceof Error ? err.message : String(err));
24
+ }
25
+ }
26
+ run();
27
+ }, []);
28
+ if (error)
29
+ return _jsxs(Text, { color: "red", children: ["Error: ", error] });
30
+ if (queued === null)
31
+ return _jsx(Spinner, { label: "Matching interests against tweets..." });
32
+ return (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: "interests match: " }), _jsx(Text, { children: queued ? '✓ queued' : '✗ failed' })] }), _jsx(RefreshTip, {})] }));
33
+ }
@@ -0,0 +1,142 @@
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 } from 'ink';
5
+ import { gql } from '../../lib/client.js';
6
+ import { generateInterest } from '../../lib/ai.js';
7
+ import { getVendor } from '../../lib/config.js';
8
+ import { Spinner } from '../../components/Spinner.js';
9
+ export const options = zod.object({
10
+ id: zod.string().describe('Interest ID to update'),
11
+ name: zod.string().optional().describe('New name'),
12
+ description: zod.string().optional().describe('New description'),
13
+ keywords: zod.string().optional().describe('Comma-separated keywords (full replace)'),
14
+ topics: zod.string().optional().describe('Comma-separated related topics (full replace)'),
15
+ addKeywords: zod.string().optional().describe('Comma-separated keywords to add'),
16
+ removeKeywords: zod.string().optional().describe('Comma-separated keywords to remove'),
17
+ addTopics: zod.string().optional().describe('Comma-separated topics to add'),
18
+ removeTopics: zod.string().optional().describe('Comma-separated topics to remove'),
19
+ fromPrompt: zod.string().optional().describe('Regenerate all fields from a prompt'),
20
+ vendor: zod.string().optional().describe('AI vendor: openai|anthropic'),
21
+ json: zod.boolean().default(false).describe('Raw JSON output'),
22
+ });
23
+ const QUERY = `
24
+ query Interests {
25
+ projects {
26
+ id: nanoId
27
+ name
28
+ description
29
+ keywords
30
+ relatedTopics
31
+ version
32
+ createdAt
33
+ updatedAt
34
+ }
35
+ }
36
+ `;
37
+ const UPDATE_MUTATION = `
38
+ mutation CreateOrUpdateInterest(
39
+ $nanoId: String
40
+ $name: String!
41
+ $description: String
42
+ $keywords: [String!]
43
+ $relatedTopics: [String!]
44
+ ) {
45
+ createOrUpdateProject(input: {
46
+ nanoId: $nanoId
47
+ name: $name
48
+ description: $description
49
+ keywords: $keywords
50
+ relatedTopics: $relatedTopics
51
+ }) {
52
+ id: nanoId
53
+ name
54
+ description
55
+ keywords
56
+ relatedTopics
57
+ version
58
+ createdAt
59
+ updatedAt
60
+ }
61
+ }
62
+ `;
63
+ async function fetchById(id) {
64
+ const result = await gql(QUERY);
65
+ const found = result.projects.find((p) => p.id === id);
66
+ if (!found)
67
+ throw new Error(`Interest with id "${id}" not found`);
68
+ return found;
69
+ }
70
+ export default function InterestsUpdate({ options: flags }) {
71
+ const [data, setData] = useState(null);
72
+ const [error, setError] = useState(null);
73
+ useEffect(() => {
74
+ async function run() {
75
+ try {
76
+ const isPatch = !!(flags.addKeywords || flags.removeKeywords || flags.addTopics || flags.removeTopics);
77
+ let name = flags.name;
78
+ let description = flags.description ?? null;
79
+ let keywords = flags.keywords ? flags.keywords.split(',').map((k) => k.trim()) : null;
80
+ let relatedTopics = flags.topics ? flags.topics.split(',').map((t) => t.trim()) : null;
81
+ if (isPatch) {
82
+ const existing = await fetchById(flags.id);
83
+ name = flags.name ?? existing.name;
84
+ description = flags.description ?? existing.description ?? null;
85
+ const addKw = flags.addKeywords ? flags.addKeywords.split(',').map((k) => k.trim()).filter(Boolean) : [];
86
+ const removeKw = flags.removeKeywords ? new Set(flags.removeKeywords.split(',').map((k) => k.trim())) : new Set();
87
+ const existingKw = existing.keywords ?? [];
88
+ keywords = [...new Set([...existingKw.filter((k) => !removeKw.has(k)), ...addKw])];
89
+ const addT = flags.addTopics ? flags.addTopics.split(',').map((t) => t.trim()).filter(Boolean) : [];
90
+ const removeT = flags.removeTopics ? new Set(flags.removeTopics.split(',').map((t) => t.trim())) : new Set();
91
+ const existingT = existing.relatedTopics ?? [];
92
+ relatedTopics = [...new Set([...existingT.filter((t) => !removeT.has(t)), ...addT])];
93
+ }
94
+ else if (flags.fromPrompt) {
95
+ const vendor = getVendor(flags.vendor);
96
+ const generated = await generateInterest(flags.fromPrompt, vendor);
97
+ name = generated.name;
98
+ description = generated.description;
99
+ keywords = generated.keywords;
100
+ relatedTopics = generated.relatedTopics;
101
+ }
102
+ if (!name) {
103
+ const existing = await fetchById(flags.id);
104
+ name = existing.name;
105
+ if (!description)
106
+ description = existing.description ?? null;
107
+ if (!keywords)
108
+ keywords = existing.keywords ?? null;
109
+ if (!relatedTopics)
110
+ relatedTopics = existing.relatedTopics ?? null;
111
+ }
112
+ const result = await gql(UPDATE_MUTATION, {
113
+ nanoId: flags.id,
114
+ name,
115
+ description,
116
+ keywords,
117
+ relatedTopics,
118
+ });
119
+ if (flags.json) {
120
+ process.stdout.write(JSON.stringify(result.createOrUpdateProject, null, 2) + '\n');
121
+ process.exit(0);
122
+ }
123
+ setData(result.createOrUpdateProject);
124
+ }
125
+ catch (err) {
126
+ setError(err instanceof Error ? err.message : String(err));
127
+ }
128
+ }
129
+ run();
130
+ }, []);
131
+ if (error)
132
+ return _jsxs(Text, { color: "red", children: ["Error: ", error] });
133
+ if (!data) {
134
+ const label = flags.fromPrompt
135
+ ? `Generating interest via ${getVendor(flags.vendor)}...`
136
+ : (flags.addKeywords || flags.removeKeywords || flags.addTopics || flags.removeTopics)
137
+ ? 'Updating interest...'
138
+ : 'Updating interest...';
139
+ return _jsx(Spinner, { label: label });
140
+ }
141
+ return (_jsxs(Box, { flexDirection: "column", gap: 0, children: [_jsxs(Box, { gap: 2, children: [_jsx(Text, { bold: true, color: "cyan", children: data.name }), _jsxs(Text, { dimColor: true, children: ["v", data.version, " \u00B7 ", data.id, " \u00B7 updated"] })] }), data.description && _jsx(Text, { dimColor: true, children: data.description }), data.keywords && data.keywords.length > 0 && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "keywords:" }), _jsx(Text, { children: data.keywords.join(', ') })] })), data.relatedTopics && data.relatedTopics.length > 0 && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: "topics: " }), _jsx(Text, { children: data.relatedTopics.join(', ') })] }))] }));
142
+ }
@@ -0,0 +1,81 @@
1
+ import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useEffect, useState } from 'react';
3
+ import zod from 'zod';
4
+ import { Box, Text, useApp } from 'ink';
5
+ import { getToken, getApiUrl } from '../lib/config.js';
6
+ import { gql } from '../lib/client.js';
7
+ import { Spinner } from '../components/Spinner.js';
8
+ import { AccountCard } from '../components/AccountCard.js';
9
+ export const options = zod.object({
10
+ watch: zod.boolean().default(false).describe('Poll and refresh every 2 seconds'),
11
+ });
12
+ const POLL_INTERVAL = 2000;
13
+ const QUEUE_LABELS = {
14
+ tweets: 'Tweets',
15
+ bookmarks: 'Bookmarks',
16
+ social_graph: 'Social graph',
17
+ suggestions: 'Suggestions',
18
+ };
19
+ export default function Monitor({ options: flags }) {
20
+ const { exit } = useApp();
21
+ const [data, setData] = useState(null);
22
+ const [error, setError] = useState(null);
23
+ useEffect(() => {
24
+ const token = getToken();
25
+ const baseUrl = getApiUrl().replace(/\/graphql$/, '');
26
+ async function fetchStatus() {
27
+ try {
28
+ const [statusRes, meRes] = await Promise.all([
29
+ fetch(`${baseUrl}/indexing/status`, {
30
+ headers: { Authorization: `Bearer ${token}` },
31
+ }),
32
+ gql(`
33
+ query MonitorStatus {
34
+ me {
35
+ accountId
36
+ email
37
+ xHandle
38
+ xid
39
+ isPayingCustomer
40
+ indexingAccounts
41
+ indexedTweets
42
+ pendingEmbeddings
43
+ twitterIndexedAt
44
+ refreshedSuggestionsAt
45
+ }
46
+ }
47
+ `),
48
+ ]);
49
+ if (!statusRes.ok)
50
+ throw new Error(`HTTP ${statusRes.status} from ${baseUrl}`);
51
+ const status = await statusRes.json();
52
+ setData({ me: meRes.me, queues: status.queues });
53
+ setError(null);
54
+ }
55
+ catch (err) {
56
+ setError(err instanceof Error ? err.message : String(err));
57
+ }
58
+ }
59
+ fetchStatus();
60
+ if (!flags.watch)
61
+ return;
62
+ const timer = setInterval(fetchStatus, POLL_INTERVAL);
63
+ return () => clearInterval(timer);
64
+ }, []);
65
+ useEffect(() => {
66
+ if (!flags.watch && data !== null)
67
+ exit();
68
+ }, [data]);
69
+ useEffect(() => {
70
+ if (!flags.watch && error !== null)
71
+ exit(new Error(error));
72
+ }, [error]);
73
+ if (error)
74
+ return _jsxs(Text, { color: "red", children: ["Error: ", error] });
75
+ if (!data)
76
+ return _jsx(Spinner, { label: "Loading ingest status..." });
77
+ const { me, queues } = data;
78
+ const entries = Object.entries(queues);
79
+ const hasActivity = entries.length > 0 || me.pendingEmbeddings > 0;
80
+ return (_jsxs(Box, { flexDirection: "column", gap: 1, children: [_jsx(AccountCard, { me: me }), _jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, color: "cyan", children: "Job Queues" }), !hasActivity ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: "green", children: "No active ingest jobs." }), _jsxs(Text, { color: "green", children: ["Run ", _jsx(Text, { color: "cyan", children: "sonar interests match" }), " to start surface relevant tweets."] })] })) : (_jsxs(Box, { flexDirection: "column", gap: 0, children: [_jsxs(Box, { gap: 2, marginBottom: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: ('Queue').padEnd(16) }), _jsx(Text, { bold: true, color: "cyan", children: 'Running'.padEnd(10) }), _jsx(Text, { bold: true, color: "cyan", children: "Queued" })] }), entries.map(([name, counts]) => (_jsxs(Box, { gap: 2, children: [_jsx(Text, { children: (QUEUE_LABELS[name] ?? name).padEnd(16) }), _jsx(Text, { color: counts.running > 0 ? 'green' : 'white', children: String(counts.running).padEnd(10) }), _jsx(Text, { color: counts.queued > 0 ? 'yellow' : 'white', children: counts.queued })] }, name)))] }))] })] }));
81
+ }
@@ -0,0 +1,6 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import { formatDistanceToNow } from 'date-fns';
4
+ export function AccountCard({ me }) {
5
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, color: "cyan", children: "Account" }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "handle: " }), "@", me.xHandle] }), me.email && _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "email: " }), me.email] }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "plan: " }), me.isPayingCustomer ? 'Pro' : 'Free'] }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "indexing accounts: " }), me.indexingAccounts] }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "indexed tweets: " }), me.indexedTweets.toLocaleString()] }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "embeddings queue: " }), _jsx(Text, { color: me.pendingEmbeddings > 0 ? 'yellow' : 'green', children: me.pendingEmbeddings })] }), me.twitterIndexedAt && (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "last indexed: " }), formatDistanceToNow(new Date(me.twitterIndexedAt), { addSuffix: true })] })), me.refreshedSuggestionsAt && (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "suggestions refreshed: " }), formatDistanceToNow(new Date(me.refreshedSuggestionsAt), { addSuffix: true })] }))] }));
6
+ }
@@ -0,0 +1,241 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState, useCallback } from 'react';
3
+ import { Box, Text, useInput, useStdout } from 'ink';
4
+ import { Spinner } from './Spinner.js';
5
+ import { TweetCard } from '../commands/feed.js';
6
+ import { gql } from '../lib/client.js';
7
+ import { generateReply } from '../lib/ai.js';
8
+ import { getFeedWidth } from '../lib/config.js';
9
+ const UPDATE_SUGGESTION_MUTATION = `
10
+ mutation UpdateSuggestion($suggestionId: ID!, $status: SuggestionStatus!) {
11
+ updateSuggestion(input: { suggestionId: $suggestionId, status: $status }) {
12
+ suggestionId
13
+ status
14
+ }
15
+ }
16
+ `;
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
+ function Divider({ width }) {
29
+ return _jsx(Text, { dimColor: true, children: '─'.repeat(Math.min(width - 2, 72)) });
30
+ }
31
+ // ─── Shared hook ──────────────────────────────────────────────────────────────
32
+ function useInteractiveState(total, vendor) {
33
+ const [currentIndex, setCurrentIndex] = useState(0);
34
+ const [mode, setMode] = useState('view');
35
+ const [replyInput, setReplyInput] = useState('');
36
+ const [replyDraft, setReplyDraft] = useState('');
37
+ const [statusMessage, setStatusMessage] = useState('');
38
+ const goNext = useCallback(() => {
39
+ setCurrentIndex((i) => Math.min(i + 1, total - 1));
40
+ setMode('view');
41
+ setReplyDraft('');
42
+ setStatusMessage('');
43
+ }, [total]);
44
+ const goPrev = useCallback(() => {
45
+ setCurrentIndex((i) => Math.max(i - 1, 0));
46
+ setMode('view');
47
+ setReplyDraft('');
48
+ setStatusMessage('');
49
+ }, []);
50
+ const startReply = useCallback(() => {
51
+ setReplyInput('');
52
+ setMode('reply-input');
53
+ }, []);
54
+ const dismissDraft = useCallback(() => {
55
+ setReplyDraft('');
56
+ setMode('view');
57
+ }, []);
58
+ const handleReply = useCallback(async (tweetText, angle) => {
59
+ setMode('reply-loading');
60
+ try {
61
+ const result = await generateReply(tweetText, angle, vendor);
62
+ setReplyDraft(result.reply);
63
+ setMode('reply-draft');
64
+ }
65
+ catch (err) {
66
+ setStatusMessage(`Error: ${err instanceof Error ? err.message : String(err)}`);
67
+ setMode('view');
68
+ }
69
+ }, [vendor]);
70
+ return {
71
+ currentIndex,
72
+ mode,
73
+ replyInput,
74
+ replyDraft,
75
+ statusMessage,
76
+ setReplyInput,
77
+ setMode,
78
+ setStatusMessage,
79
+ goNext,
80
+ goPrev,
81
+ startReply,
82
+ dismissDraft,
83
+ handleReply,
84
+ };
85
+ }
86
+ export function InteractiveFeedSession({ items, vendor }) {
87
+ const { stdout } = useStdout();
88
+ const termWidth = stdout.columns ?? 100;
89
+ const cardWidth = getFeedWidth();
90
+ const { currentIndex, mode, replyInput, replyDraft, statusMessage, setReplyInput, setMode, setStatusMessage, goNext, goPrev, startReply, dismissDraft, handleReply, } = useInteractiveState(items.length, vendor);
91
+ const current = items[currentIndex];
92
+ useInput((input, key) => {
93
+ if (mode === 'reply-loading')
94
+ return;
95
+ if (mode === 'reply-input') {
96
+ if (key.return) {
97
+ handleReply(current.tweet.text, replyInput);
98
+ }
99
+ else if (key.escape) {
100
+ setMode('view');
101
+ setReplyInput('');
102
+ }
103
+ else if (key.backspace || key.delete) {
104
+ setReplyInput((s) => s.slice(0, -1));
105
+ }
106
+ else if (input && !key.ctrl && !key.meta) {
107
+ setReplyInput((s) => s + input);
108
+ }
109
+ return;
110
+ }
111
+ if (mode === 'reply-draft') {
112
+ if (input === 'r') {
113
+ handleReply(current.tweet.text, '');
114
+ }
115
+ else if (key.escape) {
116
+ dismissDraft();
117
+ }
118
+ return;
119
+ }
120
+ // view mode
121
+ if (input === 'n' || key.rightArrow || input === ' ') {
122
+ goNext();
123
+ }
124
+ else if (input === 'p' || key.leftArrow) {
125
+ goPrev();
126
+ }
127
+ else if (input === 'r') {
128
+ startReply();
129
+ }
130
+ else if (input === 's') {
131
+ setStatusMessage('star — coming soon');
132
+ }
133
+ else if (input === 'a') {
134
+ setStatusMessage('analyze — coming soon');
135
+ }
136
+ else if (input === 'q') {
137
+ process.exit(0);
138
+ }
139
+ }, { isActive: mode !== 'reply-loading' });
140
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { dimColor: true, children: [' ', currentIndex + 1, " / ", items.length, ' · ', "feed --interactive"] }) }), _jsx(TweetCard, { item: current, termWidth: termWidth, cardWidth: cardWidth, isLast: true }), mode === 'reply-draft' && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Divider, { width: termWidth }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "Draft reply:" }), _jsx(Box, { marginTop: 1, paddingLeft: 2, width: Math.min(termWidth, 80), children: _jsx(Text, { wrap: "wrap", children: replyDraft }) })] })] })), statusMessage && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "green", children: statusMessage }) })), _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Divider, { width: termWidth }), mode === 'reply-input' ? (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { dimColor: true, children: "Angle (Enter to auto-generate, Esc to cancel): " }), _jsx(Text, { children: replyInput }), _jsx(Text, { color: "cyan", children: "\u2588" })] })) : mode === 'reply-loading' ? (_jsx(Box, { marginTop: 1, children: _jsx(Spinner, { label: "Generating reply..." }) })) : mode === 'reply-draft' ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "[r] new draft [Esc] dismiss" }) })) : (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "[n]ext [p]rev [s]tar [r]eply [a]nalyze [q]uit" }) }))] })] }));
141
+ }
142
+ // ─── Suggestion Card ──────────────────────────────────────────────────────────
143
+ function scoreColor(score) {
144
+ if (score >= 0.7)
145
+ return 'green';
146
+ if (score >= 0.4)
147
+ return 'yellow';
148
+ return 'white';
149
+ }
150
+ function statusColor(status) {
151
+ switch (status.toLowerCase()) {
152
+ case 'inbox': return 'cyan';
153
+ case 'read': return 'green';
154
+ case 'skipped': return 'gray';
155
+ case 'later': return 'yellow';
156
+ case 'archived': return 'magenta';
157
+ default: return 'white';
158
+ }
159
+ }
160
+ function SuggestionCard({ item, termWidth }) {
161
+ const handle = item.tweet.user.username ?? item.tweet.user.displayName;
162
+ const author = `@${handle}`;
163
+ const profileUrl = `https://x.com/${handle}`;
164
+ const tweetUrl = `https://x.com/${handle}/status/${item.tweet.xid}`;
165
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: "cyan", bold: true, children: relativeTime(item.tweet.createdAt) }), _jsx(Text, { dimColor: true, children: " \u00B7 " }), _jsx(Text, { color: scoreColor(item.score), children: item.score.toFixed(2) }), _jsx(Text, { dimColor: true, children: " \u00B7 " }), _jsx(Text, { color: statusColor(item.status), children: item.status.toLowerCase() }), item.projectsMatched > 0 && (_jsxs(Text, { dimColor: true, children: [" \u00B7 ", item.projectsMatched, " interest", item.projectsMatched !== 1 ? 's' : ''] }))] }), _jsxs(Box, { children: [_jsxs(Text, { color: "gray", children: ['└', " "] }), _jsx(Text, { color: "blueBright", bold: true, children: author })] }), _jsx(Box, { paddingLeft: 2, width: Math.min(termWidth, 82), marginTop: 1, children: _jsx(Text, { wrap: "wrap", children: item.tweet.text }) }), _jsx(Box, { marginLeft: 2, marginTop: 1, children: _jsxs(Text, { dimColor: true, children: [profileUrl, " \u00B7 ", tweetUrl] }) })] }));
166
+ }
167
+ const INBOX_STATUS_KEYS = {
168
+ R: 'READ',
169
+ S: 'SKIPPED',
170
+ L: 'LATER',
171
+ A: 'ARCHIVED',
172
+ };
173
+ export function InteractiveInboxSession({ items, vendor }) {
174
+ const { stdout } = useStdout();
175
+ const termWidth = stdout.columns ?? 100;
176
+ const [isActing, setIsActing] = useState(false);
177
+ const { currentIndex, mode, replyInput, replyDraft, statusMessage, setReplyInput, setMode, setStatusMessage, goNext, goPrev, startReply, dismissDraft, handleReply, } = useInteractiveState(items.length, vendor);
178
+ const current = items[currentIndex];
179
+ const handleStatusUpdate = useCallback(async (status) => {
180
+ setIsActing(true);
181
+ try {
182
+ await gql(UPDATE_SUGGESTION_MUTATION, { suggestionId: current.suggestionId, status });
183
+ setStatusMessage(`✓ marked as ${status.toLowerCase()}`);
184
+ }
185
+ catch (err) {
186
+ setStatusMessage(`Error: ${err instanceof Error ? err.message : String(err)}`);
187
+ }
188
+ finally {
189
+ setIsActing(false);
190
+ }
191
+ }, [current.suggestionId, setStatusMessage]);
192
+ useInput((input, key) => {
193
+ if (isActing || mode === 'reply-loading')
194
+ return;
195
+ if (mode === 'reply-input') {
196
+ if (key.return) {
197
+ handleReply(current.tweet.text, replyInput);
198
+ }
199
+ else if (key.escape) {
200
+ setMode('view');
201
+ setReplyInput('');
202
+ }
203
+ else if (key.backspace || key.delete) {
204
+ setReplyInput((s) => s.slice(0, -1));
205
+ }
206
+ else if (input && !key.ctrl && !key.meta) {
207
+ setReplyInput((s) => s + input);
208
+ }
209
+ return;
210
+ }
211
+ if (mode === 'reply-draft') {
212
+ if (input === 'r') {
213
+ handleReply(current.tweet.text, '');
214
+ }
215
+ else if (key.escape) {
216
+ dismissDraft();
217
+ }
218
+ return;
219
+ }
220
+ // view mode
221
+ if (input === 'n' || key.rightArrow || input === ' ') {
222
+ goNext();
223
+ }
224
+ else if (input === 'p' || key.leftArrow) {
225
+ goPrev();
226
+ }
227
+ else if (input === 'r') {
228
+ startReply();
229
+ }
230
+ else if (input === 'a') {
231
+ setStatusMessage('analyze — coming soon');
232
+ }
233
+ else if (input === 'q') {
234
+ process.exit(0);
235
+ }
236
+ else if (INBOX_STATUS_KEYS[input]) {
237
+ handleStatusUpdate(INBOX_STATUS_KEYS[input]);
238
+ }
239
+ }, { isActive: !isActing && mode !== 'reply-loading' });
240
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { dimColor: true, children: [' ', currentIndex + 1, " / ", items.length, ' · ', "inbox --interactive"] }) }), _jsx(SuggestionCard, { item: current, termWidth: termWidth }), mode === 'reply-draft' && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Divider, { width: termWidth }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "Draft reply:" }), _jsx(Box, { marginTop: 1, paddingLeft: 2, width: Math.min(termWidth, 80), children: _jsx(Text, { wrap: "wrap", children: replyDraft }) })] })] })), statusMessage && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "green", children: statusMessage }) })), _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Divider, { width: termWidth }), isActing ? (_jsx(Box, { marginTop: 1, children: _jsx(Spinner, { label: "Updating..." }) })) : mode === 'reply-input' ? (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { dimColor: true, children: "Angle (Enter to auto-generate, Esc to cancel): " }), _jsx(Text, { children: replyInput }), _jsx(Text, { color: "cyan", children: "\u2588" })] })) : mode === 'reply-loading' ? (_jsx(Box, { marginTop: 1, children: _jsx(Spinner, { label: "Generating reply..." }) })) : mode === 'reply-draft' ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "[r] new draft [Esc] dismiss" }) })) : (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "[n]ext [p]rev [r]eply [a]nalyze [R]ead [S]kip [L]ater [A]rchive [q]uit" }) }))] })] }));
241
+ }