opensciencenet-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.
package/bin/os-cli.js ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import('../dist/index.js')
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "opensciencenet-cli",
3
+ "version": "0.1.0",
4
+ "description": "Mining CLI for the OpenScienceNet Proof of Useful Work platform",
5
+ "type": "module",
6
+ "bin": {
7
+ "os-cli": "bin/os-cli.js"
8
+ },
9
+ "scripts": {
10
+ "build": "tsup",
11
+ "dev": "tsx src/index.tsx",
12
+ "typecheck": "tsc --noEmit"
13
+ },
14
+ "engines": {
15
+ "node": ">=18"
16
+ },
17
+ "dependencies": {
18
+ "react": "^18.3.1",
19
+ "ink": "^5.1.0",
20
+ "ink-text-input": "^6.0.0",
21
+ "ink-spinner": "^5.0.0",
22
+ "chalk": "^5.4.1",
23
+ "@anthropic-ai/sdk": "^0.39.0",
24
+ "openai": "^4.80.0"
25
+ },
26
+ "devDependencies": {
27
+ "@types/react": "^18.3.0",
28
+ "typescript": "^5.7.0",
29
+ "tsup": "^8.4.0",
30
+ "tsx": "^4.19.0",
31
+ "@types/node": "^22.0.0"
32
+ }
33
+ }
package/src/api.ts ADDED
@@ -0,0 +1,33 @@
1
+ import { loadConfig, getToken } from './config.js'
2
+
3
+ type RequestOptions = {
4
+ method?: string
5
+ body?: unknown
6
+ auth?: boolean
7
+ }
8
+
9
+ export async function api<T = unknown>(path: string, opts: RequestOptions = {}): Promise<T> {
10
+ const config = loadConfig()
11
+ const headers: Record<string, string> = { 'Content-Type': 'application/json' }
12
+ if (opts.auth !== false) {
13
+ headers['Authorization'] = `Bearer ${getToken()}`
14
+ }
15
+
16
+ const resp = await fetch(`${config.server_url}${path}`, {
17
+ method: opts.method || (opts.body ? 'POST' : 'GET'),
18
+ headers,
19
+ body: opts.body ? JSON.stringify(opts.body) : undefined,
20
+ })
21
+
22
+ const data = await resp.json() as T & { detail?: string }
23
+
24
+ if (!resp.ok) {
25
+ throw new Error(data.detail || `HTTP ${resp.status}`)
26
+ }
27
+
28
+ return data
29
+ }
30
+
31
+ export async function apiNoAuth<T = unknown>(path: string, opts: RequestOptions = {}): Promise<T> {
32
+ return api<T>(path, { ...opts, auth: false })
33
+ }
@@ -0,0 +1,169 @@
1
+ import React, { useState, useEffect } from 'react'
2
+ import { Box, Text, useApp } from 'ink'
3
+ import TextInput from 'ink-text-input'
4
+ import { Header } from '../components/Header.js'
5
+ import { StatusIcon } from '../components/StatusIcon.js'
6
+ import { Table } from '../components/Table.js'
7
+ import { theme } from '../theme.js'
8
+ import { api, apiNoAuth } from '../api.js'
9
+ import { saveConfig, loadConfig } from '../config.js'
10
+
11
+ // --- Register ---
12
+
13
+ export function Register() {
14
+ const { exit } = useApp()
15
+ const [step, setStep] = useState<'username' | 'email' | 'password' | 'done'>('username')
16
+ const [username, setUsername] = useState('')
17
+ const [email, setEmail] = useState('')
18
+ const [password, setPassword] = useState('')
19
+ const [result, setResult] = useState<string | null>(null)
20
+ const [error, setError] = useState<string | null>(null)
21
+
22
+ const handleSubmit = async (val: string) => {
23
+ if (step === 'username') { setUsername(val); setStep('email') }
24
+ else if (step === 'email') { setEmail(val); setStep('password') }
25
+ else if (step === 'password') {
26
+ setPassword(val)
27
+ try {
28
+ const data = await apiNoAuth<any>('/api/auth/register', {
29
+ body: { username, email, password: val },
30
+ })
31
+ setResult(`Account created! You received ${data.balance} OS coins.`)
32
+ } catch (e: any) {
33
+ setError(e.message)
34
+ }
35
+ setStep('done')
36
+ }
37
+ }
38
+
39
+ useEffect(() => { if (step === 'done') setTimeout(() => exit(), 100) }, [step])
40
+
41
+ return (
42
+ <Box flexDirection="column" paddingX={1}>
43
+ <Header title="Register" />
44
+ {step === 'username' && <Box><Text color={theme.brand}>Username: </Text><TextInput value={username} onChange={setUsername} onSubmit={handleSubmit} /></Box>}
45
+ {step === 'email' && <Box><Text color={theme.brand}>Email: </Text><TextInput value={email} onChange={setEmail} onSubmit={handleSubmit} /></Box>}
46
+ {step === 'password' && <Box><Text color={theme.brand}>Password: </Text><TextInput value={password} onChange={setPassword} onSubmit={handleSubmit} mask="*" /></Box>}
47
+ {result && <Text><StatusIcon status="success" /> <Text color={theme.success}>{result}</Text></Text>}
48
+ {error && <Text><StatusIcon status="error" /> <Text color={theme.error}>{error}</Text></Text>}
49
+ </Box>
50
+ )
51
+ }
52
+
53
+ // --- Login ---
54
+
55
+ export function Login() {
56
+ const { exit } = useApp()
57
+ const [step, setStep] = useState<'email' | 'password' | 'done'>('email')
58
+ const [email, setEmail] = useState('')
59
+ const [password, setPassword] = useState('')
60
+ const [result, setResult] = useState<string | null>(null)
61
+ const [error, setError] = useState<string | null>(null)
62
+
63
+ const handleSubmit = async (val: string) => {
64
+ if (step === 'email') { setEmail(val); setStep('password') }
65
+ else if (step === 'password') {
66
+ try {
67
+ const data = await apiNoAuth<any>('/api/auth/login', {
68
+ body: { email, password: val },
69
+ })
70
+ saveConfig({ auth_token: data.access_token })
71
+ setResult('Logged in successfully.')
72
+ } catch (e: any) {
73
+ setError(e.message)
74
+ }
75
+ setStep('done')
76
+ }
77
+ }
78
+
79
+ useEffect(() => { if (step === 'done') setTimeout(() => exit(), 100) }, [step])
80
+
81
+ return (
82
+ <Box flexDirection="column" paddingX={1}>
83
+ <Header title="Login" />
84
+ {step === 'email' && <Box><Text color={theme.brand}>Email: </Text><TextInput value={email} onChange={setEmail} onSubmit={handleSubmit} /></Box>}
85
+ {step === 'password' && <Box><Text color={theme.brand}>Password: </Text><TextInput value={password} onChange={setPassword} onSubmit={handleSubmit} mask="*" /></Box>}
86
+ {result && <Text><StatusIcon status="success" /> <Text color={theme.success}>{result}</Text></Text>}
87
+ {error && <Text><StatusIcon status="error" /> <Text color={theme.error}>{error}</Text></Text>}
88
+ </Box>
89
+ )
90
+ }
91
+
92
+ // --- Balance ---
93
+
94
+ export function Balance() {
95
+ const { exit } = useApp()
96
+ const [data, setData] = useState<any>(null)
97
+
98
+ useEffect(() => {
99
+ api<any>('/api/auth/me').then(setData).finally(() => setTimeout(() => exit(), 100))
100
+ }, [])
101
+
102
+ if (!data) return <Text dimColor>Loading...</Text>
103
+ return (
104
+ <Box paddingX={1}>
105
+ <Text bold>{data.username}</Text>
106
+ <Text>: </Text>
107
+ <Text color={theme.success} bold>{data.balance.toLocaleString()} OS</Text>
108
+ </Box>
109
+ )
110
+ }
111
+
112
+ // --- History ---
113
+
114
+ export function History({ limit = 20 }: { limit?: number }) {
115
+ const { exit } = useApp()
116
+ const [txns, setTxns] = useState<any[]>([])
117
+
118
+ useEffect(() => {
119
+ api<any[]>(`/api/transactions?limit=${limit}`).then(setTxns).finally(() => setTimeout(() => exit(), 100))
120
+ }, [])
121
+
122
+ return (
123
+ <Box flexDirection="column" paddingX={1}>
124
+ <Header title="Transaction History" />
125
+ {txns.map((tx, i) => {
126
+ const sign = tx.amount > 0 ? '+' : ''
127
+ const color = tx.amount > 0 ? theme.success : theme.error
128
+ return (
129
+ <Box key={i} gap={1}>
130
+ <Text color={color}>{(sign + tx.amount).padStart(8)} OS</Text>
131
+ <Text dimColor>{tx.type.padEnd(18)}</Text>
132
+ <Text dimColor>{tx.description || ''}</Text>
133
+ </Box>
134
+ )
135
+ })}
136
+ </Box>
137
+ )
138
+ }
139
+
140
+ // --- Stats ---
141
+
142
+ export function Stats() {
143
+ const { exit } = useApp()
144
+ const [user, setUser] = useState<any>(null)
145
+ const [txns, setTxns] = useState<any[]>([])
146
+
147
+ useEffect(() => {
148
+ Promise.all([
149
+ api<any>('/api/auth/me'),
150
+ api<any[]>('/api/transactions?limit=1000'),
151
+ ]).then(([u, t]) => { setUser(u); setTxns(t) }).finally(() => setTimeout(() => exit(), 100))
152
+ }, [])
153
+
154
+ if (!user) return <Text dimColor>Loading...</Text>
155
+
156
+ const earned = txns.filter(t => t.amount > 0 && t.type !== 'admin_grant').reduce((s, t) => s + t.amount, 0)
157
+ const burned = txns.filter(t => t.amount < 0).reduce((s, t) => s + Math.abs(t.amount), 0)
158
+ const improvements = txns.filter(t => ['improvement_reward', 'pool_reward', 'mint_reward'].includes(t.type)).length
159
+
160
+ return (
161
+ <Box flexDirection="column" paddingX={1}>
162
+ <Header title={user.username} subtitle="Mining Stats" />
163
+ <Text>Balance: <Text color={theme.success} bold>{user.balance.toLocaleString()} OS</Text></Text>
164
+ <Text>Total earned: <Text color={theme.success}>{earned.toLocaleString()} OS</Text></Text>
165
+ <Text>Total burned: <Text color={theme.burned}>{burned.toLocaleString()} OS</Text></Text>
166
+ <Text>Improvements: <Text>{improvements}</Text></Text>
167
+ </Box>
168
+ )
169
+ }
@@ -0,0 +1,229 @@
1
+ import React, { useState, useEffect, useCallback } from 'react'
2
+ import { Box, Text, useApp } from 'ink'
3
+ import { Spinner } from '../components/Spinner.js'
4
+ import { RoundResult } from '../components/RoundResult.js'
5
+ import { Header } from '../components/Header.js'
6
+ import { theme } from '../theme.js'
7
+ import { api } from '../api.js'
8
+ import { buildPrompt, callLLM } from '../llm.js'
9
+ import { loadSession, saveSession, deleteSession } from '../session.js'
10
+
11
+ interface MineProps {
12
+ taskId: string
13
+ model: string
14
+ maxRounds: number
15
+ apiBase?: string
16
+ }
17
+
18
+ interface RoundLog {
19
+ round: number
20
+ score: number | null
21
+ improved: boolean
22
+ earned: number
23
+ tokens: number
24
+ error?: string | null
25
+ isCompletion?: boolean
26
+ }
27
+
28
+ type MiningState = 'loading' | 'calling_llm' | 'submitting' | 'done'
29
+
30
+ export function Mine({ taskId, model, maxRounds, apiBase }: MineProps) {
31
+ const { exit } = useApp()
32
+ const [state, setState] = useState<MiningState>('loading')
33
+ const [task, setTask] = useState<any>(null)
34
+ const [rounds, setRounds] = useState<RoundLog[]>([])
35
+ const [roundNum, setRoundNum] = useState(0)
36
+ const [bestScore, setBestScore] = useState<number | null>(null)
37
+ const [bestAnswer, setBestAnswer] = useState<string | null>(null)
38
+ const [platformBest, setPlatformBest] = useState<number | null>(null)
39
+ const [totalEarned, setTotalEarned] = useState(0)
40
+ const [totalMinted, setTotalMinted] = useState(0)
41
+ const [totalTokens, setTotalTokens] = useState(0)
42
+ const [totalCost, setTotalCost] = useState(0)
43
+ const [elapsed, setElapsed] = useState(0)
44
+ const [error, setError] = useState<string | null>(null)
45
+ const [resumed, setResumed] = useState(false)
46
+
47
+ // Timer for elapsed
48
+ useEffect(() => {
49
+ if (state !== 'calling_llm' && state !== 'submitting') return
50
+ const t = setInterval(() => setElapsed(e => e + 100), 100)
51
+ return () => clearInterval(t)
52
+ }, [state])
53
+
54
+ // Main mining loop
55
+ const mineLoop = useCallback(async () => {
56
+ try {
57
+ // Load task
58
+ const taskData = await api<any>(`/api/tasks/${taskId}`)
59
+ setTask(taskData)
60
+ const bestInfo = await api<any>(`/api/tasks/${taskId}/submissions/best`)
61
+ setPlatformBest(bestInfo.score)
62
+
63
+ // Restore session
64
+ let myBestAnswer: string | null = null
65
+ let myBestScore: number | null = null
66
+ let startRound = 0
67
+ let earned = 0
68
+ let minted = 0
69
+ let tokens = 0
70
+ let cost = 0
71
+ const session = loadSession(taskId)
72
+ if (session) {
73
+ myBestAnswer = session.my_best_answer
74
+ myBestScore = session.my_best_score
75
+ startRound = session.round_num
76
+ earned = session.total_earned
77
+ minted = session.total_minted
78
+ tokens = session.total_tokens
79
+ cost = session.total_cost
80
+ setResumed(true)
81
+ setBestScore(myBestScore)
82
+ setBestAnswer(myBestAnswer)
83
+ setTotalEarned(earned)
84
+ setTotalMinted(minted)
85
+ setTotalTokens(tokens)
86
+ setTotalCost(cost)
87
+ }
88
+
89
+ let currentRound = startRound
90
+ while (true) {
91
+ currentRound++
92
+ if (maxRounds > 0 && currentRound > maxRounds + startRound) break
93
+
94
+ setRoundNum(currentRound)
95
+ setState('calling_llm')
96
+ setElapsed(0)
97
+
98
+ // Build prompt & call LLM
99
+ const prompt = buildPrompt(taskData, myBestAnswer, myBestScore, bestInfo.score)
100
+ let result: Awaited<ReturnType<typeof callLLM>>
101
+ try {
102
+ result = await callLLM(prompt, model, apiBase)
103
+ } catch (e: any) {
104
+ setRounds(r => [...r, { round: currentRound, score: null, improved: false, earned: 0, tokens: 0, error: e.message }])
105
+ await new Promise(r => setTimeout(r, 2000))
106
+ continue
107
+ }
108
+
109
+ tokens += result.usage.total_tokens
110
+ cost += result.usage.cost
111
+ setTotalTokens(tokens)
112
+ setTotalCost(cost)
113
+
114
+ // Submit
115
+ setState('submitting')
116
+ const sub = await api<any>(`/api/tasks/${taskId}/submissions`, {
117
+ body: { answer: result.answer, thinking: result.thinking, llm_model_used: model },
118
+ })
119
+
120
+ const roundEarned = sub.reward_earned || 0
121
+ earned += roundEarned
122
+ minted += sub.reward_minted || 0
123
+ setTotalEarned(earned)
124
+ setTotalMinted(minted)
125
+
126
+ const roundLog: RoundLog = {
127
+ round: currentRound,
128
+ score: sub.score,
129
+ improved: sub.is_improvement || false,
130
+ earned: roundEarned,
131
+ tokens: result.usage.total_tokens,
132
+ error: sub.eval_error,
133
+ isCompletion: sub.is_completion,
134
+ }
135
+ setRounds(r => [...r, roundLog])
136
+
137
+ if (sub.is_improvement) {
138
+ myBestAnswer = result.answer
139
+ myBestScore = sub.score
140
+ setBestScore(sub.score)
141
+ setBestAnswer(result.answer)
142
+ }
143
+
144
+ // Save session
145
+ saveSession(taskId, {
146
+ my_best_answer: myBestAnswer,
147
+ my_best_score: myBestScore,
148
+ round_num: currentRound,
149
+ total_earned: earned,
150
+ total_minted: minted,
151
+ total_tokens: tokens,
152
+ total_cost: cost,
153
+ model,
154
+ })
155
+
156
+ if (sub.is_completion) {
157
+ deleteSession(taskId)
158
+ break
159
+ }
160
+
161
+ // Check task status
162
+ const taskStatus = await api<any>(`/api/tasks/${taskId}`)
163
+ if (!['open', 'completed'].includes(taskStatus.status)) break
164
+ }
165
+ } catch (e: any) {
166
+ setError(e.message)
167
+ }
168
+ setState('done')
169
+ }, [taskId, model, maxRounds, apiBase])
170
+
171
+ useEffect(() => { mineLoop() }, [mineLoop])
172
+
173
+ // Exit when done
174
+ useEffect(() => {
175
+ if (state === 'done') {
176
+ const timer = setTimeout(() => exit(), 100)
177
+ return () => clearTimeout(timer)
178
+ }
179
+ }, [state, exit])
180
+
181
+ return (
182
+ <Box flexDirection="column" paddingX={1}>
183
+ <Header
184
+ title={task ? `Mining: ${task.title}` : 'Loading...'}
185
+ subtitle={`Model: ${model}`}
186
+ />
187
+
188
+ {task && (
189
+ <Box marginBottom={1} gap={2}>
190
+ <Text dimColor>Threshold: <Text color={theme.text}>{task.completion_threshold}</Text></Text>
191
+ <Text dimColor>Pool: <Text color={theme.success}>{task.pool_balance} OS</Text></Text>
192
+ {bestScore !== null && <Text dimColor>Best: <Text color={theme.text}>{bestScore.toFixed(4)}</Text></Text>}
193
+ </Box>
194
+ )}
195
+
196
+ {resumed && <Text color={theme.info}>Session restored from round {roundNum - rounds.length}</Text>}
197
+
198
+ {/* Round history */}
199
+ {rounds.map((r, i) => (
200
+ <RoundResult key={i} {...r} />
201
+ ))}
202
+
203
+ {/* Active spinner */}
204
+ {(state === 'calling_llm' || state === 'submitting') && (
205
+ <Box marginTop={rounds.length > 0 ? 1 : 0}>
206
+ <Spinner
207
+ label={state === 'calling_llm' ? `Round ${roundNum}: calling ${model}` : `Round ${roundNum}: submitting`}
208
+ elapsed={elapsed}
209
+ stalled={elapsed > 10000}
210
+ />
211
+ </Box>
212
+ )}
213
+
214
+ {/* Summary */}
215
+ {state === 'done' && (
216
+ <Box flexDirection="column" marginTop={1} borderStyle="single" borderColor={theme.dim} paddingX={1}>
217
+ <Text bold color={theme.brand}>Summary</Text>
218
+ <Text>Best score: <Text color={theme.text}>{bestScore?.toFixed(6) ?? 'N/A'}</Text></Text>
219
+ <Text>Total earned: <Text color={theme.success}>{totalEarned.toLocaleString()} OS</Text></Text>
220
+ <Text>Minted: <Text color={theme.minted}>{totalMinted.toLocaleString()} OS</Text></Text>
221
+ <Text>Tokens used: <Text dimColor>{totalTokens.toLocaleString()}</Text></Text>
222
+ {totalCost > 0 && <Text>API cost: <Text color={theme.error}>${totalCost.toFixed(4)}</Text></Text>}
223
+ </Box>
224
+ )}
225
+
226
+ {error && <Text color={theme.error}>Error: {error}</Text>}
227
+ </Box>
228
+ )
229
+ }
@@ -0,0 +1,142 @@
1
+ import React, { useState, useEffect } from 'react'
2
+ import { Box, Text, useApp } from 'ink'
3
+ import { Header } from '../components/Header.js'
4
+ import { Table } from '../components/Table.js'
5
+ import { theme } from '../theme.js'
6
+ import { api } from '../api.js'
7
+
8
+ // --- Tasks List ---
9
+
10
+ export function TasksList({ status = 'open' }: { status?: string }) {
11
+ const { exit } = useApp()
12
+ const [tasks, setTasks] = useState<any[]>([])
13
+
14
+ useEffect(() => {
15
+ api<any[]>(`/api/tasks?task_status=${status}`, { auth: false })
16
+ .then(setTasks)
17
+ .finally(() => setTimeout(() => exit(), 100))
18
+ }, [])
19
+
20
+ const rows = tasks.map(t => ({
21
+ id: String(t.id).slice(0, 8),
22
+ title: t.title.length > 30 ? t.title.slice(0, 27) + '...' : t.title,
23
+ eval: t.eval_type,
24
+ best: t.best_score !== null ? t.best_score.toFixed(4) : '-',
25
+ pool: String(t.pool_balance),
26
+ burn: String(t.task_burn),
27
+ }))
28
+
29
+ return (
30
+ <Box flexDirection="column" paddingX={1}>
31
+ <Table
32
+ title="Open Tasks"
33
+ columns={[
34
+ { key: 'id', label: 'ID', width: 10 },
35
+ { key: 'title', label: 'Title', width: 32 },
36
+ { key: 'eval', label: 'Eval', width: 14 },
37
+ { key: 'best', label: 'Best', width: 12, align: 'right' },
38
+ { key: 'burn', label: 'Burn', width: 8, align: 'right', color: theme.burned },
39
+ { key: 'pool', label: 'Pool', width: 8, align: 'right', color: theme.success },
40
+ ]}
41
+ rows={rows}
42
+ />
43
+ </Box>
44
+ )
45
+ }
46
+
47
+ // --- Task View ---
48
+
49
+ export function TaskView({ taskId }: { taskId: string }) {
50
+ const { exit } = useApp()
51
+ const [task, setTask] = useState<any>(null)
52
+
53
+ useEffect(() => {
54
+ api<any>(`/api/tasks/${taskId}`, { auth: false })
55
+ .then(setTask)
56
+ .finally(() => setTimeout(() => exit(), 100))
57
+ }, [])
58
+
59
+ if (!task) return <Text dimColor>Loading...</Text>
60
+
61
+ return (
62
+ <Box flexDirection="column" paddingX={1}>
63
+ <Header title={task.title} />
64
+ <Text dimColor>ID: <Text color={theme.text}>{task.id}</Text></Text>
65
+ <Text dimColor>Eval: <Text color={theme.text}>{task.eval_type} ({task.direction})</Text></Text>
66
+ <Text dimColor>Status: <Text color={theme.text}>{task.status}</Text></Text>
67
+ <Text dimColor>Threshold: <Text color={theme.text}>{task.completion_threshold}</Text></Text>
68
+ <Text dimColor>Best: <Text color={theme.text}>{task.best_score ?? '-'}</Text></Text>
69
+ <Text dimColor>Baseline: <Text color={theme.text}>{task.baseline_score ?? '-'}</Text></Text>
70
+ <Text dimColor>Burned: <Text color={theme.burned}>{task.task_burn} OS</Text></Text>
71
+ <Text dimColor>Pool: <Text color={theme.success}>{task.pool_balance} OS</Text></Text>
72
+ <Box marginTop={1}>
73
+ <Text dimColor>{task.description}</Text>
74
+ </Box>
75
+ </Box>
76
+ )
77
+ }
78
+
79
+ // --- Leaderboard ---
80
+
81
+ export function Leaderboard({ taskId }: { taskId: string }) {
82
+ const { exit } = useApp()
83
+ const [subs, setSubs] = useState<any[]>([])
84
+
85
+ useEffect(() => {
86
+ api<any[]>(`/api/tasks/${taskId}/leaderboard`, { auth: false })
87
+ .then(setSubs)
88
+ .finally(() => setTimeout(() => exit(), 100))
89
+ }, [])
90
+
91
+ const rows = subs.map((s, i) => ({
92
+ rank: String(i + 1),
93
+ score: s.score !== null ? s.score.toFixed(6) : '-',
94
+ reward: String(s.reward_earned || 0),
95
+ model: s.llm_model_used || '-',
96
+ time: s.created_at?.slice(0, 19) || '-',
97
+ }))
98
+
99
+ return (
100
+ <Box flexDirection="column" paddingX={1}>
101
+ <Table
102
+ title="Leaderboard"
103
+ columns={[
104
+ { key: 'rank', label: '#', width: 4 },
105
+ { key: 'score', label: 'Score', width: 14, align: 'right' },
106
+ { key: 'reward', label: 'Reward', width: 10, align: 'right', color: theme.success },
107
+ { key: 'model', label: 'Model', width: 28 },
108
+ { key: 'time', label: 'Time', width: 20 },
109
+ ]}
110
+ rows={rows}
111
+ />
112
+ </Box>
113
+ )
114
+ }
115
+
116
+ // --- Mint ---
117
+
118
+ export function Mint() {
119
+ const { exit } = useApp()
120
+ const [data, setData] = useState<any>(null)
121
+
122
+ useEffect(() => {
123
+ api<any>('/api/mint', { auth: false })
124
+ .then(setData)
125
+ .finally(() => setTimeout(() => exit(), 100))
126
+ }, [])
127
+
128
+ if (!data) return <Text dimColor>Loading...</Text>
129
+
130
+ const pct = (data.total_minted / data.total_supply * 100).toFixed(4)
131
+
132
+ return (
133
+ <Box flexDirection="column" paddingX={1}>
134
+ <Header title="OS Mint State" />
135
+ <Text>Total supply: <Text bold>{data.total_supply.toLocaleString()} OS</Text></Text>
136
+ <Text>Total minted: <Text color={theme.minted}>{data.total_minted.toLocaleString()} OS</Text> <Text dimColor>({pct}%)</Text></Text>
137
+ <Text>Base reward: <Text>{data.base_reward} OS</Text></Text>
138
+ <Text>Match multiplier: <Text>{data.match_multiplier}</Text></Text>
139
+ <Text>Halvings: <Text>{data.halving_count}</Text></Text>
140
+ </Box>
141
+ )
142
+ }
@@ -0,0 +1,12 @@
1
+ import React from 'react'
2
+ import { Box, Text } from 'ink'
3
+ import { theme } from '../theme.js'
4
+
5
+ export function Header({ title, subtitle }: { title: string; subtitle?: string }) {
6
+ return (
7
+ <Box flexDirection="column" marginBottom={1}>
8
+ <Text bold color={theme.brand}>{'◆'} {title}</Text>
9
+ {subtitle && <Text color={theme.dim}> {subtitle}</Text>}
10
+ </Box>
11
+ )
12
+ }
@@ -0,0 +1,44 @@
1
+ import React from 'react'
2
+ import { Box, Text } from 'ink'
3
+ import { StatusIcon } from './StatusIcon.js'
4
+ import { theme } from '../theme.js'
5
+
6
+ interface RoundResultProps {
7
+ round: number
8
+ score: number | null
9
+ improved: boolean
10
+ earned: number
11
+ tokens: number
12
+ error?: string | null
13
+ isCompletion?: boolean
14
+ }
15
+
16
+ export function RoundResult({ round, score, improved, earned, tokens, error, isCompletion }: RoundResultProps) {
17
+ const scoreStr = score !== null ? score.toFixed(6) : 'ERROR'
18
+
19
+ let status: 'success' | 'error' | 'dim' = 'dim'
20
+ let label = 'no change'
21
+ if (error) {
22
+ status = 'error'
23
+ label = 'error'
24
+ } else if (isCompletion) {
25
+ status = 'success'
26
+ label = 'COMPLETE'
27
+ } else if (improved) {
28
+ status = 'success'
29
+ label = 'improved'
30
+ }
31
+
32
+ return (
33
+ <Box gap={1}>
34
+ <Text dimColor>R{String(round).padStart(2)}</Text>
35
+ <Text color={error ? theme.error : theme.text}>{scoreStr.padStart(12)}</Text>
36
+ <StatusIcon status={status} />
37
+ <Text color={status === 'success' ? theme.success : status === 'error' ? theme.error : theme.dim}>
38
+ {label.padEnd(10)}
39
+ </Text>
40
+ {earned > 0 && <Text color={theme.success}>+{earned} OS</Text>}
41
+ <Text dimColor>{tokens.toLocaleString()} tok</Text>
42
+ </Box>
43
+ )
44
+ }
@@ -0,0 +1,44 @@
1
+ import React, { useState, useEffect } from 'react'
2
+ import { Text } from 'ink'
3
+ import { theme } from '../theme.js'
4
+
5
+ const FRAMES = ['▖', '▗', '▝', '▘']
6
+ const VERBS = [
7
+ 'Thinking', 'Computing', 'Analyzing', 'Reasoning', 'Processing',
8
+ 'Optimizing', 'Evaluating', 'Iterating', 'Refining', 'Exploring',
9
+ ]
10
+
11
+ interface SpinnerProps {
12
+ label?: string
13
+ elapsed?: number
14
+ stalled?: boolean
15
+ }
16
+
17
+ export function Spinner({ label, elapsed, stalled }: SpinnerProps) {
18
+ const [frame, setFrame] = useState(0)
19
+ const [verb, setVerb] = useState(VERBS[0])
20
+
21
+ useEffect(() => {
22
+ const timer = setInterval(() => {
23
+ setFrame(f => (f + 1) % FRAMES.length)
24
+ }, 80)
25
+ return () => clearInterval(timer)
26
+ }, [])
27
+
28
+ useEffect(() => {
29
+ const timer = setInterval(() => {
30
+ setVerb(VERBS[Math.floor(Math.random() * VERBS.length)])
31
+ }, 3000)
32
+ return () => clearInterval(timer)
33
+ }, [])
34
+
35
+ const color = stalled ? theme.error : theme.brand
36
+ const elapsedStr = elapsed ? ` (${(elapsed / 1000).toFixed(1)}s)` : ''
37
+
38
+ return (
39
+ <Text>
40
+ <Text color={color}>{FRAMES[frame]}</Text>
41
+ <Text color={theme.dim}> {label || verb}...{elapsedStr}</Text>
42
+ </Text>
43
+ )
44
+ }
@@ -0,0 +1,19 @@
1
+ import React from 'react'
2
+ import { Text } from 'ink'
3
+ import { theme } from '../theme.js'
4
+
5
+ type Status = 'success' | 'error' | 'warning' | 'info' | 'pending' | 'dim'
6
+
7
+ const ICONS: Record<Status, { char: string; color: string }> = {
8
+ success: { char: '✓', color: theme.success },
9
+ error: { char: '✗', color: theme.error },
10
+ warning: { char: '⚠', color: theme.warning },
11
+ info: { char: '●', color: theme.info },
12
+ pending: { char: '○', color: theme.dim },
13
+ dim: { char: '·', color: theme.dim },
14
+ }
15
+
16
+ export function StatusIcon({ status }: { status: Status }) {
17
+ const { char, color } = ICONS[status]
18
+ return <Text color={color}>{char}</Text>
19
+ }
@@ -0,0 +1,58 @@
1
+ import React from 'react'
2
+ import { Box, Text } from 'ink'
3
+ import { theme } from '../theme.js'
4
+
5
+ interface Column {
6
+ key: string
7
+ label: string
8
+ width?: number
9
+ align?: 'left' | 'right'
10
+ color?: string
11
+ }
12
+
13
+ interface TableProps {
14
+ columns: Column[]
15
+ rows: Record<string, string | number | null | undefined>[]
16
+ title?: string
17
+ }
18
+
19
+ export function Table({ columns, rows, title }: TableProps) {
20
+ return (
21
+ <Box flexDirection="column">
22
+ {title && (
23
+ <Box marginBottom={1}>
24
+ <Text bold color={theme.brand}>{title}</Text>
25
+ </Box>
26
+ )}
27
+ {/* Header */}
28
+ <Box>
29
+ {columns.map((col, i) => (
30
+ <Box key={col.key} width={col.width || 14}>
31
+ <Text bold dimColor>{col.label}</Text>
32
+ </Box>
33
+ ))}
34
+ </Box>
35
+ {/* Separator */}
36
+ <Box>
37
+ {columns.map(col => (
38
+ <Box key={col.key} width={col.width || 14}>
39
+ <Text dimColor>{'─'.repeat((col.width || 14) - 2)}</Text>
40
+ </Box>
41
+ ))}
42
+ </Box>
43
+ {/* Rows */}
44
+ {rows.map((row, i) => (
45
+ <Box key={i}>
46
+ {columns.map(col => {
47
+ const val = String(row[col.key] ?? '-')
48
+ return (
49
+ <Box key={col.key} width={col.width || 14} justifyContent={col.align === 'right' ? 'flex-end' : 'flex-start'}>
50
+ <Text color={col.color}>{val}</Text>
51
+ </Box>
52
+ )
53
+ })}
54
+ </Box>
55
+ ))}
56
+ </Box>
57
+ )
58
+ }
package/src/config.ts ADDED
@@ -0,0 +1,46 @@
1
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs'
2
+ import { join } from 'path'
3
+ import { homedir } from 'os'
4
+
5
+ const CONFIG_DIR = join(homedir(), '.oscli')
6
+ const CONFIG_FILE = join(CONFIG_DIR, 'config.json')
7
+
8
+ export interface Config {
9
+ server_url: string
10
+ auth_token: string
11
+ default_model: string
12
+ api_base: string
13
+ }
14
+
15
+ const DEFAULT_CONFIG: Config = {
16
+ server_url: 'http://localhost:8000',
17
+ auth_token: '',
18
+ default_model: 'anthropic/claude-opus-4-6',
19
+ api_base: '',
20
+ }
21
+
22
+ export function loadConfig(): Config {
23
+ if (!existsSync(CONFIG_FILE)) return { ...DEFAULT_CONFIG }
24
+ try {
25
+ const data = JSON.parse(readFileSync(CONFIG_FILE, 'utf-8'))
26
+ return { ...DEFAULT_CONFIG, ...data }
27
+ } catch {
28
+ return { ...DEFAULT_CONFIG }
29
+ }
30
+ }
31
+
32
+ export function saveConfig(config: Partial<Config>): void {
33
+ mkdirSync(CONFIG_DIR, { recursive: true })
34
+ const current = loadConfig()
35
+ const merged = { ...current, ...config }
36
+ writeFileSync(CONFIG_FILE, JSON.stringify(merged, null, 2) + '\n')
37
+ }
38
+
39
+ export function getToken(): string {
40
+ const config = loadConfig()
41
+ if (!config.auth_token) {
42
+ console.error('Not logged in. Run: os-cli login')
43
+ process.exit(1)
44
+ }
45
+ return config.auth_token
46
+ }
package/src/index.tsx ADDED
@@ -0,0 +1,114 @@
1
+ #!/usr/bin/env node
2
+ import React from 'react'
3
+ import { render } from 'ink'
4
+ import { loadConfig } from './config.js'
5
+
6
+ const args = process.argv.slice(2)
7
+ const command = args[0]
8
+ const subcommand = args[1]
9
+
10
+ function getFlag(name: string): string | undefined {
11
+ const idx = args.indexOf(`--${name}`)
12
+ if (idx === -1) return undefined
13
+ return args[idx + 1]
14
+ }
15
+
16
+ function hasFlag(name: string): boolean {
17
+ return args.includes(`--${name}`)
18
+ }
19
+
20
+ async function main() {
21
+ const config = loadConfig()
22
+
23
+ switch (command) {
24
+ case 'register': {
25
+ const { Register } = await import('./commands/auth.js')
26
+ render(<Register />)
27
+ break
28
+ }
29
+ case 'login': {
30
+ const { Login } = await import('./commands/auth.js')
31
+ render(<Login />)
32
+ break
33
+ }
34
+ case 'balance': {
35
+ const { Balance } = await import('./commands/auth.js')
36
+ render(<Balance />)
37
+ break
38
+ }
39
+ case 'history': {
40
+ const { History } = await import('./commands/auth.js')
41
+ const limit = parseInt(getFlag('limit') || '20')
42
+ render(<History limit={limit} />)
43
+ break
44
+ }
45
+ case 'stats': {
46
+ const { Stats } = await import('./commands/auth.js')
47
+ render(<Stats />)
48
+ break
49
+ }
50
+ case 'tasks': {
51
+ if (subcommand === 'list') {
52
+ const { TasksList } = await import('./commands/tasks.js')
53
+ const status = getFlag('status') || 'open'
54
+ render(<TasksList status={status} />)
55
+ } else if (subcommand === 'view' && args[2]) {
56
+ const { TaskView } = await import('./commands/tasks.js')
57
+ render(<TaskView taskId={args[2]} />)
58
+ } else {
59
+ console.log('Usage: os-cli tasks <list|view <id>>')
60
+ }
61
+ break
62
+ }
63
+ case 'mine': {
64
+ const taskId = args[1]
65
+ if (!taskId) {
66
+ console.log('Usage: os-cli mine <task_id> [--model name] [--max-rounds n] [--api-base url]')
67
+ break
68
+ }
69
+ const { Mine } = await import('./commands/mine.js')
70
+ const model = getFlag('model') || config.default_model
71
+ const maxRounds = parseInt(getFlag('max-rounds') || '0')
72
+ const apiBase = getFlag('api-base') || config.api_base || undefined
73
+ render(<Mine taskId={taskId} model={model} maxRounds={maxRounds} apiBase={apiBase} />)
74
+ break
75
+ }
76
+ case 'leaderboard': {
77
+ if (!args[1]) { console.log('Usage: os-cli leaderboard <task_id>'); break }
78
+ const { Leaderboard } = await import('./commands/tasks.js')
79
+ render(<Leaderboard taskId={args[1]} />)
80
+ break
81
+ }
82
+ case 'mint': {
83
+ const { Mint } = await import('./commands/tasks.js')
84
+ render(<Mint />)
85
+ break
86
+ }
87
+ default:
88
+ console.log(`
89
+ ◆ OpenScienceNet CLI
90
+
91
+ Commands:
92
+ register Create a new account
93
+ login Log in and save token
94
+ balance Show OS coin balance
95
+ history Transaction history
96
+ stats Mining statistics
97
+
98
+ tasks list Browse open tasks
99
+ tasks view <id> Task details
100
+ mine <task_id> Start mining a task
101
+ --model <name> LLM model (default: ${config.default_model})
102
+ --max-rounds <n> Stop after n rounds
103
+ --api-base <url> Custom API base (Ollama/vLLM)
104
+
105
+ leaderboard <id> Top scores for a task
106
+ mint Global mint state
107
+ `)
108
+ }
109
+ }
110
+
111
+ main().catch(e => {
112
+ console.error(e.message)
113
+ process.exit(1)
114
+ })
package/src/llm.ts ADDED
@@ -0,0 +1,108 @@
1
+ import Anthropic from '@anthropic-ai/sdk'
2
+ import OpenAI from 'openai'
3
+
4
+ export interface LLMResult {
5
+ thinking: string
6
+ answer: string
7
+ usage: { prompt_tokens: number; completion_tokens: number; total_tokens: number; cost: number }
8
+ }
9
+
10
+ export function buildPrompt(
11
+ task: { title: string; description: string; eval_type: string; direction: string; completion_threshold: number },
12
+ myBestAnswer: string | null,
13
+ myBestScore: number | null,
14
+ platformBestScore: number | null,
15
+ ): string {
16
+ const dirText = task.direction === 'maximize' ? 'higher is better' : 'lower is better'
17
+ const isCode = task.eval_type === 'code_output'
18
+
19
+ let prompt = `# Task: ${task.title}\n\n## Description\n${task.description}\n\n`
20
+ prompt += `## Evaluation\nScored using: ${task.eval_type}\nDirection: ${task.direction} (${dirText})\n`
21
+ prompt += `Completion threshold: ${task.completion_threshold}\n`
22
+ prompt += `Platform best: ${platformBestScore ?? 'None (no submissions yet)'}\n`
23
+
24
+ if (myBestAnswer !== null && myBestScore !== null) {
25
+ prompt += `\n## Your Previous Best\nScore: ${myBestScore}\nAnswer:\n${myBestAnswer}\n\nAnalyze why it scored ${myBestScore}. Improve it.\n`
26
+ } else {
27
+ prompt += '\nThis is your first attempt. Think carefully.\n'
28
+ }
29
+
30
+ if (isCode) {
31
+ prompt += `\n## CRITICAL RULES\n1. Do NOT hardcode test data. Input comes via function parameters.\n2. Functions must work with ANY input.\n\n`
32
+ }
33
+
34
+ prompt += `## OUTPUT FORMAT\n<thinking>your reasoning</thinking>\n<answer>${isCode ? 'ONLY executable code, NO prose, NO markdown fences' : 'your answer'}</answer>\n`
35
+ return prompt
36
+ }
37
+
38
+ function parseResponse(text: string): { thinking: string; answer: string } {
39
+ const thinkMatch = text.match(/<thinking>([\s\S]*?)<\/thinking>/)
40
+ const answerMatch = text.match(/<answer>([\s\S]*?)<\/answer>/)
41
+
42
+ let thinking = thinkMatch?.[1]?.trim() || text
43
+ let answer = answerMatch?.[1]?.trim() || text.trim()
44
+
45
+ // Strip markdown fences
46
+ answer = answer.replace(/```[\w]*\s*\n?/g, '').replace(/\n?\s*```/g, '').trim()
47
+
48
+ return { thinking, answer }
49
+ }
50
+
51
+ // Cost estimates per 1M tokens (input/output)
52
+ const COST_TABLE: Record<string, [number, number]> = {
53
+ 'claude-opus-4-6': [15, 75],
54
+ 'claude-sonnet-4-20250514': [3, 15],
55
+ 'gpt-4o': [2.5, 10],
56
+ 'gpt-4o-mini': [0.15, 0.6],
57
+ 'deepseek-chat': [0.14, 0.28],
58
+ }
59
+
60
+ function estimateCost(model: string, promptTok: number, completionTok: number): number {
61
+ const shortModel = model.replace(/^(anthropic|openai|deepseek)\//, '')
62
+ const costs = COST_TABLE[shortModel] || [5, 15] // default
63
+ return (promptTok * costs[0] + completionTok * costs[1]) / 1_000_000
64
+ }
65
+
66
+ export async function callLLM(prompt: string, model: string, apiBase?: string): Promise<LLMResult> {
67
+ const provider = model.split('/')[0]
68
+ const modelName = model.split('/').slice(1).join('/')
69
+
70
+ let text = ''
71
+ let promptTokens = 0
72
+ let completionTokens = 0
73
+
74
+ if (provider === 'anthropic') {
75
+ const client = new Anthropic()
76
+ const resp = await client.messages.create({
77
+ model: modelName,
78
+ max_tokens: 4096,
79
+ messages: [{ role: 'user', content: prompt }],
80
+ })
81
+ text = resp.content.filter(b => b.type === 'text').map(b => b.text).join('')
82
+ promptTokens = resp.usage.input_tokens
83
+ completionTokens = resp.usage.output_tokens
84
+ } else {
85
+ // OpenAI-compatible (openai, ollama, deepseek, vllm)
86
+ const client = new OpenAI({
87
+ apiKey: process.env.OPENAI_API_KEY || process.env.DEEPSEEK_API_KEY || 'ollama',
88
+ baseURL: apiBase || (provider === 'deepseek' ? 'https://api.deepseek.com' : undefined),
89
+ })
90
+ const resp = await client.chat.completions.create({
91
+ model: modelName,
92
+ max_tokens: 4096,
93
+ messages: [{ role: 'user', content: prompt }],
94
+ })
95
+ text = resp.choices[0]?.message?.content || ''
96
+ promptTokens = resp.usage?.prompt_tokens || 0
97
+ completionTokens = resp.usage?.completion_tokens || 0
98
+ }
99
+
100
+ const { thinking, answer } = parseResponse(text)
101
+ const cost = estimateCost(model, promptTokens, completionTokens)
102
+
103
+ return {
104
+ thinking,
105
+ answer,
106
+ usage: { prompt_tokens: promptTokens, completion_tokens: completionTokens, total_tokens: promptTokens + completionTokens, cost },
107
+ }
108
+ }
package/src/session.ts ADDED
@@ -0,0 +1,36 @@
1
+ import { readFileSync, writeFileSync, mkdirSync, unlinkSync, existsSync } from 'fs'
2
+ import { join } from 'path'
3
+ import { homedir } from 'os'
4
+
5
+ const SESSIONS_DIR = join(homedir(), '.oscli', 'sessions')
6
+
7
+ export interface MiningSession {
8
+ my_best_answer: string | null
9
+ my_best_score: number | null
10
+ round_num: number
11
+ total_earned: number
12
+ total_minted: number
13
+ total_tokens: number
14
+ total_cost: number
15
+ model: string
16
+ }
17
+
18
+ export function loadSession(taskId: string): MiningSession | null {
19
+ const path = join(SESSIONS_DIR, `${taskId}.json`)
20
+ if (!existsSync(path)) return null
21
+ try {
22
+ return JSON.parse(readFileSync(path, 'utf-8'))
23
+ } catch {
24
+ return null
25
+ }
26
+ }
27
+
28
+ export function saveSession(taskId: string, data: MiningSession): void {
29
+ mkdirSync(SESSIONS_DIR, { recursive: true })
30
+ writeFileSync(join(SESSIONS_DIR, `${taskId}.json`), JSON.stringify(data, null, 2))
31
+ }
32
+
33
+ export function deleteSession(taskId: string): void {
34
+ const path = join(SESSIONS_DIR, `${taskId}.json`)
35
+ if (existsSync(path)) unlinkSync(path)
36
+ }
package/src/theme.ts ADDED
@@ -0,0 +1,25 @@
1
+ export const theme = {
2
+ // Brand
3
+ brand: '#6C5CE7', // OS purple
4
+ brandDim: '#A29BFE',
5
+
6
+ // Semantic
7
+ success: '#00B894',
8
+ error: '#D63031',
9
+ warning: '#FDCB6E',
10
+ info: '#0984E3',
11
+
12
+ // Text
13
+ text: '#DFE6E9',
14
+ dim: '#636E72',
15
+ muted: '#B2BEC3',
16
+
17
+ // Mining
18
+ improved: '#00B894',
19
+ noChange: '#636E72',
20
+ minted: '#00CEC9',
21
+ burned: '#D63031',
22
+
23
+ // Spinner frames
24
+ spinnerFrames: ['▖', '▗', '▝', '▘'] as const,
25
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "jsx": "react-jsx",
7
+ "strict": true,
8
+ "esModuleInterop": true,
9
+ "skipLibCheck": true,
10
+ "outDir": "dist",
11
+ "rootDir": "src",
12
+ "declaration": true
13
+ },
14
+ "include": ["src"]
15
+ }
package/tsup.config.ts ADDED
@@ -0,0 +1,11 @@
1
+ import { defineConfig } from 'tsup'
2
+
3
+ export default defineConfig({
4
+ entry: ['src/index.tsx'],
5
+ format: ['esm'],
6
+ target: 'node18',
7
+ outDir: 'dist',
8
+ clean: true,
9
+ sourcemap: true,
10
+ external: ['react', 'ink'],
11
+ })