neagen 0.1.0 → 0.1.1

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 ADDED
@@ -0,0 +1,2 @@
1
+ export const dim = (text) => `\x1b[2m${text}\x1b[0m`;
2
+ export const bold = (text) => `\x1b[1m${text}\x1b[0m`;
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(DEFAULT_TIMEOUT_MS),
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
+ }
@@ -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-up). Passkey stays the
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 — approve with your passkey…", "info");
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
 
@@ -0,0 +1,170 @@
1
+ import { Client } from "eve/client";
2
+
3
+ export function createEveClient(host, token) {
4
+ return new Client({
5
+ host,
6
+ auth: { bearer: token },
7
+ preserveCompletedSessions: true,
8
+ redirect: "manual",
9
+ });
10
+ }
11
+
12
+ export function summarizeResult(result) {
13
+ const neagenBody = cleanReply(result?.neagenMessage?.body);
14
+ if (neagenBody) return neagenBody;
15
+ const message = cleanReply(result?.message);
16
+ if (message) return message;
17
+ if (result?.runtimeStatus === "waiting") return "(waiting)";
18
+ if (result?.status) return `(${result.status})`;
19
+ return "(no reply)";
20
+ }
21
+
22
+ function cleanReply(value) {
23
+ if (typeof value !== "string") return undefined;
24
+ const text = value.trim();
25
+ if (!text || text === "undefined" || text === "null") return undefined;
26
+ return text;
27
+ }
28
+
29
+ export function requestToolName(request) {
30
+ return request?.action?.toolName ?? "input";
31
+ }
32
+
33
+ function preview(value) {
34
+ if (typeof value !== "string") return undefined;
35
+ return value.length > 120 ? `${value.slice(0, 117)}...` : value;
36
+ }
37
+
38
+ export function formatRequestDetails(request) {
39
+ const input = request?.action?.input;
40
+ if (!input || typeof input !== "object") return "";
41
+ const parts = [];
42
+ if (typeof input.to === "string") parts.push(`to: ${input.to}`);
43
+ if (typeof input.path === "string") parts.push(`path: ${input.path}`);
44
+ if (typeof input.draftId === "string") parts.push(`draft: ${input.draftId}`);
45
+ const body = preview(input.body);
46
+ if (body !== undefined) parts.push(`body: ${JSON.stringify(body)}`);
47
+ return parts.join(", ");
48
+ }
49
+
50
+ export function pickOption(request, approved) {
51
+ const options = request?.options ?? [];
52
+ const pattern = approved ? /approve|allow|yes|confirm/i : /deny|reject|no|cancel/i;
53
+ return (
54
+ options.find((option) => pattern.test(`${option.id} ${option.label}`)) ??
55
+ options[approved ? 0 : options.length - 1]
56
+ );
57
+ }
58
+
59
+ export function approvalResponse(request, approved) {
60
+ const option = pickOption(request, approved);
61
+ return { requestId: request.requestId, optionId: option?.id ?? (approved ? "approve" : "deny") };
62
+ }
63
+
64
+ export function questionResponse(request, answer) {
65
+ const value = answer.trim();
66
+ const numeric = Number(value);
67
+ const option = Number.isInteger(numeric) ? request.options?.[numeric - 1] : undefined;
68
+ if (option) return { requestId: request.requestId, optionId: option.id };
69
+
70
+ const byText = request.options?.find((entry) => {
71
+ const haystack = `${entry.id} ${entry.label}`.toLowerCase();
72
+ return haystack === value.toLowerCase() || entry.label.toLowerCase() === value.toLowerCase();
73
+ });
74
+ if (byText) return { requestId: request.requestId, optionId: byText.id };
75
+
76
+ return { requestId: request.requestId, text: answer };
77
+ }
78
+
79
+ export function wakeSyncPrompt(event) {
80
+ return (
81
+ `[mail] You just received new mail (${event.kind === "message" ? "a text message" : "a file"}) from a connection. ` +
82
+ "Call sync_inbox once to fetch it, then tell me in one short line who it is from and what it is."
83
+ );
84
+ }
85
+
86
+ export async function settleInputRequests(result, session, handlers) {
87
+ let settled = result;
88
+ while ((settled.inputRequests?.length ?? 0) > 0) {
89
+ const request = settled.inputRequests[0];
90
+ const response =
91
+ requestToolName(request) === "ask_question"
92
+ ? questionResponse(request, await handlers.askQuestion(request))
93
+ : approvalResponse(request, await handlers.askApproval(request));
94
+
95
+ const resumed = await session.send({ inputResponses: [response] });
96
+ settled = await resumed.result();
97
+ }
98
+ return settled;
99
+ }
100
+
101
+ export async function sendEveTurn(session, message, options = {}) {
102
+ const response = await session.send({
103
+ message,
104
+ ...(options.clientContext ? { clientContext: options.clientContext } : {}),
105
+ });
106
+ const result = await response.result();
107
+ return options.settle ? options.settle(result, session) : result;
108
+ }
109
+
110
+ function parseSseEvents(text) {
111
+ return text
112
+ .split(/\n\n+/)
113
+ .map((chunk) => {
114
+ const event = chunk.match(/^event: (.+)$/m)?.[1];
115
+ const data = chunk.match(/^data: (.+)$/m)?.[1];
116
+ if (!event || !data) return undefined;
117
+ return { event, data: JSON.parse(data) };
118
+ })
119
+ .filter(Boolean);
120
+ }
121
+
122
+ async function postTurn(host, token, body) {
123
+ const res = await fetch(`${host.replace(/\/$/, "")}/api/turns`, {
124
+ method: "POST",
125
+ headers: {
126
+ authorization: `Bearer ${token}`,
127
+ "content-type": "application/json",
128
+ accept: "text/event-stream",
129
+ },
130
+ body: JSON.stringify(body),
131
+ signal: AbortSignal.timeout(120_000),
132
+ });
133
+ const text = await res.text();
134
+ if (!res.ok) {
135
+ let message = `HTTP ${res.status}`;
136
+ try {
137
+ message = JSON.parse(text)?.error?.message ?? message;
138
+ } catch {
139
+ if (text.trim()) message = text.trim();
140
+ }
141
+ throw new Error(message);
142
+ }
143
+ return parseSseEvents(text).find((entry) => entry.event === "turn")?.data;
144
+ }
145
+
146
+ export async function sendWakeSyncTurn({ host, token, event, handlers }) {
147
+ return sendTransportTurn({
148
+ host,
149
+ token,
150
+ conversationId: undefined,
151
+ message: wakeSyncPrompt(event),
152
+ handlers,
153
+ });
154
+ }
155
+
156
+ export async function sendTransportTurn({ host, token, conversationId, message, handlers }) {
157
+ let result = await postTurn(host, token, { conversationId, body: message });
158
+ while ((result?.inputRequests?.length ?? 0) > 0) {
159
+ const request = result.inputRequests[0];
160
+ const response =
161
+ requestToolName(request) === "ask_question"
162
+ ? questionResponse(request, await handlers.askQuestion(request))
163
+ : approvalResponse(request, await handlers.askApproval(request));
164
+ result = await postTurn(host, token, {
165
+ conversationId: result.conversationId,
166
+ inputResponses: [response],
167
+ });
168
+ }
169
+ return result;
170
+ }
package/lib/picker.mjs ADDED
@@ -0,0 +1,280 @@
1
+ // Interactive terminal picker — type to filter, arrows to move, enter to select.
2
+
3
+ import { emitKeypressEvents } from "node:readline";
4
+ import { stdin, stdout } from "node:process";
5
+ import { dim, bold } from "./ansi.mjs";
6
+ import { slashCommandMatches, slashCompletionSuffix } from "./slash.mjs";
7
+
8
+ const DEFAULT_WINDOW = 10;
9
+
10
+ /**
11
+ * @param {import("node:readline/promises").Interface} rl
12
+ * @param {object} options
13
+ * @param {readonly unknown[]} options.items
14
+ * @param {(item: unknown, filter: string) => boolean} options.matchItem
15
+ * @param {(item: unknown, selected: boolean, active: boolean) => string} options.formatRow
16
+ * @param {string[]} options.headerLines
17
+ * @param {number} [options.windowSize]
18
+ * @param {number} [options.initialIndex]
19
+ */
20
+ export function runPicker(rl, options) {
21
+ const {
22
+ items,
23
+ matchItem,
24
+ formatRow,
25
+ headerLines,
26
+ windowSize = DEFAULT_WINDOW,
27
+ initialIndex = 0,
28
+ } = options;
29
+
30
+ return new Promise((resolve) => {
31
+ let filter = "";
32
+ let view = [...items];
33
+ let index = Math.min(Math.max(0, initialIndex), Math.max(0, view.length - 1));
34
+ let offset = Math.max(0, Math.min(index - 2, Math.max(0, view.length - windowSize)));
35
+
36
+ const refilter = () => {
37
+ view = items.filter((item) => matchItem(item, filter));
38
+ index = 0;
39
+ offset = 0;
40
+ };
41
+
42
+ rl.pause();
43
+ const prevRaw = stdin.isRaw;
44
+ const savedKeypress = stdin.listeners("keypress");
45
+ stdin.removeAllListeners("keypress");
46
+ emitKeypressEvents(stdin);
47
+ if (stdin.isTTY) stdin.setRawMode(true);
48
+ stdin.resume();
49
+
50
+ let lastLines = 0;
51
+ const clearRender = () => {
52
+ if (lastLines > 0) stdout.write(`\x1b[${lastLines}A\x1b[J`);
53
+ };
54
+ const render = () => {
55
+ clearRender();
56
+ const lines = [...headerLines, dim(` filter: ${filter || "(all)"} · ${view.length} match`)];
57
+ if (view.length === 0) {
58
+ lines.push(dim(" no matches"));
59
+ } else {
60
+ for (let i = offset; i < Math.min(offset + windowSize, view.length); i++) {
61
+ lines.push(formatRow(view[i], i === index, false));
62
+ }
63
+ }
64
+ stdout.write(`${lines.join("\n")}\n`);
65
+ lastLines = lines.length;
66
+ };
67
+
68
+ const finish = (result) => {
69
+ stdin.removeListener("keypress", onKey);
70
+ clearRender();
71
+ if (stdin.isTTY) stdin.setRawMode(prevRaw);
72
+ stdin.removeAllListeners("keypress");
73
+ for (const listener of savedKeypress) stdin.on("keypress", listener);
74
+ rl.resume();
75
+ resolve(result);
76
+ };
77
+
78
+ function onKey(str, key) {
79
+ if (!key) return;
80
+ if (key.ctrl && key.name === "c") return finish(null);
81
+ if (key.name === "escape") return finish(null);
82
+ if (key.name === "return" || key.name === "enter") return finish(view[index] ?? null);
83
+ if (key.name === "up") {
84
+ if (index > 0) index--;
85
+ } else if (key.name === "down") {
86
+ if (index < view.length - 1) index++;
87
+ } else if (key.name === "backspace") {
88
+ filter = filter.slice(0, -1);
89
+ refilter();
90
+ } else if (str && str.length === 1 && !key.ctrl && !key.meta && str >= " ") {
91
+ filter += str;
92
+ refilter();
93
+ } else {
94
+ return;
95
+ }
96
+ if (index < offset) offset = index;
97
+ if (index >= offset + windowSize) offset = index - windowSize + 1;
98
+ render();
99
+ }
100
+
101
+ stdin.on("keypress", onKey);
102
+ render();
103
+ });
104
+ }
105
+
106
+ export function pickModel(rl, models, activeId) {
107
+ const initialIndex = Math.max(0, models.findIndex((model) => model.id === activeId));
108
+ return runPicker(rl, {
109
+ items: models,
110
+ initialIndex,
111
+ headerLines: [dim(" pick a model — type to filter · ↑/↓ move · enter select · esc cancel")],
112
+ matchItem: (model, filter) => {
113
+ const haystack = `${model.name ?? ""} ${model.providerLabel ?? ""} ${model.id}`.toLowerCase();
114
+ return haystack.includes(filter.toLowerCase());
115
+ },
116
+ formatRow: (model, selected, _active) => {
117
+ const arrow = selected ? bold("›") : " ";
118
+ const mark = model.id === activeId ? "●" : " ";
119
+ const name = selected ? bold(model.name ?? model.id) : (model.name ?? model.id);
120
+ const meta = dim(
121
+ `${model.providerLabel ?? ""} · $${model.inputUsdPerMillionTokens ?? "?"}/$${model.outputUsdPerMillionTokens ?? "?"} per M`,
122
+ );
123
+ return ` ${arrow} ${mark} ${name} ${meta}`;
124
+ },
125
+ });
126
+ }
127
+
128
+ export function pickSlashCommand(rl, commands) {
129
+ return runPicker(rl, {
130
+ items: commands,
131
+ headerLines: [dim(" pick a command — type to filter · ↑/↓ move · enter select · esc cancel")],
132
+ matchItem: (entry, filter) => {
133
+ const haystack = `${entry.cmd} ${entry.help}`.toLowerCase();
134
+ return haystack.includes(filter.toLowerCase());
135
+ },
136
+ formatRow: (entry, selected) => {
137
+ const arrow = selected ? bold("›") : " ";
138
+ const cmd = selected ? bold(entry.cmd) : entry.cmd;
139
+ return ` ${arrow} ${cmd.padEnd(11)} ${dim(entry.help)}`;
140
+ },
141
+ });
142
+ }
143
+
144
+ export function readPromptLine(rl, options) {
145
+ const {
146
+ prompt,
147
+ slashCommands = [],
148
+ } = options;
149
+
150
+ return new Promise((resolve, reject) => {
151
+ let buffer = "";
152
+ let selectedIndex = 0;
153
+ let matches = [];
154
+ let lastLines = 0;
155
+
156
+ function visibleSlashMatches() {
157
+ if (!buffer.startsWith("/") || buffer.includes(" ")) return [];
158
+ return slashCommandMatches(buffer, slashCommands);
159
+ }
160
+
161
+ function clampSelection() {
162
+ if (matches.length === 0) {
163
+ selectedIndex = 0;
164
+ return;
165
+ }
166
+ selectedIndex = Math.max(0, Math.min(selectedIndex, matches.length - 1));
167
+ }
168
+
169
+ function clearRender() {
170
+ if (lastLines > 0) stdout.write(`\x1b[${lastLines}A\x1b[J`);
171
+ }
172
+
173
+ function render() {
174
+ matches = visibleSlashMatches();
175
+ clampSelection();
176
+
177
+ clearRender();
178
+ const suffix = slashCompletionSuffix(buffer);
179
+ const lines = [`${prompt}${buffer}${suffix ? dim(suffix) : ""}`];
180
+
181
+ if (buffer.startsWith("/") && !buffer.includes(" ")) {
182
+ lines.push(dim(" pick a command - type to filter · ↑/↓ move · tab complete · enter select"));
183
+ if (matches.length === 0) {
184
+ lines.push(dim(" no matches"));
185
+ } else {
186
+ for (let i = 0; i < matches.length; i++) {
187
+ const entry = matches[i];
188
+ const arrow = i === selectedIndex ? bold("›") : " ";
189
+ const cmd = i === selectedIndex ? bold(entry.cmd) : entry.cmd;
190
+ lines.push(` ${arrow} ${cmd.padEnd(11)} ${dim(entry.help)}`);
191
+ }
192
+ }
193
+ }
194
+
195
+ stdout.write(`${lines.join("\n")}\n`);
196
+ lastLines = lines.length;
197
+ }
198
+
199
+ function finish(value) {
200
+ stdin.removeListener("keypress", onKey);
201
+ clearRender();
202
+ if (value !== null) stdout.write(`${prompt}${value}\n`);
203
+ if (stdin.isTTY) stdin.setRawMode(prevRaw);
204
+ stdin.removeAllListeners("keypress");
205
+ for (const listener of savedKeypress) stdin.on("keypress", listener);
206
+ rl.resume();
207
+ resolve(value);
208
+ }
209
+
210
+ function acceptSlashSelection() {
211
+ if (!buffer.startsWith("/") || buffer.includes(" ")) return false;
212
+ const suffix = slashCompletionSuffix(buffer);
213
+ if (suffix) {
214
+ buffer += suffix;
215
+ return true;
216
+ }
217
+ if (matches.length === 0) return false;
218
+ buffer = matches[selectedIndex]?.cmd ?? buffer;
219
+ return true;
220
+ }
221
+
222
+ function onKey(str, key) {
223
+ if (!key) return;
224
+ if (key.ctrl && key.name === "c") return finish(null);
225
+ if (key.ctrl && key.name === "d" && buffer.length === 0) return finish(null);
226
+ if (key.name === "return" || key.name === "enter") {
227
+ if (acceptSlashSelection()) return finish(buffer);
228
+ return finish(buffer);
229
+ }
230
+ if (key.name === "tab") {
231
+ const suffix = slashCompletionSuffix(buffer);
232
+ if (suffix) {
233
+ buffer += suffix;
234
+ selectedIndex = 0;
235
+ return render();
236
+ }
237
+ if (acceptSlashSelection()) return render();
238
+ return;
239
+ }
240
+ if (key.name === "escape") {
241
+ buffer = "";
242
+ selectedIndex = 0;
243
+ return render();
244
+ }
245
+ if (key.name === "backspace") {
246
+ buffer = buffer.slice(0, -1);
247
+ selectedIndex = 0;
248
+ return render();
249
+ }
250
+ if (key.name === "up" && matches.length > 0) {
251
+ selectedIndex = Math.max(0, selectedIndex - 1);
252
+ return render();
253
+ }
254
+ if (key.name === "down" && matches.length > 0) {
255
+ selectedIndex = Math.min(matches.length - 1, selectedIndex + 1);
256
+ return render();
257
+ }
258
+ if (str && str.length === 1 && !key.ctrl && !key.meta && str >= " ") {
259
+ buffer += str;
260
+ selectedIndex = 0;
261
+ return render();
262
+ }
263
+ }
264
+
265
+ rl.pause();
266
+ const prevRaw = stdin.isRaw;
267
+ const savedKeypress = stdin.listeners("keypress");
268
+ stdin.removeAllListeners("keypress");
269
+ emitKeypressEvents(stdin);
270
+ if (stdin.isTTY) stdin.setRawMode(true);
271
+ stdin.resume();
272
+
273
+ try {
274
+ stdin.on("keypress", onKey);
275
+ render();
276
+ } catch (error) {
277
+ reject(error);
278
+ }
279
+ });
280
+ }
package/lib/slash.mjs ADDED
@@ -0,0 +1,49 @@
1
+ // Slash commands for the neagen TUI — single source for /help and command selection.
2
+
3
+ export const SLASH_COMMANDS = [
4
+ { cmd: "/model", help: "pick a model — type to filter, arrows to move, enter to select" },
5
+ { cmd: "/new", help: "start a fresh conversation" },
6
+ { cmd: "/help", help: "show commands" },
7
+ { cmd: "/exit", help: "quit (also /quit, Ctrl-D, Ctrl-C)" },
8
+ ];
9
+
10
+ /** Hidden alias — completable, not listed in /help. */
11
+ export const SLASH_ALIASES = ["/quit"];
12
+
13
+ export const SLASH_NAMES = [...SLASH_COMMANDS.map((entry) => entry.cmd), ...SLASH_ALIASES];
14
+
15
+ export function slashQuery(line) {
16
+ if (!line.startsWith("/") || line.includes(" ")) return null;
17
+ return line.toLowerCase();
18
+ }
19
+
20
+ export function slashCommandMatches(line, commands = SLASH_COMMANDS) {
21
+ const query = slashQuery(line);
22
+ if (query === null) return [];
23
+ return commands.filter((entry) => entry.cmd.toLowerCase().startsWith(query));
24
+ }
25
+
26
+ export function slashCompletionSuffix(line) {
27
+ const query = slashQuery(line);
28
+ if (query === null) return "";
29
+ const hits = SLASH_NAMES.filter((name) => name.toLowerCase().startsWith(query));
30
+ if (hits.length !== 1) return "";
31
+ return hits[0].slice(line.length);
32
+ }
33
+
34
+ /**
35
+ * readline completer: Tab-completes slash commands on the command word only
36
+ * (leading "/" with no space yet) so Tab never hijacks normal chat input.
37
+ */
38
+ export function completeSlash(line) {
39
+ if (!line.startsWith("/") || line.includes(" ")) return [[], line];
40
+ const query = line.toLowerCase();
41
+ const hits = SLASH_NAMES.filter((name) => name.startsWith(query));
42
+ return [hits, line];
43
+ }
44
+
45
+ export function slashHelpLines() {
46
+ const rows = SLASH_COMMANDS.map(({ cmd, help }) => ` ${cmd.padEnd(11)} ${help}`);
47
+ rows.push(" (type / to open the command selector; Tab accepts the prefill)");
48
+ return rows;
49
+ }
package/lib/tui.mjs CHANGED
@@ -1,28 +1,30 @@
1
- // neagen TUI — chat, agent-mail, files against the deployed agent.
2
- // Login happens here (device-grant panel) when no valid session exists.
3
- //
4
- // Resolves Eve's TUI internals from the INSTALLED `eve` package (via
5
- // require.resolve), so it works no matter where npm puts the dependency — global
6
- // install, npx cache, or this repo's node_modules. `eve` is a hard dependency of
7
- // this package (see ../package.json), pinned to an exact version because these
8
- // dist paths are internal to that version.
9
-
10
- import { createRequire } from "node:module";
11
- import { dirname, join } from "node:path";
12
- import { pathToFileURL } from "node:url";
13
- import { Client } from "eve/client";
1
+ // neagen CLI — chat through Neagen's server-mediated turn transport.
2
+ // Login happens here (device grant) when no valid session exists.
3
+
4
+ import readline from "node:readline/promises";
5
+ import { clearLine, cursorTo } from "node:readline";
6
+ import { stdin } from "node:process";
7
+ import { dim, bold } from "./ansi.mjs";
14
8
  import { assertTransportSafe, clearCreds, loadToken, resolveHost } from "./creds.mjs";
15
- import { probeHost, resolveIdentity } from "./api.mjs";
9
+ import {
10
+ getSelectedModel,
11
+ listModels,
12
+ probeHost,
13
+ resolveIdentity,
14
+ selectModel,
15
+ } from "./api.mjs";
16
16
  import { runDeviceGrantLogin } from "./device-login.mjs";
17
- import { panel } from "./flow.mjs";
17
+ import {
18
+ formatRequestDetails,
19
+ requestToolName,
20
+ sendTransportTurn,
21
+ sendWakeSyncTurn,
22
+ summarizeResult,
23
+ } from "./eve-chat.mjs";
24
+ import { pickModel, readPromptLine } from "./picker.mjs";
25
+ import { SLASH_COMMANDS, slashHelpLines } from "./slash.mjs";
18
26
  import { subscribeInboxWakeAbly } from "./wake.mjs";
19
27
 
20
- const require = createRequire(import.meta.url);
21
- // `eve` exposes "./package.json" in its exports map, so this resolves the
22
- // installed package root regardless of hoisting.
23
- const EVE_DIST = join(dirname(require.resolve("eve/package.json")), "dist", "src");
24
- const EVE_TUI = join(EVE_DIST, "cli", "dev", "tui");
25
-
26
28
  const profile = process.env.NEAGEN_PROFILE?.trim() || undefined;
27
29
  const host = resolveHost();
28
30
 
@@ -38,59 +40,21 @@ try {
38
40
  process.exit(1);
39
41
  }
40
42
 
41
- async function importEve(relPath) {
42
- return import(pathToFileURL(join(EVE_DIST, relPath)).href);
43
- }
44
-
45
- const [
46
- { EveTUIRunner },
47
- { createPromptCommandHandler },
48
- { promptCommandsFor },
49
- { formatRemoteAuthChallengeMessage },
50
- { toErrorMessage },
51
- { isVercelAuthChallenge },
52
- { TerminalRenderer },
53
- ] = await Promise.all([
54
- import(pathToFileURL(join(EVE_TUI, "runner.js")).href),
55
- import(pathToFileURL(join(EVE_TUI, "prompt-command-handler.js")).href),
56
- import(pathToFileURL(join(EVE_TUI, "prompt-commands.js")).href),
57
- import(pathToFileURL(join(EVE_TUI, "remote-auth-result.js")).href),
58
- importEve("shared/errors.js"),
59
- importEve("services/dev-client/vercel-auth-error.js"),
60
- import(pathToFileURL(join(EVE_TUI, "terminal-renderer.js")).href),
61
- ]);
62
-
63
- const target = {
64
- kind: "remote",
65
- serverUrl: host,
66
- workspaceRoot: process.cwd(),
67
- };
68
-
69
- const renderer = new TerminalRenderer({ availablePromptCommands: promptCommandsFor("remote") });
70
-
71
43
  function fail(message) {
72
- try {
73
- panel(renderer).renderLine(message, "error");
74
- } catch {
75
- console.error(`\n ${message}\n`);
76
- }
44
+ console.error(`\n ${message}\n`);
77
45
  process.exit(1);
78
46
  }
79
47
 
80
48
  async function ensureReachable() {
81
- const ui = panel(renderer);
82
- ui.begin("neagen", "pulse");
83
- ui.renderLine(`Connecting to ${host}…`, "info");
49
+ process.stdout.write(`Connecting to ${host}...\n`);
84
50
  try {
85
51
  await probeHost(host);
86
- ui.renderLine("Agent online.", "success");
52
+ process.stdout.write("Agent online.\n");
87
53
  } catch (e) {
88
- ui.renderLine(`Can't reach ${host} (${e.message}).`, "error");
89
- ui.renderLine("Check your connection, or set NEAGEN_HOST to your server.", "info");
90
- ui.end({ preserveDiagnostics: true });
54
+ process.stderr.write(`Can't reach ${host} (${e.message}).\n`);
55
+ process.stderr.write("Check your connection, or set NEAGEN_HOST to your server.\n");
91
56
  process.exit(1);
92
57
  }
93
- ui.end({ preserveDiagnostics: false });
94
58
  }
95
59
 
96
60
  async function ensureAuthenticated() {
@@ -101,7 +65,7 @@ async function ensureAuthenticated() {
101
65
  clearCreds(profile);
102
66
  token = null;
103
67
  }
104
- token = await runDeviceGrantLogin({ host, profile, renderer });
68
+ token = await runDeviceGrantLogin({ host, profile });
105
69
  const me = await resolveIdentity(host, token);
106
70
  if (!me) {
107
71
  clearCreds(profile);
@@ -110,6 +74,119 @@ async function ensureAuthenticated() {
110
74
  return { token, me };
111
75
  }
112
76
 
77
+ function formatModel(model) {
78
+ if (!model) return "unknown";
79
+ const label = model.providerLabel && model.name ? `${model.providerLabel} ${model.name}` : model.id;
80
+ return `${label} (${model.id})`;
81
+ }
82
+
83
+ function modelAliases(input) {
84
+ const value = input.trim();
85
+ const lower = value.toLowerCase();
86
+ if (lower === "deepseek" || lower === "flash") return "deepseek/deepseek-v4-flash";
87
+ if (lower === "sonnet" || lower === "claude" || lower === "claude-sonnet") {
88
+ return "anthropic/claude-sonnet-4.6";
89
+ }
90
+ return value;
91
+ }
92
+
93
+ async function runModelPicker(rl, token) {
94
+ let selection;
95
+ let models;
96
+ try {
97
+ [selection, models] = await Promise.all([
98
+ getSelectedModel(host, token),
99
+ listModels(host, token),
100
+ ]);
101
+ } catch (e) {
102
+ process.stderr.write(`! ${e?.message ?? e}\n`);
103
+ return;
104
+ }
105
+
106
+ const activeId = selection?.activeModel?.id;
107
+ const chosen = await pickModel(rl, models, activeId);
108
+ if (!chosen) {
109
+ process.stdout.write(`${dim("(no change)")}\n`);
110
+ return;
111
+ }
112
+ if (chosen.id === activeId) {
113
+ process.stdout.write(`${dim(`already using ${formatModel(chosen)}`)}\n`);
114
+ return;
115
+ }
116
+
117
+ try {
118
+ const updated = await selectModel(host, token, chosen.id);
119
+ process.stdout.write(`Switched model to ${bold(formatModel(updated.activeModel))}.\n`);
120
+ } catch (e) {
121
+ process.stderr.write(`! ${e?.message ?? e}\n`);
122
+ }
123
+ }
124
+
125
+ async function switchModelByName(token, raw) {
126
+ const modelId = modelAliases(raw);
127
+ const selection = await selectModel(host, token, modelId);
128
+ process.stdout.write(`Switched model to ${bold(formatModel(selection.activeModel))}.\n`);
129
+ }
130
+
131
+ async function handleModelCommand(rl, token, raw) {
132
+ const arg = raw.trim();
133
+ if (!arg) {
134
+ await runModelPicker(rl, token);
135
+ return;
136
+ }
137
+ await switchModelByName(token, arg);
138
+ }
139
+
140
+ function printHelp() {
141
+ process.stdout.write([...slashHelpLines(), ""].join("\n"));
142
+ }
143
+
144
+ function normalizeSlashInput(input) {
145
+ if (input === "/quit") return "/exit";
146
+ return input;
147
+ }
148
+
149
+ async function askApproval(rl, request) {
150
+ const tool = requestToolName(request);
151
+ const details = formatRequestDetails(request);
152
+ const answer = (await rl.question(
153
+ `\nApprove ${tool}${details ? ` (${details})` : ""}?\n(y/n) > `,
154
+ )).trim().toLowerCase();
155
+ return answer === "y" || answer === "yes";
156
+ }
157
+
158
+ async function askQuestion(rl, request) {
159
+ const options = (request.options ?? []).map((option, index) => `${index + 1}. ${option.label}`).join(" ");
160
+ return rl.question(`\n${request.prompt}\n${options ? `${options}\n` : ""}> `);
161
+ }
162
+
163
+ function startStatus(label) {
164
+ if (!process.stdout.isTTY) return { stop() {} };
165
+
166
+ let frame = 0;
167
+ let active = true;
168
+ const frames = ["", ".", "..", "..."];
169
+ const render = () => {
170
+ if (!active) return;
171
+ cursorTo(process.stdout, 0);
172
+ process.stdout.write(`${label}${frames[frame % frames.length]}`);
173
+ clearLine(process.stdout, 1);
174
+ frame += 1;
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
+ };
188
+ }
189
+
113
190
  await ensureReachable();
114
191
 
115
192
  let session;
@@ -122,27 +199,63 @@ try {
122
199
  const { me } = session;
123
200
  const label = me.displayName ?? "you";
124
201
  const ownerId = me.ownerId;
202
+ const token = session.token;
125
203
 
126
- const client = new Client({
127
- host,
128
- auth: { bearer: () => loadToken(profile) ?? "" },
129
- preserveCompletedSessions: true,
204
+ let conversationId;
205
+ const rl = readline.createInterface({
206
+ input: stdin,
207
+ output: process.stdout,
208
+ historySize: 100,
130
209
  });
131
210
 
132
- try {
133
- await client.health();
134
- } catch (e) {
135
- fail(`Agent unreachable at ${host} (${e.message}).`);
211
+ let chain = Promise.resolve();
212
+ function runExclusive(fn) {
213
+ const next = chain.then(fn, fn);
214
+ chain = next.catch(() => {});
215
+ return next;
216
+ }
217
+
218
+ async function sendTurn(message) {
219
+ const status = startStatus("thinking");
220
+ const result = await sendTransportTurn({
221
+ host,
222
+ token,
223
+ conversationId,
224
+ message,
225
+ handlers: {
226
+ askApproval: (request) => {
227
+ status.stop();
228
+ return askApproval(rl, request);
229
+ },
230
+ askQuestion: (request) => {
231
+ status.stop();
232
+ return askQuestion(rl, request);
233
+ },
234
+ },
235
+ }).finally(() => status.stop());
236
+ conversationId = result?.conversationId ?? conversationId;
237
+ return result;
136
238
  }
137
239
 
138
240
  let wake;
139
241
  try {
140
- const token = loadToken(profile);
141
242
  wake = await subscribeInboxWakeAbly(ownerId, (ev) => {
142
243
  const what = ev.kind === "message" ? "a message" : `a file (${ev.filename})`;
143
- renderer.renderNotice(
144
- `📬 New mail — ${what} arrived. Ask me about it (e.g. "what's new?") to fetch it.`,
145
- );
244
+ process.stdout.write(`\nNew mail: ${what} arrived. Fetching it now...\n`);
245
+ void runExclusive(async () => {
246
+ const result = await sendWakeSyncTurn({
247
+ host,
248
+ token,
249
+ event: ev,
250
+ handlers: {
251
+ askApproval: (request) => askApproval(rl, request),
252
+ askQuestion: (request) => askQuestion(rl, request),
253
+ },
254
+ });
255
+ process.stdout.write(`\n${summarizeResult(result)}\n\n`);
256
+ }).catch((e) => {
257
+ process.stderr.write(`! wake sync failed: ${e?.message ?? e}\n`);
258
+ });
146
259
  }, { host, bearerToken: token });
147
260
  } catch {
148
261
  /* wake is best-effort */
@@ -158,16 +271,58 @@ const stopWake = async () => {
158
271
  process.on("SIGINT", stopWake);
159
272
  process.on("exit", () => void wake?.close());
160
273
 
161
- await new EveTUIRunner({
162
- name: `neagen (${label})`,
163
- session: client.session(),
164
- client,
165
- serverUrl: host,
166
- renderer,
167
- promptCommandHandler: createPromptCommandHandler({ target }),
168
- availablePromptCommands: promptCommandsFor("remote"),
169
- formatTransportError: (error) =>
170
- isVercelAuthChallenge(error) ? formatRemoteAuthChallengeMessage(host) : toErrorMessage(error),
171
- }).run();
274
+ process.stdout.write(`neagen (${label})\n`);
275
+ process.stdout.write("Type a message, / for commands, /model to switch models, /help for the list.\n\n");
276
+
277
+ async function handleSlash(rl, input) {
278
+ const normalized = normalizeSlashInput(input);
279
+
280
+ if (normalized === "/exit") return true;
281
+ if (normalized === "/help") {
282
+ printHelp();
283
+ return false;
284
+ }
285
+ if (normalized === "/new") {
286
+ conversationId = undefined;
287
+ process.stdout.write("Started a new conversation.\n");
288
+ return false;
289
+ }
290
+ if (normalized === "/model" || normalized.startsWith("/model ")) {
291
+ await handleModelCommand(rl, token, normalized.slice("/model".length));
292
+ return false;
293
+ }
294
+
295
+ process.stdout.write(`${dim(`unknown command: ${input} (try /help)`)}\n`);
296
+ return false;
297
+ }
298
+
299
+ try {
300
+ for (;;) {
301
+ let line;
302
+ try {
303
+ line = await readPromptLine(rl, { prompt: "neagen> ", slashCommands: SLASH_COMMANDS });
304
+ } catch {
305
+ break;
306
+ }
307
+ if (line === null) break;
308
+
309
+ const input = line.trim();
310
+ if (!input) continue;
311
+
312
+ try {
313
+ if (input.startsWith("/")) {
314
+ if (await handleSlash(rl, input)) break;
315
+ continue;
316
+ }
317
+
318
+ const result = await runExclusive(() => sendTurn(line));
319
+ process.stdout.write(`\n${summarizeResult(result)}\n\n`);
320
+ } catch (e) {
321
+ process.stderr.write(`! ${e?.message ?? e}\n`);
322
+ }
323
+ }
324
+ } finally {
325
+ rl.close();
326
+ }
172
327
 
173
328
  await stopWake();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "neagen",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Your personal networking agent, in the terminal. Chat, agent-mail, and files against your Neagen — passkey login, no passwords.",
5
5
  "type": "module",
6
6
  "bin": {