@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,10 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ export function InterestCard({ interest, termWidth, isLast }) {
4
+ const updatedAt = new Date(interest.updatedAt).toLocaleDateString('en-US', {
5
+ month: 'short',
6
+ day: 'numeric',
7
+ year: 'numeric',
8
+ });
9
+ return (_jsxs(Box, { flexDirection: "column", marginBottom: isLast ? 0 : 1, width: termWidth, children: [_jsxs(Box, { children: [_jsx(Text, { color: "cyan", bold: true, children: interest.name }), _jsxs(Text, { dimColor: true, children: [" v", interest.version, " \u00B7 ", interest.id, " \u00B7 ", updatedAt] })] }), interest.description && (_jsxs(Box, { children: [_jsxs(Text, { color: "gray", children: ['└', " "] }), _jsx(Text, { wrap: "wrap", children: interest.description })] })), interest.keywords && interest.keywords.length > 0 && (_jsxs(Box, { marginLeft: 2, children: [_jsx(Text, { dimColor: true, children: "keywords " }), _jsx(Text, { color: "yellow", children: interest.keywords.join(' ') })] })), interest.relatedTopics && interest.relatedTopics.length > 0 && (_jsxs(Box, { marginLeft: 2, children: [_jsx(Text, { dimColor: true, children: "topics " }), _jsx(Text, { children: interest.relatedTopics.join(' ') })] })), !isLast && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: '─'.repeat(Math.min(termWidth - 2, 72)) }) }))] }));
10
+ }
@@ -0,0 +1,5 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Text } from 'ink';
3
+ export function RefreshTip() {
4
+ return (_jsxs(Text, { dimColor: true, children: ["Tip: run ", _jsx(Text, { color: "cyan", children: "sonar monitor --watch" }), " to monitor progress."] }));
5
+ }
@@ -0,0 +1,14 @@
1
+ import { jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState, useEffect } from 'react';
3
+ import { Text } from 'ink';
4
+ const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
5
+ export function Spinner({ label }) {
6
+ const [frame, setFrame] = useState(0);
7
+ useEffect(() => {
8
+ const timer = setInterval(() => {
9
+ setFrame((f) => (f + 1) % FRAMES.length);
10
+ }, 80);
11
+ return () => clearInterval(timer);
12
+ }, []);
13
+ return (_jsxs(Text, { children: [_jsxs(Text, { color: "cyan", children: [FRAMES[frame], " "] }), label ?? 'Loading...'] }));
14
+ }
@@ -0,0 +1,23 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ export function Table({ rows, columns }) {
4
+ if (rows.length === 0) {
5
+ return _jsx(Text, { dimColor: true, children: "No results." });
6
+ }
7
+ const cols = columns ?? Object.keys(rows[0]);
8
+ // Calculate column widths
9
+ const widths = {};
10
+ for (const col of cols) {
11
+ widths[col] = col.length;
12
+ }
13
+ for (const row of rows) {
14
+ for (const col of cols) {
15
+ const val = String(row[col] ?? '');
16
+ if (val.length > widths[col]) {
17
+ widths[col] = val.length;
18
+ }
19
+ }
20
+ }
21
+ const pad = (str, width) => str.padEnd(width);
22
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { children: cols.map((col, i) => (_jsx(Box, { marginRight: i < cols.length - 1 ? 2 : 0, children: _jsx(Text, { bold: true, color: "cyan", children: pad(col.toUpperCase(), widths[col]) }) }, col))) }), _jsx(Box, { children: _jsx(Text, { dimColor: true, children: cols.map((col) => '─'.repeat(widths[col])).join(' ') }) }), rows.map((row, i) => (_jsx(Box, { children: cols.map((col, j) => (_jsx(Box, { marginRight: j < cols.length - 1 ? 2 : 0, children: _jsx(Text, { children: pad(String(row[col] ?? ''), widths[col]) }) }, col))) }, i)))] }));
23
+ }
package/dist/lib/ai.js ADDED
@@ -0,0 +1,160 @@
1
+ function extractJSON(text) {
2
+ // Strip markdown fences if present
3
+ const stripped = text.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/i, '').trim();
4
+ // Find the outermost JSON object in case there's surrounding prose
5
+ const start = stripped.indexOf('{');
6
+ const end = stripped.lastIndexOf('}');
7
+ if (start === -1 || end === -1)
8
+ throw new Error('No JSON object found in response');
9
+ return stripped.slice(start, end + 1);
10
+ }
11
+ const SYSTEM_PROMPT = `You generate structured interest profiles for a social intelligence tool. These profiles are embedded into a vector database and matched against tweets and people on X (Twitter). Every field must be optimised for semantic similarity search — not for human reading.
12
+
13
+ Before generating the profile, research what is currently topical and actively being discussed in this space: recent releases, emerging tools, ongoing debates, notable events, new protocols, and people generating buzz right now. Weave this current signal throughout every field.
14
+
15
+ Given a user's prompt, expand it into a rich interest profile and return a JSON object with exactly these fields:
16
+
17
+ - name: short, specific interest name (3-6 words, title case)
18
+ - description: a dense, jargon-rich passage written in the voice of a practitioner deeply embedded in this space. Do NOT describe or summarise the interest — instead write AS IF you are someone active in this community right now. Pack it with domain-specific terminology, key concepts, tools, protocols, notable figures, current debates, and recent developments. Reference what is actively being discussed and shipped today. Think: what would a knowledgeable tweet thread about this topic sound like this week? This is the most important field for vector matching.
19
+ - keywords: 12-20 specific, high-signal terms used by practitioners. Include:
20
+ - Core technical terms and jargon
21
+ - Key tools, frameworks, protocols, or products — especially recently launched or trending ones
22
+ - Community hashtags (without #)
23
+ - Names of people, projects, or companies actively discussed right now
24
+ - Abbreviations alongside their full forms
25
+ - relatedTopics: 6-10 adjacent topic areas practitioners in this space also commonly engage with right now. Used for second-degree discovery.
26
+
27
+ Optimise every field for semantic density and current relevance, not readability.
28
+
29
+ Respond ONLY with valid JSON, no markdown, no explanation.`;
30
+ async function callOpenAI(prompt, apiKey) {
31
+ const res = await fetch('https://api.openai.com/v1/responses', {
32
+ method: 'POST',
33
+ headers: {
34
+ 'Content-Type': 'application/json',
35
+ Authorization: `Bearer ${apiKey}`,
36
+ },
37
+ body: JSON.stringify({
38
+ model: 'gpt-4o',
39
+ tools: [{ type: 'web_search_preview' }],
40
+ instructions: SYSTEM_PROMPT,
41
+ input: prompt,
42
+ }),
43
+ });
44
+ if (!res.ok) {
45
+ const err = await res.json().catch(() => ({}));
46
+ throw new Error(`OpenAI error: ${err?.error?.message ?? res.status}`);
47
+ }
48
+ const data = await res.json();
49
+ const text = data.output
50
+ ?.filter((b) => b.type === 'message')
51
+ .flatMap((b) => b.content)
52
+ .filter((c) => c.type === 'output_text')
53
+ .map((c) => c.text)
54
+ .join('') ?? '';
55
+ return JSON.parse(extractJSON(text));
56
+ }
57
+ async function callAnthropic(prompt, apiKey) {
58
+ const res = await fetch('https://api.anthropic.com/v1/messages', {
59
+ method: 'POST',
60
+ headers: {
61
+ 'Content-Type': 'application/json',
62
+ 'x-api-key': apiKey,
63
+ 'anthropic-version': '2023-06-01',
64
+ },
65
+ body: JSON.stringify({
66
+ model: 'claude-haiku-4-5-20251001',
67
+ max_tokens: 1024,
68
+ system: SYSTEM_PROMPT,
69
+ messages: [{ role: 'user', content: prompt }],
70
+ }),
71
+ });
72
+ if (!res.ok) {
73
+ const err = await res.json().catch(() => ({}));
74
+ throw new Error(`Anthropic error: ${err?.error?.message ?? res.status}`);
75
+ }
76
+ const data = await res.json();
77
+ return JSON.parse(extractJSON(data.content[0].text));
78
+ }
79
+ const REPLY_SYSTEM_PROMPT = `You are a concise, contextual tweet reply writer. Write a single reply tweet (max 280 characters) that is natural, engaging, and relevant to the original tweet. Do not use hashtags unless essential. Do not include any explanation or preamble. Return only valid JSON with a single "reply" field containing the reply text.`;
80
+ async function callOpenAIReply(tweetText, userPrompt, apiKey) {
81
+ const userContent = userPrompt
82
+ ? `Original tweet: "${tweetText}"\n\nAngle for reply: ${userPrompt}`
83
+ : `Original tweet: "${tweetText}"\n\nWrite a thoughtful reply.`;
84
+ const res = await fetch('https://api.openai.com/v1/chat/completions', {
85
+ method: 'POST',
86
+ headers: {
87
+ 'Content-Type': 'application/json',
88
+ Authorization: `Bearer ${apiKey}`,
89
+ },
90
+ body: JSON.stringify({
91
+ model: 'gpt-4o',
92
+ messages: [
93
+ { role: 'system', content: REPLY_SYSTEM_PROMPT },
94
+ { role: 'user', content: userContent },
95
+ ],
96
+ }),
97
+ });
98
+ if (!res.ok) {
99
+ const err = await res.json().catch(() => ({}));
100
+ throw new Error(`OpenAI error: ${err?.error?.message ?? res.status}`);
101
+ }
102
+ const data = await res.json();
103
+ const text = data.choices?.[0]?.message?.content ?? '';
104
+ return JSON.parse(extractJSON(text));
105
+ }
106
+ async function callAnthropicReply(tweetText, userPrompt, apiKey) {
107
+ const userContent = userPrompt
108
+ ? `Original tweet: "${tweetText}"\n\nAngle for reply: ${userPrompt}`
109
+ : `Original tweet: "${tweetText}"\n\nWrite a thoughtful reply.`;
110
+ const res = await fetch('https://api.anthropic.com/v1/messages', {
111
+ method: 'POST',
112
+ headers: {
113
+ 'Content-Type': 'application/json',
114
+ 'x-api-key': apiKey,
115
+ 'anthropic-version': '2023-06-01',
116
+ },
117
+ body: JSON.stringify({
118
+ model: 'claude-haiku-4-5-20251001',
119
+ max_tokens: 512,
120
+ system: REPLY_SYSTEM_PROMPT,
121
+ messages: [{ role: 'user', content: userContent }],
122
+ }),
123
+ });
124
+ if (!res.ok) {
125
+ const err = await res.json().catch(() => ({}));
126
+ throw new Error(`Anthropic error: ${err?.error?.message ?? res.status}`);
127
+ }
128
+ const data = await res.json();
129
+ return JSON.parse(extractJSON(data.content[0].text));
130
+ }
131
+ export async function generateReply(tweetText, userPrompt, vendor) {
132
+ if (vendor === 'openai') {
133
+ const apiKey = process.env.OPENAI_API_KEY;
134
+ if (!apiKey)
135
+ throw new Error('OPENAI_API_KEY is not set');
136
+ return callOpenAIReply(tweetText, userPrompt, apiKey);
137
+ }
138
+ if (vendor === 'anthropic') {
139
+ const apiKey = process.env.ANTHROPIC_API_KEY;
140
+ if (!apiKey)
141
+ throw new Error('ANTHROPIC_API_KEY is not set');
142
+ return callAnthropicReply(tweetText, userPrompt, apiKey);
143
+ }
144
+ throw new Error(`Unknown vendor: ${vendor}. Supported: openai, anthropic`);
145
+ }
146
+ export async function generateInterest(prompt, vendor) {
147
+ if (vendor === 'openai') {
148
+ const apiKey = process.env.OPENAI_API_KEY;
149
+ if (!apiKey)
150
+ throw new Error('OPENAI_API_KEY is not set');
151
+ return callOpenAI(prompt, apiKey);
152
+ }
153
+ if (vendor === 'anthropic') {
154
+ const apiKey = process.env.ANTHROPIC_API_KEY;
155
+ if (!apiKey)
156
+ throw new Error('ANTHROPIC_API_KEY is not set');
157
+ return callAnthropic(prompt, apiKey);
158
+ }
159
+ throw new Error(`Unknown vendor: ${vendor}. Supported: openai, anthropic`);
160
+ }
@@ -0,0 +1,33 @@
1
+ import { getApiUrl, getToken } from './config.js';
2
+ export async function gql(query, variables = {}, flags = {}) {
3
+ const token = getToken();
4
+ const url = getApiUrl();
5
+ let res;
6
+ try {
7
+ if (flags.debug) {
8
+ console.error(url, query, variables);
9
+ }
10
+ res = await fetch(url, {
11
+ method: 'POST',
12
+ headers: {
13
+ 'Content-Type': 'application/json',
14
+ Authorization: `Bearer ${token}`,
15
+ },
16
+ body: JSON.stringify({ query, variables }),
17
+ });
18
+ }
19
+ catch {
20
+ throw new Error('Unable to reach server, please try again shortly.');
21
+ }
22
+ if (!res.ok) {
23
+ if (flags.debug) {
24
+ console.error(JSON.stringify(await res.json(), null, 2));
25
+ }
26
+ throw new Error(`HTTP ${res.status}: ${res.statusText}`);
27
+ }
28
+ const json = (await res.json());
29
+ if (json.errors && json.errors.length > 0) {
30
+ throw new Error(json.errors[0].message);
31
+ }
32
+ return json.data;
33
+ }
@@ -0,0 +1,74 @@
1
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ const CONFIG_DIR = join(homedir(), '.sonar');
5
+ const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
6
+ export function readConfig() {
7
+ try {
8
+ const raw = readFileSync(CONFIG_FILE, 'utf8');
9
+ return JSON.parse(raw);
10
+ }
11
+ catch {
12
+ return {
13
+ token: '',
14
+ apiUrl: process.env.SONAR_API_URL ?? 'https://api.sonar.8640p.info/graphql',
15
+ };
16
+ }
17
+ }
18
+ export function configExists() {
19
+ return existsSync(CONFIG_FILE);
20
+ }
21
+ export function deleteConfig() {
22
+ if (configExists()) {
23
+ unlinkSync(CONFIG_FILE);
24
+ }
25
+ }
26
+ export function deleteDatabase() {
27
+ if (existsSync(join(CONFIG_DIR, 'database.sqlite'))) {
28
+ unlinkSync(join(CONFIG_DIR, 'database.sqlite'));
29
+ }
30
+ }
31
+ export function writeConfig(config) {
32
+ const current = readConfig();
33
+ const updated = { ...current, ...config };
34
+ mkdirSync(CONFIG_DIR, { recursive: true });
35
+ writeFileSync(CONFIG_FILE, JSON.stringify(updated, null, 2), 'utf8');
36
+ }
37
+ export function getToken() {
38
+ // SONAR_API_KEY env var takes highest priority
39
+ const apiKey = process.env.SONAR_API_KEY;
40
+ if (apiKey)
41
+ return apiKey;
42
+ // Fall back to config file token
43
+ const config = readConfig();
44
+ if (config.token)
45
+ return config.token;
46
+ process.stderr.write('No token found. Set SONAR_API_KEY or run: sonar config setup\n');
47
+ process.exit(1);
48
+ }
49
+ export function getApiUrl() {
50
+ const config = readConfig();
51
+ return (process.env.SONAR_API_URL ??
52
+ config.apiUrl ??
53
+ 'https://api.sonar.8640p.info/graphql');
54
+ }
55
+ export function getFeedRender(override) {
56
+ return (override ??
57
+ process.env.SONAR_FEED_RENDER ??
58
+ readConfig().feedRender ??
59
+ 'card');
60
+ }
61
+ export function getFeedWidth(override) {
62
+ const env = process.env.SONAR_FEED_WIDTH
63
+ ? Number(process.env.SONAR_FEED_WIDTH)
64
+ : undefined;
65
+ return override ?? env ?? readConfig().feedWidth ?? 80;
66
+ }
67
+ export function getVendor(override) {
68
+ const vendor = override ?? process.env.SONAR_AI_VENDOR ?? readConfig().vendor ?? 'openai';
69
+ if (vendor !== 'openai' && vendor !== 'anthropic') {
70
+ process.stderr.write(`Unknown vendor "${vendor}". Supported: openai, anthropic\n`);
71
+ process.exit(1);
72
+ }
73
+ return vendor;
74
+ }
@@ -0,0 +1,61 @@
1
+ export const FEED_QUERY = `
2
+ query DataFeed($hours: Int, $days: Int, $limit: Int) {
3
+ feed(hours: $hours, days: $days, limit: $limit) {
4
+ score
5
+ matchedKeywords
6
+ tweet {
7
+ id
8
+ xid
9
+ text
10
+ createdAt
11
+ likeCount
12
+ retweetCount
13
+ replyCount
14
+ user {
15
+ displayName
16
+ username
17
+ followersCount
18
+ followingCount
19
+ }
20
+ }
21
+ }
22
+ }
23
+ `;
24
+ export const SUGGESTIONS_QUERY = `
25
+ query DataSuggestions($status: SuggestionStatus, $limit: Int) {
26
+ suggestions(status: $status, limit: $limit) {
27
+ suggestionId
28
+ score
29
+ projectsMatched
30
+ status
31
+ tweet {
32
+ id
33
+ xid
34
+ text
35
+ createdAt
36
+ likeCount
37
+ retweetCount
38
+ replyCount
39
+ user {
40
+ displayName
41
+ username
42
+ followersCount
43
+ followingCount
44
+ }
45
+ }
46
+ }
47
+ }
48
+ `;
49
+ export const INTERESTS_QUERY = `
50
+ query DataInterests {
51
+ projects {
52
+ id: nanoId
53
+ name
54
+ description
55
+ keywords
56
+ relatedTopics
57
+ createdAt
58
+ updatedAt
59
+ }
60
+ }
61
+ `;
package/dist/lib/db.js ADDED
@@ -0,0 +1,73 @@
1
+ import Database from 'better-sqlite3';
2
+ import { mkdirSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { join, dirname } from 'node:path';
5
+ export const DB_PATH = join(homedir(), '.sonar', 'data.db');
6
+ export function openDb() {
7
+ mkdirSync(dirname(DB_PATH), { recursive: true });
8
+ const db = new Database(DB_PATH);
9
+ db.exec(`
10
+ CREATE TABLE IF NOT EXISTS tweets (
11
+ id TEXT PRIMARY KEY,
12
+ xid TEXT, text TEXT, created_at TEXT,
13
+ like_count INTEGER, retweet_count INTEGER, reply_count INTEGER,
14
+ author_username TEXT, author_display_name TEXT,
15
+ author_followers_count INTEGER, author_following_count INTEGER
16
+ );
17
+ CREATE TABLE IF NOT EXISTS feed_items (
18
+ tweet_id TEXT PRIMARY KEY, score REAL,
19
+ matched_keywords TEXT,
20
+ synced_at TEXT
21
+ );
22
+ CREATE TABLE IF NOT EXISTS suggestions (
23
+ suggestion_id TEXT PRIMARY KEY, tweet_id TEXT, score REAL,
24
+ status TEXT, relevance TEXT,
25
+ projects_matched TEXT,
26
+ metadata TEXT,
27
+ synced_at TEXT
28
+ );
29
+ CREATE TABLE IF NOT EXISTS interests (
30
+ id TEXT PRIMARY KEY, name TEXT, description TEXT,
31
+ keywords TEXT, topics TEXT,
32
+ created_at TEXT, updated_at TEXT, synced_at TEXT
33
+ );
34
+ CREATE TABLE IF NOT EXISTS sync_state (
35
+ key TEXT PRIMARY KEY, value TEXT
36
+ );
37
+ `);
38
+ return db;
39
+ }
40
+ export function upsertTweet(db, tweet) {
41
+ db.prepare(`
42
+ INSERT OR REPLACE INTO tweets
43
+ (id, xid, text, created_at, like_count, retweet_count, reply_count,
44
+ author_username, author_display_name, author_followers_count, author_following_count)
45
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
46
+ `).run(tweet.id, tweet.xid, tweet.text, tweet.createdAt, tweet.likeCount, tweet.retweetCount, tweet.replyCount, tweet.user.username, tweet.user.displayName, tweet.user.followersCount, tweet.user.followingCount);
47
+ }
48
+ export function upsertFeedItem(db, item) {
49
+ db.prepare(`
50
+ INSERT OR REPLACE INTO feed_items (tweet_id, score, matched_keywords, synced_at)
51
+ VALUES (?, ?, ?, ?)
52
+ `).run(item.tweetId, item.score, JSON.stringify(item.matchedKeywords), new Date().toISOString());
53
+ }
54
+ export function upsertSuggestion(db, s) {
55
+ db.prepare(`
56
+ INSERT OR REPLACE INTO suggestions
57
+ (suggestion_id, tweet_id, score, status, relevance, projects_matched, metadata, synced_at)
58
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
59
+ `).run(s.suggestionId, s.tweetId, s.score, s.status, s.relevance, JSON.stringify(s.projectsMatched), s.metadata != null ? JSON.stringify(s.metadata) : null, new Date().toISOString());
60
+ }
61
+ export function upsertInterest(db, interest) {
62
+ db.prepare(`
63
+ INSERT OR REPLACE INTO interests (id, name, description, keywords, topics, created_at, updated_at, synced_at)
64
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
65
+ `).run(interest.id, interest.name, interest.description, JSON.stringify(interest.keywords ?? []), JSON.stringify(interest.relatedTopics ?? []), interest.createdAt, interest.updatedAt, new Date().toISOString());
66
+ }
67
+ export function getSyncState(db, key) {
68
+ const row = db.prepare('SELECT value FROM sync_state WHERE key = ?').get(key);
69
+ return row?.value ?? null;
70
+ }
71
+ export function setSyncState(db, key, value) {
72
+ db.prepare('INSERT OR REPLACE INTO sync_state (key, value) VALUES (?, ?)').run(key, value);
73
+ }