neagen 0.1.1 → 0.1.3
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/lib/eve-chat.mjs +83 -29
- package/lib/eve-product-tui.mjs +399 -0
- package/lib/tui.mjs +21 -38
- package/lib/turn-stream.mjs +219 -0
- package/neagen.mjs +2 -2
- package/package.json +1 -1
package/lib/eve-chat.mjs
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import { Client } from "eve/client";
|
|
2
|
+
import {
|
|
3
|
+
createTurnStatusSpinner,
|
|
4
|
+
createTurnStreamRenderer,
|
|
5
|
+
readTurnSseStream,
|
|
6
|
+
} from "./turn-stream.mjs";
|
|
2
7
|
|
|
3
8
|
export function createEveClient(host, token) {
|
|
4
9
|
return new Client({
|
|
@@ -107,19 +112,26 @@ export async function sendEveTurn(session, message, options = {}) {
|
|
|
107
112
|
return options.settle ? options.settle(result, session) : result;
|
|
108
113
|
}
|
|
109
114
|
|
|
110
|
-
function
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
115
|
+
function resolveTurnPayload(events) {
|
|
116
|
+
const finalized = events.find((entry) => entry.event === "neagen.finalized")?.data;
|
|
117
|
+
const waiting = events.find((entry) => entry.event === "neagen.waiting_saved")?.data;
|
|
118
|
+
const declined = events.find((entry) => entry.event === "neagen.gate_declined")?.data;
|
|
119
|
+
const legacyTurn = events.find((entry) => entry.event === "turn")?.data;
|
|
120
|
+
|
|
121
|
+
if (waiting) {
|
|
122
|
+
return {
|
|
123
|
+
conversationId: waiting.conversationId,
|
|
124
|
+
runtimeStatus: waiting.runtimeStatus,
|
|
125
|
+
inputRequests: waiting.inputRequests,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
if (finalized) return finalized;
|
|
129
|
+
if (declined) return declined;
|
|
130
|
+
if (legacyTurn) return legacyTurn;
|
|
131
|
+
throw new Error("Turn stream ended without a Neagen result.");
|
|
120
132
|
}
|
|
121
133
|
|
|
122
|
-
async function
|
|
134
|
+
async function postTurnStream(host, token, body, { render } = {}) {
|
|
123
135
|
const res = await fetch(`${host.replace(/\/$/, "")}/api/turns`, {
|
|
124
136
|
method: "POST",
|
|
125
137
|
headers: {
|
|
@@ -130,41 +142,83 @@ async function postTurn(host, token, body) {
|
|
|
130
142
|
body: JSON.stringify(body),
|
|
131
143
|
signal: AbortSignal.timeout(120_000),
|
|
132
144
|
});
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
145
|
+
|
|
146
|
+
const renderer = render ?? createTurnStreamRenderer();
|
|
147
|
+
const spinner = createTurnStatusSpinner("thinking");
|
|
148
|
+
let streamError;
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
const events = await readTurnSseStream(res, (event, data) => {
|
|
152
|
+
spinner.stop();
|
|
153
|
+
if (event === "neagen.error") {
|
|
154
|
+
streamError = new Error(data?.message ?? "Turn failed.");
|
|
155
|
+
}
|
|
156
|
+
renderer.handle(event, data);
|
|
157
|
+
});
|
|
158
|
+
renderer.finish();
|
|
159
|
+
|
|
160
|
+
if (streamError) throw streamError;
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
result: resolveTurnPayload(events),
|
|
164
|
+
assistantTextStreamed: renderer.assistantTextStreamed,
|
|
165
|
+
};
|
|
166
|
+
} finally {
|
|
167
|
+
spinner.stop();
|
|
142
168
|
}
|
|
143
|
-
return parseSseEvents(text).find((entry) => entry.event === "turn")?.data;
|
|
144
169
|
}
|
|
145
170
|
|
|
146
|
-
export async function sendWakeSyncTurn({ host, token, event, handlers }) {
|
|
171
|
+
export async function sendWakeSyncTurn({ host, token, event, handlers, render }) {
|
|
147
172
|
return sendTransportTurn({
|
|
148
173
|
host,
|
|
149
174
|
token,
|
|
150
175
|
conversationId: undefined,
|
|
151
176
|
message: wakeSyncPrompt(event),
|
|
152
177
|
handlers,
|
|
178
|
+
render,
|
|
153
179
|
});
|
|
154
180
|
}
|
|
155
181
|
|
|
156
|
-
export async function sendTransportTurn({
|
|
157
|
-
|
|
182
|
+
export async function sendTransportTurn({
|
|
183
|
+
host,
|
|
184
|
+
token,
|
|
185
|
+
conversationId,
|
|
186
|
+
message,
|
|
187
|
+
handlers,
|
|
188
|
+
render,
|
|
189
|
+
}) {
|
|
190
|
+
const renderer = render ?? createTurnStreamRenderer();
|
|
191
|
+
renderer.reset();
|
|
192
|
+
let assistantTextStreamed = false;
|
|
193
|
+
|
|
194
|
+
let leg = await postTurnStream(
|
|
195
|
+
host,
|
|
196
|
+
token,
|
|
197
|
+
{ conversationId, body: message },
|
|
198
|
+
{ render: renderer },
|
|
199
|
+
);
|
|
200
|
+
assistantTextStreamed ||= leg.assistantTextStreamed;
|
|
201
|
+
let result = leg.result;
|
|
202
|
+
|
|
158
203
|
while ((result?.inputRequests?.length ?? 0) > 0) {
|
|
159
204
|
const request = result.inputRequests[0];
|
|
160
205
|
const response =
|
|
161
206
|
requestToolName(request) === "ask_question"
|
|
162
207
|
? questionResponse(request, await handlers.askQuestion(request))
|
|
163
208
|
: approvalResponse(request, await handlers.askApproval(request));
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
209
|
+
renderer.reset();
|
|
210
|
+
leg = await postTurnStream(
|
|
211
|
+
host,
|
|
212
|
+
token,
|
|
213
|
+
{
|
|
214
|
+
conversationId: result.conversationId,
|
|
215
|
+
inputResponses: [response],
|
|
216
|
+
},
|
|
217
|
+
{ render: renderer },
|
|
218
|
+
);
|
|
219
|
+
assistantTextStreamed ||= leg.assistantTextStreamed;
|
|
220
|
+
result = leg.result;
|
|
168
221
|
}
|
|
169
|
-
|
|
222
|
+
|
|
223
|
+
return { ...result, assistantTextStreamed };
|
|
170
224
|
}
|
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
// Product Neagen TUI: Eve's terminal runner, Neagen's authenticated /api/turns
|
|
2
|
+
// transport. This keeps Eve's TUI/approval/tool rendering while preserving the
|
|
3
|
+
// app's wallet, memory, billing, conversation, and model-selection lifecycle.
|
|
4
|
+
|
|
5
|
+
import { createRequire } from "node:module";
|
|
6
|
+
import { dirname, join } from "node:path";
|
|
7
|
+
import { pathToFileURL } from "node:url";
|
|
8
|
+
import { assertTransportSafe, clearCreds, loadToken, resolveHost } from "./creds.mjs";
|
|
9
|
+
import {
|
|
10
|
+
getSelectedModel,
|
|
11
|
+
listModels,
|
|
12
|
+
probeHost,
|
|
13
|
+
resolveIdentity,
|
|
14
|
+
selectModel,
|
|
15
|
+
} from "./api.mjs";
|
|
16
|
+
import { runDeviceGrantLogin } from "./device-login.mjs";
|
|
17
|
+
import { parseSseChunk } from "./turn-stream.mjs";
|
|
18
|
+
|
|
19
|
+
const require = createRequire(import.meta.url);
|
|
20
|
+
|
|
21
|
+
function eveInternalUrl(relativePath) {
|
|
22
|
+
const root = dirname(require.resolve("eve/package.json"));
|
|
23
|
+
return pathToFileURL(join(root, relativePath)).href;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function loadEveTuiRunner() {
|
|
27
|
+
const mod = await import(eveInternalUrl("dist/src/cli/dev/tui/runner.js"));
|
|
28
|
+
return mod.EveTUIRunner;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function normalizeHost(host) {
|
|
32
|
+
return host.replace(/\/$/, "");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function modelAliases(input) {
|
|
36
|
+
const value = input.trim();
|
|
37
|
+
const lower = value.toLowerCase();
|
|
38
|
+
if (lower === "deepseek" || lower === "flash") return "deepseek/deepseek-v4-flash";
|
|
39
|
+
if (lower === "sonnet" || lower === "claude" || lower === "claude-sonnet") {
|
|
40
|
+
return "anthropic/claude-sonnet-4.6";
|
|
41
|
+
}
|
|
42
|
+
return value;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function formatModel(model) {
|
|
46
|
+
if (!model) return "unknown";
|
|
47
|
+
const label = model.providerLabel && model.name ? `${model.providerLabel} ${model.name}` : model.id;
|
|
48
|
+
return `${label} (${model.id})`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function agentInfoForModel(model, identity) {
|
|
52
|
+
return {
|
|
53
|
+
agent: {
|
|
54
|
+
name: identity?.neagenName ?? "Neagen",
|
|
55
|
+
model: {
|
|
56
|
+
id: model?.id ?? "unknown",
|
|
57
|
+
provider: model?.provider ?? model?.providerLabel,
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
diagnostics: {
|
|
61
|
+
discoveryErrors: 0,
|
|
62
|
+
discoveryWarnings: 0,
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function inputRequestToolName(request) {
|
|
68
|
+
return request?.action?.toolName ?? "input";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function isNeagenCommitEvent(eventName) {
|
|
72
|
+
return eventName === "neagen.finalized" ||
|
|
73
|
+
eventName === "neagen.waiting_saved" ||
|
|
74
|
+
eventName === "neagen.gate_declined";
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function neagenErrorToSessionFailed(data) {
|
|
78
|
+
return {
|
|
79
|
+
type: "session.failed",
|
|
80
|
+
data: {
|
|
81
|
+
error: {
|
|
82
|
+
code: data?.code ?? "turn_failed",
|
|
83
|
+
message: data?.message ?? "Turn failed.",
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function isBoundaryEvent(event) {
|
|
90
|
+
return event?.type === "session.waiting" ||
|
|
91
|
+
event?.type === "session.completed" ||
|
|
92
|
+
event?.type === "session.failed";
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function* readProductTurnEvents(response, onProductEvent) {
|
|
96
|
+
if (!response.ok) {
|
|
97
|
+
let message = `HTTP ${response.status}`;
|
|
98
|
+
const text = await response.text();
|
|
99
|
+
try {
|
|
100
|
+
message = JSON.parse(text)?.error?.message ?? message;
|
|
101
|
+
} catch {
|
|
102
|
+
if (text.trim()) message = text.trim();
|
|
103
|
+
}
|
|
104
|
+
throw new Error(message);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const reader = response.body?.getReader();
|
|
108
|
+
if (!reader) throw new Error("Turn stream is not readable.");
|
|
109
|
+
|
|
110
|
+
const decoder = new TextDecoder();
|
|
111
|
+
let buffer = "";
|
|
112
|
+
let heldBoundary;
|
|
113
|
+
|
|
114
|
+
async function* handleEntry(entry) {
|
|
115
|
+
const { event, data } = entry;
|
|
116
|
+
|
|
117
|
+
if (event === "done") {
|
|
118
|
+
if (heldBoundary) {
|
|
119
|
+
yield heldBoundary;
|
|
120
|
+
heldBoundary = undefined;
|
|
121
|
+
}
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (event === "neagen.error") {
|
|
126
|
+
yield neagenErrorToSessionFailed(data);
|
|
127
|
+
heldBoundary = undefined;
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (isNeagenCommitEvent(event)) {
|
|
132
|
+
onProductEvent(event, data);
|
|
133
|
+
if (heldBoundary) {
|
|
134
|
+
yield heldBoundary;
|
|
135
|
+
heldBoundary = undefined;
|
|
136
|
+
}
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const eveEvent = { type: event, data };
|
|
141
|
+
if (isBoundaryEvent(eveEvent)) {
|
|
142
|
+
heldBoundary = eveEvent;
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
yield eveEvent;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
while (true) {
|
|
149
|
+
const { done, value } = await reader.read();
|
|
150
|
+
if (done) break;
|
|
151
|
+
buffer += decoder.decode(value, { stream: true });
|
|
152
|
+
const parsed = parseSseChunk(buffer);
|
|
153
|
+
buffer = parsed.trailing;
|
|
154
|
+
for (const entry of parsed.events) {
|
|
155
|
+
yield* handleEntry(entry);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (buffer.trim()) {
|
|
160
|
+
const parsed = parseSseChunk(`${buffer}\n\n`);
|
|
161
|
+
for (const entry of parsed.events) {
|
|
162
|
+
yield* handleEntry(entry);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (heldBoundary) yield heldBoundary;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
class ProductMessageResponse {
|
|
170
|
+
constructor(events) {
|
|
171
|
+
this.events = events;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
[Symbol.asyncIterator]() {
|
|
175
|
+
return this.events[Symbol.asyncIterator]();
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
class ProductSession {
|
|
180
|
+
constructor(client, state = {}) {
|
|
181
|
+
this.client = client;
|
|
182
|
+
this.conversationId = state.conversationId;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
get state() {
|
|
186
|
+
return {
|
|
187
|
+
sessionId: this.conversationId,
|
|
188
|
+
streamIndex: 0,
|
|
189
|
+
conversationId: this.conversationId,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async send(input) {
|
|
194
|
+
const payload = typeof input === "string" ? { message: input } : (input ?? {});
|
|
195
|
+
const body = payload.inputResponses?.length
|
|
196
|
+
? { conversationId: this.conversationId, inputResponses: payload.inputResponses }
|
|
197
|
+
: { conversationId: this.conversationId, body: payload.message };
|
|
198
|
+
|
|
199
|
+
if (!body.body && !body.inputResponses) {
|
|
200
|
+
throw new Error("Nothing to send.");
|
|
201
|
+
}
|
|
202
|
+
if (body.inputResponses && !body.conversationId) {
|
|
203
|
+
throw new Error("No Neagen conversation is waiting for approval.");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const response = await fetch(`${this.client.host}/api/turns`, {
|
|
207
|
+
method: "POST",
|
|
208
|
+
headers: {
|
|
209
|
+
authorization: `Bearer ${this.client.token}`,
|
|
210
|
+
"content-type": "application/json",
|
|
211
|
+
accept: "text/event-stream",
|
|
212
|
+
},
|
|
213
|
+
body: JSON.stringify(body),
|
|
214
|
+
signal: payload.signal,
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
const events = readProductTurnEvents(response, (event, data) => {
|
|
218
|
+
if (data?.conversationId) this.conversationId = data.conversationId;
|
|
219
|
+
if (event === "neagen.gate_declined" && data?.conversationId) {
|
|
220
|
+
this.conversationId = data.conversationId;
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
return new ProductMessageResponse(events);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
class ProductClient {
|
|
228
|
+
constructor({ host, token, identity }) {
|
|
229
|
+
this.host = normalizeHost(host);
|
|
230
|
+
this.token = token;
|
|
231
|
+
this.identity = identity;
|
|
232
|
+
this.cachedSelection = undefined;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
session(state) {
|
|
236
|
+
return new ProductSession(this, state);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async selectedModel() {
|
|
240
|
+
const selection = await getSelectedModel(this.host, this.token);
|
|
241
|
+
this.cachedSelection = selection;
|
|
242
|
+
return selection.activeModel;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async info() {
|
|
246
|
+
let model;
|
|
247
|
+
try {
|
|
248
|
+
model = await this.selectedModel();
|
|
249
|
+
} catch {
|
|
250
|
+
model = this.cachedSelection?.activeModel;
|
|
251
|
+
}
|
|
252
|
+
return agentInfoForModel(model, this.identity);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function modelOptions(models, activeId) {
|
|
257
|
+
return models.map((model) => ({
|
|
258
|
+
value: model.id,
|
|
259
|
+
label: `${model.id === activeId ? "● " : ""}${model.name ?? model.id}`,
|
|
260
|
+
description: `${model.providerLabel ?? model.provider ?? ""} · ${model.id}`,
|
|
261
|
+
}));
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function createModelCommandHandler({ host, token, productClient, title }) {
|
|
265
|
+
return {
|
|
266
|
+
async handle(command, context) {
|
|
267
|
+
if (command.name !== "model") {
|
|
268
|
+
return { message: `/${command.name} is not supported in the Neagen TUI.` };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const raw = command.argument.trim();
|
|
272
|
+
if (raw) {
|
|
273
|
+
const updated = await selectModel(host, token, modelAliases(raw));
|
|
274
|
+
productClient.cachedSelection = updated;
|
|
275
|
+
context.renderer.renderAgentHeader?.({
|
|
276
|
+
name: title,
|
|
277
|
+
serverUrl: host,
|
|
278
|
+
info: agentInfoForModel(updated.activeModel, productClient.identity),
|
|
279
|
+
});
|
|
280
|
+
return { message: `Switched model to ${formatModel(updated.activeModel)}.` };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const [selection, models] = await Promise.all([
|
|
284
|
+
getSelectedModel(host, token),
|
|
285
|
+
listModels(host, token),
|
|
286
|
+
]);
|
|
287
|
+
productClient.cachedSelection = selection;
|
|
288
|
+
const activeId = selection.activeModel?.id;
|
|
289
|
+
const flow = context.renderer.setupFlow;
|
|
290
|
+
if (!flow) {
|
|
291
|
+
return {
|
|
292
|
+
message: `Current model: ${formatModel(selection.activeModel)}. Use /model provider/model to switch.`,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
flow.begin("Choose Neagen model", "pulse");
|
|
297
|
+
try {
|
|
298
|
+
const chosen = await flow.readSelect({
|
|
299
|
+
kind: "search",
|
|
300
|
+
message: "Pick the model Neagen should use",
|
|
301
|
+
placeholder: "Filter models",
|
|
302
|
+
initialValue: activeId,
|
|
303
|
+
options: modelOptions(models, activeId),
|
|
304
|
+
});
|
|
305
|
+
const modelId = chosen?.[0];
|
|
306
|
+
if (!modelId) return { message: "/model cancelled." };
|
|
307
|
+
if (modelId === activeId) return { message: `Already using ${formatModel(selection.activeModel)}.` };
|
|
308
|
+
const updated = await selectModel(host, token, modelId);
|
|
309
|
+
productClient.cachedSelection = updated;
|
|
310
|
+
context.renderer.renderAgentHeader?.({
|
|
311
|
+
name: title,
|
|
312
|
+
serverUrl: host,
|
|
313
|
+
info: agentInfoForModel(updated.activeModel, productClient.identity),
|
|
314
|
+
});
|
|
315
|
+
return { message: `Switched model to ${formatModel(updated.activeModel)}.` };
|
|
316
|
+
} finally {
|
|
317
|
+
flow.end({ preserveDiagnostics: false });
|
|
318
|
+
}
|
|
319
|
+
},
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async function ensureReachable(host) {
|
|
324
|
+
process.stdout.write(`Connecting to ${host}...\n`);
|
|
325
|
+
await probeHost(host);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async function ensureAuthenticated({ host, profile }) {
|
|
329
|
+
let token = loadToken(profile, host);
|
|
330
|
+
if (token) {
|
|
331
|
+
const identity = await resolveIdentity(host, token);
|
|
332
|
+
if (identity) return { token, identity };
|
|
333
|
+
clearCreds(profile);
|
|
334
|
+
token = null;
|
|
335
|
+
}
|
|
336
|
+
token = await runDeviceGrantLogin({ host, profile });
|
|
337
|
+
const identity = await resolveIdentity(host, token);
|
|
338
|
+
if (!identity) {
|
|
339
|
+
clearCreds(profile);
|
|
340
|
+
throw new Error("Session could not be verified after login.");
|
|
341
|
+
}
|
|
342
|
+
return { token, identity };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export async function runProductTui({ profile } = {}) {
|
|
346
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
347
|
+
throw new Error("neagen requires an interactive terminal.");
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const host = normalizeHost(resolveHost());
|
|
351
|
+
assertTransportSafe(host);
|
|
352
|
+
await ensureReachable(host);
|
|
353
|
+
const { token, identity } = await ensureAuthenticated({ host, profile });
|
|
354
|
+
const EveTUIRunner = await loadEveTuiRunner();
|
|
355
|
+
const client = new ProductClient({ host, token, identity });
|
|
356
|
+
const title = identity.neagenName ?? "neagen";
|
|
357
|
+
const session = client.session();
|
|
358
|
+
|
|
359
|
+
const selected = await client.selectedModel().catch(() => undefined);
|
|
360
|
+
process.stdout.write(`Agent online. Model: ${selected?.id ?? "unknown"}.\n`);
|
|
361
|
+
|
|
362
|
+
await new EveTUIRunner({
|
|
363
|
+
session,
|
|
364
|
+
client,
|
|
365
|
+
serverUrl: host,
|
|
366
|
+
name: title,
|
|
367
|
+
tools: "full",
|
|
368
|
+
reasoning: "full",
|
|
369
|
+
assistantResponseStats: "tokensPerSecond",
|
|
370
|
+
promptCommandHandler: createModelCommandHandler({ host, token, productClient: client, title }),
|
|
371
|
+
availablePromptCommands: [
|
|
372
|
+
{ name: "help", aliases: [], description: "Show available commands", takesArgument: false },
|
|
373
|
+
{ name: "new", aliases: [], description: "Start a fresh session", takesArgument: false },
|
|
374
|
+
{
|
|
375
|
+
name: "model",
|
|
376
|
+
aliases: [],
|
|
377
|
+
description: "Pick the Neagen model",
|
|
378
|
+
argumentHint: "[provider/model]",
|
|
379
|
+
takesArgument: true,
|
|
380
|
+
},
|
|
381
|
+
{
|
|
382
|
+
name: "loglevel",
|
|
383
|
+
aliases: [],
|
|
384
|
+
description: "Show or hide logs",
|
|
385
|
+
argumentHint: "[all|stderr|sandbox|none]",
|
|
386
|
+
takesArgument: true,
|
|
387
|
+
},
|
|
388
|
+
{ name: "exit", aliases: ["quit"], description: "Quit", takesArgument: false },
|
|
389
|
+
],
|
|
390
|
+
}).run();
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
export {
|
|
394
|
+
createModelCommandHandler,
|
|
395
|
+
ProductClient,
|
|
396
|
+
ProductSession,
|
|
397
|
+
readProductTurnEvents,
|
|
398
|
+
inputRequestToolName,
|
|
399
|
+
};
|
package/lib/tui.mjs
CHANGED
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
// Login happens here (device grant) when no valid session exists.
|
|
3
3
|
|
|
4
4
|
import readline from "node:readline/promises";
|
|
5
|
-
import { clearLine, cursorTo } from "node:readline";
|
|
6
5
|
import { stdin } from "node:process";
|
|
7
6
|
import { dim, bold } from "./ansi.mjs";
|
|
8
7
|
import { assertTransportSafe, clearCreds, loadToken, resolveHost } from "./creds.mjs";
|
|
@@ -21,6 +20,7 @@ import {
|
|
|
21
20
|
sendWakeSyncTurn,
|
|
22
21
|
summarizeResult,
|
|
23
22
|
} from "./eve-chat.mjs";
|
|
23
|
+
import { createTurnStreamRenderer } from "./turn-stream.mjs";
|
|
24
24
|
import { pickModel, readPromptLine } from "./picker.mjs";
|
|
25
25
|
import { SLASH_COMMANDS, slashHelpLines } from "./slash.mjs";
|
|
26
26
|
import { subscribeInboxWakeAbly } from "./wake.mjs";
|
|
@@ -160,31 +160,19 @@ async function askQuestion(rl, request) {
|
|
|
160
160
|
return rl.question(`\n${request.prompt}\n${options ? `${options}\n` : ""}> `);
|
|
161
161
|
}
|
|
162
162
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
process.stdout.write(
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
render();
|
|
178
|
-
const timer = setInterval(render, 450);
|
|
179
|
-
return {
|
|
180
|
-
stop() {
|
|
181
|
-
if (!active) return;
|
|
182
|
-
active = false;
|
|
183
|
-
clearInterval(timer);
|
|
184
|
-
cursorTo(process.stdout, 0);
|
|
185
|
-
clearLine(process.stdout, 0);
|
|
186
|
-
},
|
|
187
|
-
};
|
|
163
|
+
const turnRenderer = createTurnStreamRenderer();
|
|
164
|
+
|
|
165
|
+
function printTurnOutcome(result) {
|
|
166
|
+
if (result?.assistantTextStreamed) {
|
|
167
|
+
process.stdout.write("\n");
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const summary = summarizeResult(result);
|
|
171
|
+
if (summary && summary !== "(no reply)") {
|
|
172
|
+
process.stdout.write(`\n${summary}\n\n`);
|
|
173
|
+
} else {
|
|
174
|
+
process.stdout.write("\n");
|
|
175
|
+
}
|
|
188
176
|
}
|
|
189
177
|
|
|
190
178
|
await ensureReachable();
|
|
@@ -216,23 +204,17 @@ function runExclusive(fn) {
|
|
|
216
204
|
}
|
|
217
205
|
|
|
218
206
|
async function sendTurn(message) {
|
|
219
|
-
const status = startStatus("thinking");
|
|
220
207
|
const result = await sendTransportTurn({
|
|
221
208
|
host,
|
|
222
209
|
token,
|
|
223
210
|
conversationId,
|
|
224
211
|
message,
|
|
212
|
+
render: turnRenderer,
|
|
225
213
|
handlers: {
|
|
226
|
-
askApproval: (request) =>
|
|
227
|
-
|
|
228
|
-
return askApproval(rl, request);
|
|
229
|
-
},
|
|
230
|
-
askQuestion: (request) => {
|
|
231
|
-
status.stop();
|
|
232
|
-
return askQuestion(rl, request);
|
|
233
|
-
},
|
|
214
|
+
askApproval: (request) => askApproval(rl, request),
|
|
215
|
+
askQuestion: (request) => askQuestion(rl, request),
|
|
234
216
|
},
|
|
235
|
-
})
|
|
217
|
+
});
|
|
236
218
|
conversationId = result?.conversationId ?? conversationId;
|
|
237
219
|
return result;
|
|
238
220
|
}
|
|
@@ -247,12 +229,13 @@ try {
|
|
|
247
229
|
host,
|
|
248
230
|
token,
|
|
249
231
|
event: ev,
|
|
232
|
+
render: turnRenderer,
|
|
250
233
|
handlers: {
|
|
251
234
|
askApproval: (request) => askApproval(rl, request),
|
|
252
235
|
askQuestion: (request) => askQuestion(rl, request),
|
|
253
236
|
},
|
|
254
237
|
});
|
|
255
|
-
|
|
238
|
+
printTurnOutcome(result);
|
|
256
239
|
}).catch((e) => {
|
|
257
240
|
process.stderr.write(`! wake sync failed: ${e?.message ?? e}\n`);
|
|
258
241
|
});
|
|
@@ -316,7 +299,7 @@ try {
|
|
|
316
299
|
}
|
|
317
300
|
|
|
318
301
|
const result = await runExclusive(() => sendTurn(line));
|
|
319
|
-
|
|
302
|
+
printTurnOutcome(result);
|
|
320
303
|
} catch (e) {
|
|
321
304
|
process.stderr.write(`! ${e?.message ?? e}\n`);
|
|
322
305
|
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { clearLine, cursorTo } from "node:readline";
|
|
2
|
+
import { stdout } from "node:process";
|
|
3
|
+
import { dim } from "./ansi.mjs";
|
|
4
|
+
|
|
5
|
+
function sseEvent(event, data) {
|
|
6
|
+
return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function parseSseChunk(buffer) {
|
|
10
|
+
const events = [];
|
|
11
|
+
const parts = buffer.split(/\n\n+/);
|
|
12
|
+
const trailing = parts.pop() ?? "";
|
|
13
|
+
|
|
14
|
+
for (const part of parts) {
|
|
15
|
+
const event = part.match(/^event: (.+)$/m)?.[1];
|
|
16
|
+
const dataLine = part.match(/^data: (.+)$/m)?.[1];
|
|
17
|
+
if (!event || !dataLine) continue;
|
|
18
|
+
try {
|
|
19
|
+
events.push({ event, data: JSON.parse(dataLine) });
|
|
20
|
+
} catch {
|
|
21
|
+
/* ignore malformed chunks */
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return { events, trailing };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function readReasoningDelta(data) {
|
|
29
|
+
if (typeof data?.reasoningDelta === "string") return data.reasoningDelta;
|
|
30
|
+
if (typeof data?.delta === "string") return data.delta;
|
|
31
|
+
return "";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function readMessageDelta(data) {
|
|
35
|
+
if (typeof data?.messageDelta === "string") return data.messageDelta;
|
|
36
|
+
if (typeof data?.delta === "string") return data.delta;
|
|
37
|
+
return "";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function createTurnStreamRenderer({ write = (text) => stdout.write(text) } = {}) {
|
|
41
|
+
let assistantLineOpen = false;
|
|
42
|
+
let reasoningLineOpen = false;
|
|
43
|
+
let assistantTextStreamed = false;
|
|
44
|
+
|
|
45
|
+
const closeAssistantLine = () => {
|
|
46
|
+
if (!assistantLineOpen) return;
|
|
47
|
+
write("\n");
|
|
48
|
+
assistantLineOpen = false;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const closeReasoningLine = () => {
|
|
52
|
+
if (!reasoningLineOpen) return;
|
|
53
|
+
write("\n");
|
|
54
|
+
reasoningLineOpen = false;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
get assistantTextStreamed() {
|
|
59
|
+
return assistantTextStreamed;
|
|
60
|
+
},
|
|
61
|
+
reset() {
|
|
62
|
+
assistantTextStreamed = false;
|
|
63
|
+
closeAssistantLine();
|
|
64
|
+
closeReasoningLine();
|
|
65
|
+
},
|
|
66
|
+
handle(eventName, data) {
|
|
67
|
+
switch (eventName) {
|
|
68
|
+
case "turn.started":
|
|
69
|
+
closeAssistantLine();
|
|
70
|
+
closeReasoningLine();
|
|
71
|
+
break;
|
|
72
|
+
case "reasoning.appended": {
|
|
73
|
+
const delta = readReasoningDelta(data);
|
|
74
|
+
if (!delta) break;
|
|
75
|
+
if (!reasoningLineOpen) {
|
|
76
|
+
closeAssistantLine();
|
|
77
|
+
write(`${dim("… ")}`);
|
|
78
|
+
reasoningLineOpen = true;
|
|
79
|
+
}
|
|
80
|
+
write(dim(delta));
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
case "reasoning.completed":
|
|
84
|
+
closeReasoningLine();
|
|
85
|
+
break;
|
|
86
|
+
case "message.appended": {
|
|
87
|
+
const delta = readMessageDelta(data);
|
|
88
|
+
if (!delta) break;
|
|
89
|
+
assistantTextStreamed = true;
|
|
90
|
+
closeReasoningLine();
|
|
91
|
+
if (!assistantLineOpen) {
|
|
92
|
+
write("\n");
|
|
93
|
+
assistantLineOpen = true;
|
|
94
|
+
}
|
|
95
|
+
write(delta);
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
case "message.completed":
|
|
99
|
+
closeAssistantLine();
|
|
100
|
+
break;
|
|
101
|
+
case "actions.requested": {
|
|
102
|
+
closeAssistantLine();
|
|
103
|
+
closeReasoningLine();
|
|
104
|
+
const actions = Array.isArray(data?.actions) ? data.actions : [];
|
|
105
|
+
const labels = actions.map((action) => {
|
|
106
|
+
if (action?.kind === "tool-call" && typeof action.toolName === "string") {
|
|
107
|
+
return action.toolName;
|
|
108
|
+
}
|
|
109
|
+
return action?.kind ?? "action";
|
|
110
|
+
});
|
|
111
|
+
if (labels.length > 0) {
|
|
112
|
+
write(`${dim(`↳ ${labels.join(", ")}`)}\n`);
|
|
113
|
+
}
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
case "action.result": {
|
|
117
|
+
const result = data?.result;
|
|
118
|
+
const name = result?.toolName ?? result?.kind ?? "tool";
|
|
119
|
+
const status = data?.status ?? "completed";
|
|
120
|
+
write(`${dim(` ${status === "completed" ? "✓" : "×"} ${name}`)}\n`);
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
case "input.requested":
|
|
124
|
+
closeAssistantLine();
|
|
125
|
+
closeReasoningLine();
|
|
126
|
+
write(`${dim("… approval required")}\n`);
|
|
127
|
+
break;
|
|
128
|
+
case "neagen.waiting_saved":
|
|
129
|
+
closeAssistantLine();
|
|
130
|
+
closeReasoningLine();
|
|
131
|
+
break;
|
|
132
|
+
case "neagen.error":
|
|
133
|
+
closeAssistantLine();
|
|
134
|
+
closeReasoningLine();
|
|
135
|
+
write(`\n! ${data?.message ?? "Turn failed."}\n`);
|
|
136
|
+
break;
|
|
137
|
+
default:
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
finish() {
|
|
142
|
+
closeAssistantLine();
|
|
143
|
+
closeReasoningLine();
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function createTurnStatusSpinner(label = "thinking") {
|
|
149
|
+
if (!stdout.isTTY) return { stop() {} };
|
|
150
|
+
|
|
151
|
+
let frame = 0;
|
|
152
|
+
let active = true;
|
|
153
|
+
const frames = ["", ".", "..", "..."];
|
|
154
|
+
const render = () => {
|
|
155
|
+
if (!active) return;
|
|
156
|
+
cursorTo(stdout, 0);
|
|
157
|
+
stdout.write(`${label}${frames[frame % frames.length]}`);
|
|
158
|
+
clearLine(stdout, 1);
|
|
159
|
+
frame += 1;
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
render();
|
|
163
|
+
const timer = setInterval(render, 450);
|
|
164
|
+
return {
|
|
165
|
+
stop() {
|
|
166
|
+
if (!active) return;
|
|
167
|
+
active = false;
|
|
168
|
+
clearInterval(timer);
|
|
169
|
+
cursorTo(stdout, 0);
|
|
170
|
+
clearLine(stdout, 0);
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export async function readTurnSseStream(response, onEvent) {
|
|
176
|
+
if (!response.ok) {
|
|
177
|
+
let message = `HTTP ${response.status}`;
|
|
178
|
+
const text = await response.text();
|
|
179
|
+
try {
|
|
180
|
+
message = JSON.parse(text)?.error?.message ?? message;
|
|
181
|
+
} catch {
|
|
182
|
+
if (text.trim()) message = text.trim();
|
|
183
|
+
}
|
|
184
|
+
throw new Error(message);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const reader = response.body?.getReader();
|
|
188
|
+
if (!reader) {
|
|
189
|
+
throw new Error("Turn stream is not readable.");
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const decoder = new TextDecoder();
|
|
193
|
+
let buffer = "";
|
|
194
|
+
const collected = [];
|
|
195
|
+
|
|
196
|
+
while (true) {
|
|
197
|
+
const { done, value } = await reader.read();
|
|
198
|
+
if (done) break;
|
|
199
|
+
buffer += decoder.decode(value, { stream: true });
|
|
200
|
+
const parsed = parseSseChunk(buffer);
|
|
201
|
+
buffer = parsed.trailing;
|
|
202
|
+
for (const entry of parsed.events) {
|
|
203
|
+
collected.push(entry);
|
|
204
|
+
await onEvent?.(entry.event, entry.data);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (buffer.trim()) {
|
|
209
|
+
const parsed = parseSseChunk(`${buffer}\n\n`);
|
|
210
|
+
for (const entry of parsed.events) {
|
|
211
|
+
collected.push(entry);
|
|
212
|
+
await onEvent?.(entry.event, entry.data);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return collected;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export { sseEvent };
|
package/neagen.mjs
CHANGED
|
@@ -62,8 +62,8 @@ async function runTui(profile) {
|
|
|
62
62
|
);
|
|
63
63
|
process.exit(1);
|
|
64
64
|
}
|
|
65
|
-
|
|
66
|
-
await
|
|
65
|
+
const { runProductTui } = await import("./lib/eve-product-tui.mjs");
|
|
66
|
+
await runProductTui({ profile });
|
|
67
67
|
}
|
|
68
68
|
|
|
69
69
|
async function runLogin(profile) {
|
package/package.json
CHANGED