@sofer_agent/cli 0.3.10 β 0.3.12
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/sofer +6 -0
- package/package.json +8 -5
- package/src/bin.ts +0 -1
- package/src/chat.ts +24 -57
- package/src/tui.tsx +227 -0
package/bin/sofer
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// Register the @opentui/solid JSX transform plugin BEFORE any .tsx file is parsed.
|
|
3
|
+
// Static imports are all hoisted by bun, so we must use a dynamic import for the
|
|
4
|
+
// app entry so the plugin is active before bun touches tui.tsx.
|
|
5
|
+
import '@opentui/solid/preload'
|
|
6
|
+
await import('../src/bin.ts')
|
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sofer_agent/cli",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.12",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
|
-
"sofer": "./
|
|
6
|
+
"sofer": "./bin/sofer"
|
|
7
7
|
},
|
|
8
|
-
"files": ["dist", "src"],
|
|
8
|
+
"files": ["dist", "src", "bin"],
|
|
9
9
|
"publishConfig": {
|
|
10
10
|
"access": "public"
|
|
11
11
|
},
|
|
@@ -15,12 +15,15 @@
|
|
|
15
15
|
},
|
|
16
16
|
"keywords": ["sui", "ai", "agent", "walrus", "sovereign", "cli"],
|
|
17
17
|
"scripts": {
|
|
18
|
-
"build": "tsc -b && chmod +x
|
|
18
|
+
"build": "tsc -b && chmod +x bin/sofer",
|
|
19
19
|
"typecheck": "tsc --noEmit"
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
22
|
"@clack/prompts": "^1.5.1",
|
|
23
|
-
"@
|
|
23
|
+
"@opentui/core": "0.4.1",
|
|
24
|
+
"@opentui/solid": "0.4.1",
|
|
25
|
+
"@sofer_agent/core": "0.3.3",
|
|
26
|
+
"solid-js": "1.9.13"
|
|
24
27
|
},
|
|
25
28
|
"devDependencies": {
|
|
26
29
|
"@types/node": "^22.10.2",
|
package/src/bin.ts
CHANGED
package/src/chat.ts
CHANGED
|
@@ -8,52 +8,34 @@ import {
|
|
|
8
8
|
SoferAgent,
|
|
9
9
|
} from "@sofer_agent/core";
|
|
10
10
|
import { loadSecrets } from "./env.js";
|
|
11
|
-
import {
|
|
11
|
+
import { addMessage, setInfo, runTui } from "./tui.tsx";
|
|
12
12
|
|
|
13
|
-
/** Serializable message for JSONL session storage. */
|
|
14
13
|
interface SessionMessage {
|
|
15
14
|
role: string;
|
|
16
|
-
content: unknown;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
interface SessionLine {
|
|
20
|
-
role: SessionMessage["role"];
|
|
21
|
-
content: SessionMessage["content"];
|
|
15
|
+
content: unknown;
|
|
22
16
|
}
|
|
23
17
|
|
|
24
18
|
function sessionPath(config: SoferConfig): string | null {
|
|
25
|
-
if (config.agentObjectId)
|
|
26
|
-
|
|
27
|
-
}
|
|
28
|
-
if (config.memwal.accountId) {
|
|
29
|
-
return agentPaths.agent(shortAgentId(config.memwal.accountId)).activityLog;
|
|
30
|
-
}
|
|
19
|
+
if (config.agentObjectId) return agentPaths.agent(shortAgentId(config.agentObjectId)).activityLog;
|
|
20
|
+
if (config.memwal.accountId) return agentPaths.agent(shortAgentId(config.memwal.accountId)).activityLog;
|
|
31
21
|
return null;
|
|
32
22
|
}
|
|
33
23
|
|
|
34
24
|
function loadSession(path: string): SessionMessage[] {
|
|
35
25
|
if (!existsSync(path)) return [];
|
|
36
26
|
const messages: SessionMessage[] = [];
|
|
37
|
-
const
|
|
38
|
-
for (const line of raw.split("\n")) {
|
|
27
|
+
for (const line of readFileSync(path, "utf8").split("\n")) {
|
|
39
28
|
if (!line.trim()) continue;
|
|
40
|
-
try {
|
|
41
|
-
const entry = JSON.parse(line) as SessionLine;
|
|
42
|
-
messages.push({ role: entry.role, content: entry.content });
|
|
43
|
-
} catch {
|
|
44
|
-
// skip malformed lines
|
|
45
|
-
}
|
|
29
|
+
try { messages.push(JSON.parse(line) as SessionMessage); } catch { /* skip */ }
|
|
46
30
|
}
|
|
47
31
|
return messages;
|
|
48
32
|
}
|
|
49
33
|
|
|
50
34
|
function saveSession(path: string, messages: SessionMessage[]): void {
|
|
51
|
-
const lines = messages.map((m) => JSON.stringify({ role: m.role, content: m.content }));
|
|
52
35
|
mkdirSync(dirname(path), { recursive: true });
|
|
53
|
-
writeFileSync(path,
|
|
36
|
+
writeFileSync(path, messages.map((m) => JSON.stringify(m)).join("\n") + "\n", "utf8");
|
|
54
37
|
}
|
|
55
38
|
|
|
56
|
-
/** Interactive chat TUI with promus-style polish + session resume. */
|
|
57
39
|
export async function chatCommand(): Promise<void> {
|
|
58
40
|
const config = loadConfig();
|
|
59
41
|
const secrets = loadSecrets();
|
|
@@ -61,50 +43,35 @@ export async function chatCommand(): Promise<void> {
|
|
|
61
43
|
console.error("Missing secrets. Run `sofer init` first.");
|
|
62
44
|
process.exit(1);
|
|
63
45
|
}
|
|
64
|
-
const agent = await SoferAgent.create(config, {
|
|
46
|
+
const agent = await SoferAgent.create(config, {
|
|
47
|
+
suiSecretKey: secrets.suiSecretKey,
|
|
48
|
+
anthropicApiKey: secrets.anthropicApiKey,
|
|
49
|
+
});
|
|
65
50
|
|
|
66
|
-
// Session resume
|
|
67
51
|
const sp = sessionPath(config);
|
|
68
52
|
if (sp) {
|
|
69
53
|
const history = loadSession(sp);
|
|
70
|
-
if (history.length > 0)
|
|
71
|
-
agent.loadHistory(history);
|
|
72
|
-
}
|
|
54
|
+
if (history.length > 0) agent.loadHistory(history);
|
|
73
55
|
}
|
|
74
56
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const bal = await agent.getBalance();
|
|
80
|
-
tui.setInfo({ balance: (Number(bal) / 1e9).toFixed(4) });
|
|
81
|
-
} catch {
|
|
82
|
-
// RPC unavailable, skip
|
|
83
|
-
}
|
|
57
|
+
// Fetch initial balance non-blocking
|
|
58
|
+
agent.getBalance().then((bal) => {
|
|
59
|
+
setInfo({ balance: (Number(bal) / 1e9).toFixed(4) });
|
|
60
|
+
}).catch(() => {});
|
|
84
61
|
|
|
85
|
-
await
|
|
62
|
+
await runTui(agent, config, async (line, signal) => {
|
|
86
63
|
try {
|
|
87
64
|
const { text, turnUsage } = await agent.chat(line, signal);
|
|
88
|
-
|
|
89
|
-
|
|
65
|
+
addMessage("agent", text);
|
|
66
|
+
setInfo({ lastTurnIn: turnUsage.inputTokens, lastTurnOut: turnUsage.outputTokens });
|
|
90
67
|
} catch (e) {
|
|
91
|
-
if (signal
|
|
92
|
-
|
|
68
|
+
if (signal.aborted) {
|
|
69
|
+
addMessage("system", "turn interrupted.");
|
|
93
70
|
} else {
|
|
94
|
-
|
|
71
|
+
addMessage("error", e instanceof Error ? e.message : String(e));
|
|
95
72
|
}
|
|
96
73
|
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
const bal = await agent.getBalance();
|
|
100
|
-
tui.setInfo({ balance: (Number(bal) / 1e9).toFixed(4) });
|
|
101
|
-
} catch {
|
|
102
|
-
// RPC unavailable, skip
|
|
103
|
-
}
|
|
104
|
-
// Save session after each turn
|
|
105
|
-
if (sp) {
|
|
106
|
-
saveSession(sp, agent.getHistory());
|
|
107
|
-
}
|
|
108
|
-
tui.render();
|
|
74
|
+
agent.getBalance().then((bal) => setInfo({ balance: (Number(bal) / 1e9).toFixed(4) })).catch(() => {});
|
|
75
|
+
if (sp) saveSession(sp, agent.getHistory());
|
|
109
76
|
});
|
|
110
77
|
}
|
package/src/tui.tsx
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { render, useKeyboard, useTerminalDimensions } from "@opentui/solid";
|
|
2
|
+
import type { SoferAgent, SoferConfig } from "@sofer_agent/core";
|
|
3
|
+
import { For, Show, createEffect, createSignal, onCleanup } from "solid-js";
|
|
4
|
+
|
|
5
|
+
const SPINNER_FRAMES = ["β ","β ","β Ή","β Έ","β Ό","β ΄","β ¦","β §","β ","β "] as const;
|
|
6
|
+
const SPINNER_MS = 80;
|
|
7
|
+
|
|
8
|
+
export interface ChatLine {
|
|
9
|
+
role: "you" | "agent" | "system" | "error";
|
|
10
|
+
text: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface TuiInfo {
|
|
14
|
+
lastTurnIn: number;
|
|
15
|
+
lastTurnOut: number;
|
|
16
|
+
balance?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// ββ State signals (module-level so chat.ts can push into them) ββββββββββββββ
|
|
20
|
+
|
|
21
|
+
const [rows, setRows] = createSignal<ChatLine[]>([]);
|
|
22
|
+
const [status, setStatus] = createSignal<"idle" | "thinking">("idle");
|
|
23
|
+
const [input, setInput] = createSignal("");
|
|
24
|
+
const [info, setInfoSignal] = createSignal<TuiInfo>({ lastTurnIn: 0, lastTurnOut: 0 });
|
|
25
|
+
const [turnStartedAt, setTurnStartedAt] = createSignal<number | null>(null);
|
|
26
|
+
const [spinnerFrame, setSpinnerFrame] = createSignal(0);
|
|
27
|
+
const [activeAbort, setActiveAbort] = createSignal<AbortController | null>(null);
|
|
28
|
+
|
|
29
|
+
// ββ Exported imperative API (used by chat.ts) ββββββββββββββββββββββββββββββββ
|
|
30
|
+
|
|
31
|
+
export function addMessage(role: ChatLine["role"], text: string): void {
|
|
32
|
+
setRows((prev) => [...prev, { role, text }]);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function setInfo(patch: Partial<TuiInfo>): void {
|
|
36
|
+
setInfoSignal((prev) => ({ ...prev, ...patch }));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ββ Helper βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
40
|
+
|
|
41
|
+
function formatElapsed(startedAt: number | null): string {
|
|
42
|
+
if (!startedAt) return "";
|
|
43
|
+
const sec = Math.floor((Date.now() - startedAt) / 1000);
|
|
44
|
+
if (sec < 60) return `${sec}s`;
|
|
45
|
+
const m = Math.floor(sec / 60);
|
|
46
|
+
return `${m}m${String(sec % 60).padStart(2, "0")}s`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function shortModel(model: string): string {
|
|
50
|
+
const m = model.toLowerCase();
|
|
51
|
+
if (m.includes("sonnet")) return "sonnet-4";
|
|
52
|
+
if (m.includes("haiku")) return "haiku";
|
|
53
|
+
if (m.includes("opus")) return "opus-4";
|
|
54
|
+
return model.length > 16 ? `${model.slice(0, 15)}β¦` : model;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const LABEL: Record<ChatLine["role"], string> = {
|
|
58
|
+
you: "you",
|
|
59
|
+
agent: "agent",
|
|
60
|
+
system: "sys",
|
|
61
|
+
error: "err",
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const COLOR: Record<ChatLine["role"], string> = {
|
|
65
|
+
you: "#67e8f9",
|
|
66
|
+
agent: "#86efac",
|
|
67
|
+
system: "#9ca3af",
|
|
68
|
+
error: "#fca5a5",
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// ββ Components ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
72
|
+
|
|
73
|
+
function MessageRow(props: { line: ChatLine }) {
|
|
74
|
+
return (
|
|
75
|
+
<box flexDirection="row" marginBottom={1}>
|
|
76
|
+
<text fg={COLOR[props.line.role]} flexShrink={0}>
|
|
77
|
+
{` ${LABEL[props.line.role].padEnd(5)} `}
|
|
78
|
+
</text>
|
|
79
|
+
<text wrapMode="word" flexGrow={1} fg="#e5e7eb">
|
|
80
|
+
{props.line.text}
|
|
81
|
+
</text>
|
|
82
|
+
</box>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function App(props: {
|
|
87
|
+
agent: SoferAgent;
|
|
88
|
+
config: SoferConfig;
|
|
89
|
+
onSubmit: (line: string, signal: AbortSignal) => Promise<void>;
|
|
90
|
+
onExit: () => void;
|
|
91
|
+
}) {
|
|
92
|
+
const dims = useTerminalDimensions();
|
|
93
|
+
|
|
94
|
+
// Spinner tick β only while thinking
|
|
95
|
+
createEffect(() => {
|
|
96
|
+
if (status() !== "thinking") { setSpinnerFrame(0); return; }
|
|
97
|
+
const id = setInterval(
|
|
98
|
+
() => setSpinnerFrame((f) => (f + 1) % SPINNER_FRAMES.length),
|
|
99
|
+
SPINNER_MS,
|
|
100
|
+
);
|
|
101
|
+
onCleanup(() => clearInterval(id));
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
useKeyboard((evt) => {
|
|
105
|
+
if (evt.ctrl && evt.name === "c") { evt.preventDefault(); props.onExit(); return; }
|
|
106
|
+
|
|
107
|
+
if (evt.name === "escape") {
|
|
108
|
+
const ab = activeAbort();
|
|
109
|
+
if (ab && !ab.signal.aborted) ab.abort();
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (status() === "thinking") return; // block input during turns
|
|
114
|
+
|
|
115
|
+
if (evt.name === "return") {
|
|
116
|
+
const text = input().trim();
|
|
117
|
+
if (!text) return;
|
|
118
|
+
if (text === "/exit" || text === "/quit") { props.onExit(); return; }
|
|
119
|
+
addMessage("you", text);
|
|
120
|
+
setInput("");
|
|
121
|
+
const ctrl = new AbortController();
|
|
122
|
+
setActiveAbort(ctrl);
|
|
123
|
+
setStatus("thinking");
|
|
124
|
+
setTurnStartedAt(Date.now());
|
|
125
|
+
props.onSubmit(text, ctrl.signal).finally(() => {
|
|
126
|
+
setStatus("idle");
|
|
127
|
+
setTurnStartedAt(null);
|
|
128
|
+
setActiveAbort(null);
|
|
129
|
+
});
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (evt.name === "backspace" || evt.name === "delete") {
|
|
134
|
+
setInput((p) => p.slice(0, -1));
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (evt.sequence && !evt.ctrl && !evt.meta && !evt.option) {
|
|
139
|
+
setInput((p) => p + evt.sequence);
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const { agent, config } = props;
|
|
144
|
+
const addr = () => `${agent.address.slice(0, 6)}β¦${agent.address.slice(-4)}`;
|
|
145
|
+
|
|
146
|
+
return (
|
|
147
|
+
<box flexDirection="column" width={dims().width} height={dims().height}>
|
|
148
|
+
{/* Scrollable chat history */}
|
|
149
|
+
<scrollbox
|
|
150
|
+
flexGrow={1}
|
|
151
|
+
flexShrink={1}
|
|
152
|
+
stickyScroll
|
|
153
|
+
stickyStart="bottom"
|
|
154
|
+
contentOptions={{ flexDirection: "column", paddingLeft: 0, paddingRight: 1, paddingTop: 1, paddingBottom: 1 }}
|
|
155
|
+
>
|
|
156
|
+
<For each={rows()}>{(line) => <MessageRow line={line} />}</For>
|
|
157
|
+
</scrollbox>
|
|
158
|
+
|
|
159
|
+
{/* Spinner / hint row */}
|
|
160
|
+
<box flexDirection="row" flexShrink={0} paddingLeft={3} paddingRight={2} marginTop={1}>
|
|
161
|
+
<text fg="#67e8f9" flexGrow={1}>
|
|
162
|
+
{(() => {
|
|
163
|
+
if (status() !== "thinking") return " ";
|
|
164
|
+
spinnerFrame(); // reactive dependency
|
|
165
|
+
const elapsed = formatElapsed(turnStartedAt());
|
|
166
|
+
const frame = SPINNER_FRAMES[spinnerFrame()];
|
|
167
|
+
return elapsed
|
|
168
|
+
? `${frame} thinking⦠${elapsed} (esc to interrupt)`
|
|
169
|
+
: `${frame} thinking⦠(esc to interrupt)`;
|
|
170
|
+
})()}
|
|
171
|
+
</text>
|
|
172
|
+
</box>
|
|
173
|
+
|
|
174
|
+
{/* Input box */}
|
|
175
|
+
<box
|
|
176
|
+
flexDirection="row"
|
|
177
|
+
flexShrink={0}
|
|
178
|
+
minHeight={3}
|
|
179
|
+
maxHeight={8}
|
|
180
|
+
borderStyle="rounded"
|
|
181
|
+
borderColor="#374151"
|
|
182
|
+
paddingLeft={1}
|
|
183
|
+
paddingRight={1}
|
|
184
|
+
marginLeft={2}
|
|
185
|
+
marginRight={2}
|
|
186
|
+
>
|
|
187
|
+
<text fg="#67e8f9" flexShrink={0}>{"βΊ "}</text>
|
|
188
|
+
<text wrapMode="word" flexGrow={1} fg="#e5e7eb">
|
|
189
|
+
{`${input()}${status() === "idle" ? "β" : ""}`}
|
|
190
|
+
</text>
|
|
191
|
+
</box>
|
|
192
|
+
|
|
193
|
+
{/* Footer */}
|
|
194
|
+
<box flexDirection="row" flexShrink={0} paddingLeft={2} paddingRight={2}>
|
|
195
|
+
<text fg="#9ca3af">
|
|
196
|
+
{[
|
|
197
|
+
addr(),
|
|
198
|
+
`${config.network}`,
|
|
199
|
+
shortModel(config.brain.model),
|
|
200
|
+
info().balance ? `${info().balance} SUI` : null,
|
|
201
|
+
`in ${agent.usage.tokens.inputTokens} out ${agent.usage.tokens.outputTokens} $${agent.usage.costUsd.toFixed(4)}`,
|
|
202
|
+
"/exit",
|
|
203
|
+
].filter(Boolean).join(" Β· ")}
|
|
204
|
+
</text>
|
|
205
|
+
</box>
|
|
206
|
+
</box>
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ββ Main entry ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
211
|
+
|
|
212
|
+
export async function runTui(
|
|
213
|
+
agent: SoferAgent,
|
|
214
|
+
config: SoferConfig,
|
|
215
|
+
onSubmit: (line: string, signal: AbortSignal) => Promise<void>,
|
|
216
|
+
): Promise<void> {
|
|
217
|
+
return new Promise<void>((resolve) => {
|
|
218
|
+
render(() => (
|
|
219
|
+
<App
|
|
220
|
+
agent={agent}
|
|
221
|
+
config={config}
|
|
222
|
+
onSubmit={onSubmit}
|
|
223
|
+
onExit={resolve}
|
|
224
|
+
/>
|
|
225
|
+
));
|
|
226
|
+
});
|
|
227
|
+
}
|