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 +2 -0
- package/lib/api.mjs +31 -3
- package/lib/device-login.mjs +6 -4
- package/lib/eve-chat.mjs +170 -0
- package/lib/picker.mjs +280 -0
- package/lib/slash.mjs +49 -0
- package/lib/tui.mjs +243 -88
- 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,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
|
|
2
|
-
// Login happens here (device
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
52
|
+
process.stdout.write("Agent online.\n");
|
|
87
53
|
} catch (e) {
|
|
88
|
-
|
|
89
|
-
|
|
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
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
204
|
+
let conversationId;
|
|
205
|
+
const rl = readline.createInterface({
|
|
206
|
+
input: stdin,
|
|
207
|
+
output: process.stdout,
|
|
208
|
+
historySize: 100,
|
|
130
209
|
});
|
|
131
210
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
144
|
-
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
}
|
|
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