neagen 0.1.3 → 0.1.5

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.
@@ -85,7 +85,7 @@ 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 create or sign in, then approve…", "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
91
  `\n Your code: ${code.user_code}\n Approve at: ${code.verification_uri_complete}\n\n` +
@@ -97,8 +97,18 @@ export async function runDeviceGrantLogin({ host, profile, renderer }) {
97
97
  const intervalMs = (code.interval ?? 5) * 1000;
98
98
  const deadline = Date.now() + (code.expires_in ?? 600) * 1000;
99
99
 
100
+ let lastReminderAt = Date.now();
100
101
  while (Date.now() < deadline) {
101
- ui.setStatus("Waiting for passkey approval…");
102
+ // Keep the code and remaining time visible so a failed browser-open never
103
+ // strands the user scrolling back for the URL.
104
+ const minutesLeft = Math.max(1, Math.ceil((deadline - Date.now()) / 60_000));
105
+ ui.setStatus(
106
+ `Waiting for passkey approval… code ${code.user_code}, ~${minutesLeft} min left`,
107
+ );
108
+ if (Date.now() - lastReminderAt >= 60_000) {
109
+ lastReminderAt = Date.now();
110
+ ui.renderLine(`Still waiting. Approve at: ${code.verification_uri_complete}`, "info");
111
+ }
102
112
  await new Promise((r) => setTimeout(r, intervalMs));
103
113
 
104
114
  const tokRes = await fetch(`${host}/api/auth/device/token`, {
@@ -129,7 +139,7 @@ export async function runDeviceGrantLogin({ host, profile, renderer }) {
129
139
  throw new Error(err.error_description ?? err.error ?? "device/token failed");
130
140
  }
131
141
 
132
- ui.renderLine("The code expired before approval. Run neagen again.", "error");
142
+ ui.renderLine("The code expired before approval. Run `neagen login` for a fresh one.", "error");
133
143
  throw new Error("device code expired");
134
144
  } finally {
135
145
  ui.setStatus(undefined);
@@ -15,6 +15,7 @@ import {
15
15
  } from "./api.mjs";
16
16
  import { runDeviceGrantLogin } from "./device-login.mjs";
17
17
  import { parseSseChunk } from "./turn-stream.mjs";
18
+ import { subscribeInboxWakeAuto } from "./wake.mjs";
18
19
 
19
20
  const require = createRequire(import.meta.url);
20
21
 
@@ -28,6 +29,11 @@ async function loadEveTuiRunner() {
28
29
  return mod.EveTUIRunner;
29
30
  }
30
31
 
32
+ function wakeSyncPrompt(ev) {
33
+ const noun = ev.kind === "message" ? "a text message" : "a file";
34
+ return `[mail] You just received new mail (${noun}) from a connection. Call sync_inbox once to fetch it, then tell me in one short line who it is from and what it is.`;
35
+ }
36
+
31
37
  function normalizeHost(host) {
32
38
  return host.replace(/\/$/, "");
33
39
  }
@@ -359,6 +365,34 @@ export async function runProductTui({ profile } = {}) {
359
365
  const selected = await client.selectedModel().catch(() => undefined);
360
366
  process.stdout.write(`Agent online. Model: ${selected?.id ?? "unknown"}.\n`);
361
367
 
368
+ let wakeChain = Promise.resolve();
369
+ let wake = null;
370
+ try {
371
+ wake = await subscribeInboxWakeAuto(identity.ownerId, (ev) => {
372
+ const noun = ev.kind === "message" ? "a message" : `a file (${ev.filename ?? "unknown"})`;
373
+ process.stdout.write(`\nNew mail: ${noun} arrived. Fetching it now...\n`);
374
+ wakeChain = wakeChain
375
+ .then(async () => {
376
+ const syncSession = client.session();
377
+ const response = await syncSession.send({ message: wakeSyncPrompt(ev) });
378
+ for await (const _event of response) {
379
+ // drain the sync turn stream silently in the background
380
+ }
381
+ })
382
+ .catch((e) => {
383
+ process.stderr.write(`! wake sync failed: ${e instanceof Error ? e.message : String(e)}\n`);
384
+ });
385
+ }, { host, bearerToken: token });
386
+ } catch {
387
+ // wake is best-effort
388
+ }
389
+
390
+ const stopWake = async () => {
391
+ try { await wake?.close(); } catch { /* best-effort */ }
392
+ };
393
+ process.on("SIGINT", async () => { await stopWake(); process.exit(0); });
394
+ process.on("exit", () => void wake?.close());
395
+
362
396
  await new EveTUIRunner({
363
397
  session,
364
398
  client,
@@ -388,6 +422,8 @@ export async function runProductTui({ profile } = {}) {
388
422
  { name: "exit", aliases: ["quit"], description: "Quit", takesArgument: false },
389
423
  ],
390
424
  }).run();
425
+
426
+ await stopWake();
391
427
  }
392
428
 
393
429
  export {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "neagen",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
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": {
package/lib/eve-chat.mjs DELETED
@@ -1,224 +0,0 @@
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
- }
package/lib/picker.mjs DELETED
@@ -1,280 +0,0 @@
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 DELETED
@@ -1,49 +0,0 @@
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 DELETED
@@ -1,311 +0,0 @@
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 { stdin } from "node:process";
6
- import { dim, bold } from "./ansi.mjs";
7
- import { assertTransportSafe, clearCreds, loadToken, resolveHost } from "./creds.mjs";
8
- import {
9
- getSelectedModel,
10
- listModels,
11
- probeHost,
12
- resolveIdentity,
13
- selectModel,
14
- } from "./api.mjs";
15
- import { runDeviceGrantLogin } from "./device-login.mjs";
16
- import {
17
- formatRequestDetails,
18
- requestToolName,
19
- sendTransportTurn,
20
- sendWakeSyncTurn,
21
- summarizeResult,
22
- } from "./eve-chat.mjs";
23
- import { createTurnStreamRenderer } from "./turn-stream.mjs";
24
- import { pickModel, readPromptLine } from "./picker.mjs";
25
- import { SLASH_COMMANDS, slashHelpLines } from "./slash.mjs";
26
- import { subscribeInboxWakeAbly } from "./wake.mjs";
27
-
28
- const profile = process.env.NEAGEN_PROFILE?.trim() || undefined;
29
- const host = resolveHost();
30
-
31
- if (!process.stdin.isTTY || !process.stdout.isTTY) {
32
- console.error("neagen requires an interactive terminal.");
33
- process.exit(1);
34
- }
35
-
36
- try {
37
- assertTransportSafe(host);
38
- } catch (e) {
39
- console.error(`\n ${e.message}\n`);
40
- process.exit(1);
41
- }
42
-
43
- function fail(message) {
44
- console.error(`\n ${message}\n`);
45
- process.exit(1);
46
- }
47
-
48
- async function ensureReachable() {
49
- process.stdout.write(`Connecting to ${host}...\n`);
50
- try {
51
- await probeHost(host);
52
- process.stdout.write("Agent online.\n");
53
- } catch (e) {
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");
56
- process.exit(1);
57
- }
58
- }
59
-
60
- async function ensureAuthenticated() {
61
- let token = loadToken(profile);
62
- if (token) {
63
- const identity = await resolveIdentity(host, token);
64
- if (identity) return { token, me: identity };
65
- clearCreds(profile);
66
- token = null;
67
- }
68
- token = await runDeviceGrantLogin({ host, profile });
69
- const me = await resolveIdentity(host, token);
70
- if (!me) {
71
- clearCreds(profile);
72
- fail("Session could not be verified after login.");
73
- }
74
- return { token, me };
75
- }
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
- 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
- }
176
- }
177
-
178
- await ensureReachable();
179
-
180
- let session;
181
- try {
182
- session = await ensureAuthenticated();
183
- } catch (e) {
184
- fail(e.message ?? "Login failed.");
185
- }
186
-
187
- const { me } = session;
188
- const label = me.displayName ?? "you";
189
- const ownerId = me.ownerId;
190
- const token = session.token;
191
-
192
- let conversationId;
193
- const rl = readline.createInterface({
194
- input: stdin,
195
- output: process.stdout,
196
- historySize: 100,
197
- });
198
-
199
- let chain = Promise.resolve();
200
- function runExclusive(fn) {
201
- const next = chain.then(fn, fn);
202
- chain = next.catch(() => {});
203
- return next;
204
- }
205
-
206
- async function sendTurn(message) {
207
- const result = await sendTransportTurn({
208
- host,
209
- token,
210
- conversationId,
211
- message,
212
- render: turnRenderer,
213
- handlers: {
214
- askApproval: (request) => askApproval(rl, request),
215
- askQuestion: (request) => askQuestion(rl, request),
216
- },
217
- });
218
- conversationId = result?.conversationId ?? conversationId;
219
- return result;
220
- }
221
-
222
- let wake;
223
- try {
224
- wake = await subscribeInboxWakeAbly(ownerId, (ev) => {
225
- const what = ev.kind === "message" ? "a message" : `a file (${ev.filename})`;
226
- process.stdout.write(`\nNew mail: ${what} arrived. Fetching it now...\n`);
227
- void runExclusive(async () => {
228
- const result = await sendWakeSyncTurn({
229
- host,
230
- token,
231
- event: ev,
232
- render: turnRenderer,
233
- handlers: {
234
- askApproval: (request) => askApproval(rl, request),
235
- askQuestion: (request) => askQuestion(rl, request),
236
- },
237
- });
238
- printTurnOutcome(result);
239
- }).catch((e) => {
240
- process.stderr.write(`! wake sync failed: ${e?.message ?? e}\n`);
241
- });
242
- }, { host, bearerToken: token });
243
- } catch {
244
- /* wake is best-effort */
245
- }
246
-
247
- const stopWake = async () => {
248
- try {
249
- await wake?.close();
250
- } catch {
251
- /* best-effort */
252
- }
253
- };
254
- process.on("SIGINT", stopWake);
255
- process.on("exit", () => void wake?.close());
256
-
257
- process.stdout.write(`neagen (${label})\n`);
258
- process.stdout.write("Type a message, / for commands, /model to switch models, /help for the list.\n\n");
259
-
260
- async function handleSlash(rl, input) {
261
- const normalized = normalizeSlashInput(input);
262
-
263
- if (normalized === "/exit") return true;
264
- if (normalized === "/help") {
265
- printHelp();
266
- return false;
267
- }
268
- if (normalized === "/new") {
269
- conversationId = undefined;
270
- process.stdout.write("Started a new conversation.\n");
271
- return false;
272
- }
273
- if (normalized === "/model" || normalized.startsWith("/model ")) {
274
- await handleModelCommand(rl, token, normalized.slice("/model".length));
275
- return false;
276
- }
277
-
278
- process.stdout.write(`${dim(`unknown command: ${input} (try /help)`)}\n`);
279
- return false;
280
- }
281
-
282
- try {
283
- for (;;) {
284
- let line;
285
- try {
286
- line = await readPromptLine(rl, { prompt: "neagen> ", slashCommands: SLASH_COMMANDS });
287
- } catch {
288
- break;
289
- }
290
- if (line === null) break;
291
-
292
- const input = line.trim();
293
- if (!input) continue;
294
-
295
- try {
296
- if (input.startsWith("/")) {
297
- if (await handleSlash(rl, input)) break;
298
- continue;
299
- }
300
-
301
- const result = await runExclusive(() => sendTurn(line));
302
- printTurnOutcome(result);
303
- } catch (e) {
304
- process.stderr.write(`! ${e?.message ?? e}\n`);
305
- }
306
- }
307
- } finally {
308
- rl.close();
309
- }
310
-
311
- await stopWake();