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 +2 -0
- package/package.json +33 -0
- package/src/api.ts +33 -0
- package/src/commands/auth.tsx +169 -0
- package/src/commands/mine.tsx +229 -0
- package/src/commands/tasks.tsx +142 -0
- package/src/components/Header.tsx +12 -0
- package/src/components/RoundResult.tsx +44 -0
- package/src/components/Spinner.tsx +44 -0
- package/src/components/StatusIcon.tsx +19 -0
- package/src/components/Table.tsx +58 -0
- package/src/config.ts +46 -0
- package/src/index.tsx +114 -0
- package/src/llm.ts +108 -0
- package/src/session.ts +36 -0
- package/src/theme.ts +25 -0
- package/tsconfig.json +15 -0
- package/tsup.config.ts +11 -0
package/bin/os-cli.js
ADDED
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
|
+
}
|