neagen 0.1.0 → 0.1.2
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/ansi.mjs +2 -0
- package/lib/api.mjs +31 -3
- package/lib/device-login.mjs +6 -4
- package/lib/eve-chat.mjs +224 -0
- package/lib/eve-product-tui.mjs +399 -0
- package/lib/picker.mjs +280 -0
- package/lib/slash.mjs +49 -0
- package/lib/tui.mjs +226 -88
- package/lib/turn-stream.mjs +219 -0
- package/neagen.mjs +2 -2
- package/package.json +1 -1
package/lib/ansi.mjs
ADDED
package/lib/api.mjs
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
const DEFAULT_TIMEOUT_MS = 8_000;
|
|
2
|
-
|
|
3
2
|
export async function probeHost(host) {
|
|
4
3
|
const res = await fetch(`${host}/eve/v1/health`, {
|
|
5
4
|
signal: AbortSignal.timeout(5_000),
|
|
@@ -8,7 +7,7 @@ export async function probeHost(host) {
|
|
|
8
7
|
return true;
|
|
9
8
|
}
|
|
10
9
|
|
|
11
|
-
export async function apiFetch(host, path, { token, method = "GET", body } = {}) {
|
|
10
|
+
export async function apiFetch(host, path, { token, method = "GET", body, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
|
12
11
|
const res = await fetch(`${host}${path}`, {
|
|
13
12
|
method,
|
|
14
13
|
headers: {
|
|
@@ -16,12 +15,24 @@ export async function apiFetch(host, path, { token, method = "GET", body } = {})
|
|
|
16
15
|
...(body ? { "content-type": "application/json" } : {}),
|
|
17
16
|
},
|
|
18
17
|
body: body ? JSON.stringify(body) : undefined,
|
|
19
|
-
signal: AbortSignal.timeout(
|
|
18
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
20
19
|
});
|
|
21
20
|
const data = await res.json().catch(() => null);
|
|
22
21
|
return { res, data };
|
|
23
22
|
}
|
|
24
23
|
|
|
24
|
+
function apiErrorMessage(res, data) {
|
|
25
|
+
const serverMessage = data?.error?.message;
|
|
26
|
+
if (serverMessage) return serverMessage;
|
|
27
|
+
return `HTTP ${res.status}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function apiRequest(host, path, { token, method = "GET", body, timeoutMs } = {}) {
|
|
31
|
+
const { res, data } = await apiFetch(host, path, { token, method, body, timeoutMs });
|
|
32
|
+
if (!res.ok) throw new Error(apiErrorMessage(res, data));
|
|
33
|
+
return data;
|
|
34
|
+
}
|
|
35
|
+
|
|
25
36
|
/** Resolve owner identity — /api/me when deployed, else Ably token clientId. */
|
|
26
37
|
export async function resolveIdentity(host, token) {
|
|
27
38
|
const me = await apiFetch(host, "/api/me", { token });
|
|
@@ -54,3 +65,20 @@ export async function resolveIdentity(host, token) {
|
|
|
54
65
|
: (ably.data?.error?.message ?? `identity check failed (${ably.res.status})`),
|
|
55
66
|
);
|
|
56
67
|
}
|
|
68
|
+
|
|
69
|
+
export async function getSelectedModel(host, token) {
|
|
70
|
+
return apiRequest(host, "/api/models/selection", { token });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function selectModel(host, token, modelId) {
|
|
74
|
+
return apiRequest(host, "/api/models/selection", {
|
|
75
|
+
token,
|
|
76
|
+
method: "PATCH",
|
|
77
|
+
body: { modelId },
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function listModels(host, token) {
|
|
82
|
+
const data = await apiRequest(host, "/api/models", { token });
|
|
83
|
+
return data?.models ?? [];
|
|
84
|
+
}
|
package/lib/device-login.mjs
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
// approval page (${host}/device), and polls ${host}/api/auth/device/token until
|
|
5
5
|
// the Owner approves. The grant only ever mints a session for an ALREADY-EXISTING
|
|
6
6
|
// account — it can never create one — so a brand-new user must first create their
|
|
7
|
-
// account on the web (the /device page bounces them to sign-
|
|
8
|
-
// identity root.
|
|
7
|
+
// account on the web (the /device page bounces them to sign-in; first-timers toggle
|
|
8
|
+
// to sign-up on that screen). Passkey stays the identity root.
|
|
9
9
|
|
|
10
10
|
import { execFile } from "node:child_process";
|
|
11
11
|
import { saveCreds } from "./creds.mjs";
|
|
@@ -85,10 +85,12 @@ export async function runDeviceGrantLogin({ host, profile, renderer }) {
|
|
|
85
85
|
|
|
86
86
|
ui.renderLine(`Your code: ${code.user_code}`, "info");
|
|
87
87
|
ui.renderLine(code.verification_uri_complete, "info");
|
|
88
|
-
ui.renderLine("Opening your browser —
|
|
88
|
+
ui.renderLine("Opening your browser — create or sign in, then approve…", "info");
|
|
89
89
|
// Mirror to stderr so login is never silent if the panel hasn't painted yet.
|
|
90
90
|
console.error(
|
|
91
|
-
`\n Your code: ${code.user_code}\n Approve at: ${code.verification_uri_complete}\n
|
|
91
|
+
`\n Your code: ${code.user_code}\n Approve at: ${code.verification_uri_complete}\n\n` +
|
|
92
|
+
` Returning? Sign in with your passkey on that page, then approve this device.\n` +
|
|
93
|
+
` First time? Switch to create your Neagen on that page. Use the same browser throughout.\n`,
|
|
92
94
|
);
|
|
93
95
|
openBrowser(code.verification_uri_complete);
|
|
94
96
|
|
package/lib/eve-chat.mjs
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { Client } from "eve/client";
|
|
2
|
+
import {
|
|
3
|
+
createTurnStatusSpinner,
|
|
4
|
+
createTurnStreamRenderer,
|
|
5
|
+
readTurnSseStream,
|
|
6
|
+
} from "./turn-stream.mjs";
|
|
7
|
+
|
|
8
|
+
export function createEveClient(host, token) {
|
|
9
|
+
return new Client({
|
|
10
|
+
host,
|
|
11
|
+
auth: { bearer: token },
|
|
12
|
+
preserveCompletedSessions: true,
|
|
13
|
+
redirect: "manual",
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function summarizeResult(result) {
|
|
18
|
+
const neagenBody = cleanReply(result?.neagenMessage?.body);
|
|
19
|
+
if (neagenBody) return neagenBody;
|
|
20
|
+
const message = cleanReply(result?.message);
|
|
21
|
+
if (message) return message;
|
|
22
|
+
if (result?.runtimeStatus === "waiting") return "(waiting)";
|
|
23
|
+
if (result?.status) return `(${result.status})`;
|
|
24
|
+
return "(no reply)";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function cleanReply(value) {
|
|
28
|
+
if (typeof value !== "string") return undefined;
|
|
29
|
+
const text = value.trim();
|
|
30
|
+
if (!text || text === "undefined" || text === "null") return undefined;
|
|
31
|
+
return text;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function requestToolName(request) {
|
|
35
|
+
return request?.action?.toolName ?? "input";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function preview(value) {
|
|
39
|
+
if (typeof value !== "string") return undefined;
|
|
40
|
+
return value.length > 120 ? `${value.slice(0, 117)}...` : value;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function formatRequestDetails(request) {
|
|
44
|
+
const input = request?.action?.input;
|
|
45
|
+
if (!input || typeof input !== "object") return "";
|
|
46
|
+
const parts = [];
|
|
47
|
+
if (typeof input.to === "string") parts.push(`to: ${input.to}`);
|
|
48
|
+
if (typeof input.path === "string") parts.push(`path: ${input.path}`);
|
|
49
|
+
if (typeof input.draftId === "string") parts.push(`draft: ${input.draftId}`);
|
|
50
|
+
const body = preview(input.body);
|
|
51
|
+
if (body !== undefined) parts.push(`body: ${JSON.stringify(body)}`);
|
|
52
|
+
return parts.join(", ");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function pickOption(request, approved) {
|
|
56
|
+
const options = request?.options ?? [];
|
|
57
|
+
const pattern = approved ? /approve|allow|yes|confirm/i : /deny|reject|no|cancel/i;
|
|
58
|
+
return (
|
|
59
|
+
options.find((option) => pattern.test(`${option.id} ${option.label}`)) ??
|
|
60
|
+
options[approved ? 0 : options.length - 1]
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function approvalResponse(request, approved) {
|
|
65
|
+
const option = pickOption(request, approved);
|
|
66
|
+
return { requestId: request.requestId, optionId: option?.id ?? (approved ? "approve" : "deny") };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function questionResponse(request, answer) {
|
|
70
|
+
const value = answer.trim();
|
|
71
|
+
const numeric = Number(value);
|
|
72
|
+
const option = Number.isInteger(numeric) ? request.options?.[numeric - 1] : undefined;
|
|
73
|
+
if (option) return { requestId: request.requestId, optionId: option.id };
|
|
74
|
+
|
|
75
|
+
const byText = request.options?.find((entry) => {
|
|
76
|
+
const haystack = `${entry.id} ${entry.label}`.toLowerCase();
|
|
77
|
+
return haystack === value.toLowerCase() || entry.label.toLowerCase() === value.toLowerCase();
|
|
78
|
+
});
|
|
79
|
+
if (byText) return { requestId: request.requestId, optionId: byText.id };
|
|
80
|
+
|
|
81
|
+
return { requestId: request.requestId, text: answer };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function wakeSyncPrompt(event) {
|
|
85
|
+
return (
|
|
86
|
+
`[mail] You just received new mail (${event.kind === "message" ? "a text message" : "a file"}) from a connection. ` +
|
|
87
|
+
"Call sync_inbox once to fetch it, then tell me in one short line who it is from and what it is."
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function settleInputRequests(result, session, handlers) {
|
|
92
|
+
let settled = result;
|
|
93
|
+
while ((settled.inputRequests?.length ?? 0) > 0) {
|
|
94
|
+
const request = settled.inputRequests[0];
|
|
95
|
+
const response =
|
|
96
|
+
requestToolName(request) === "ask_question"
|
|
97
|
+
? questionResponse(request, await handlers.askQuestion(request))
|
|
98
|
+
: approvalResponse(request, await handlers.askApproval(request));
|
|
99
|
+
|
|
100
|
+
const resumed = await session.send({ inputResponses: [response] });
|
|
101
|
+
settled = await resumed.result();
|
|
102
|
+
}
|
|
103
|
+
return settled;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function sendEveTurn(session, message, options = {}) {
|
|
107
|
+
const response = await session.send({
|
|
108
|
+
message,
|
|
109
|
+
...(options.clientContext ? { clientContext: options.clientContext } : {}),
|
|
110
|
+
});
|
|
111
|
+
const result = await response.result();
|
|
112
|
+
return options.settle ? options.settle(result, session) : result;
|
|
113
|
+
}
|
|
114
|
+
|
|
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.");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function postTurnStream(host, token, body, { render } = {}) {
|
|
135
|
+
const res = await fetch(`${host.replace(/\/$/, "")}/api/turns`, {
|
|
136
|
+
method: "POST",
|
|
137
|
+
headers: {
|
|
138
|
+
authorization: `Bearer ${token}`,
|
|
139
|
+
"content-type": "application/json",
|
|
140
|
+
accept: "text/event-stream",
|
|
141
|
+
},
|
|
142
|
+
body: JSON.stringify(body),
|
|
143
|
+
signal: AbortSignal.timeout(120_000),
|
|
144
|
+
});
|
|
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();
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export async function sendWakeSyncTurn({ host, token, event, handlers, render }) {
|
|
172
|
+
return sendTransportTurn({
|
|
173
|
+
host,
|
|
174
|
+
token,
|
|
175
|
+
conversationId: undefined,
|
|
176
|
+
message: wakeSyncPrompt(event),
|
|
177
|
+
handlers,
|
|
178
|
+
render,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
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
|
+
|
|
203
|
+
while ((result?.inputRequests?.length ?? 0) > 0) {
|
|
204
|
+
const request = result.inputRequests[0];
|
|
205
|
+
const response =
|
|
206
|
+
requestToolName(request) === "ask_question"
|
|
207
|
+
? questionResponse(request, await handlers.askQuestion(request))
|
|
208
|
+
: approvalResponse(request, await handlers.askApproval(request));
|
|
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;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return { ...result, assistantTextStreamed };
|
|
224
|
+
}
|