@xingjisoft/neutrino 0.1.4 → 0.1.6
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/README.md +413 -163
- package/assets/prompt.txt +4 -0
- package/dist/agents.js +114 -15
- package/dist/app.js +442 -30
- package/dist/bin.js +177 -5
- package/dist/colors.js +2 -0
- package/dist/compact.js +19 -0
- package/dist/config.js +121 -28
- package/dist/cwd.js +54 -0
- package/dist/header.js +8 -0
- package/dist/llm/approval.js +543 -0
- package/dist/llm/client.js +14 -0
- package/dist/llm/risk.js +377 -0
- package/dist/llm/tools.js +37 -21
- package/dist/persistence.js +51 -0
- package/dist/screens/agentHistory.js +7 -4
- package/dist/screens/chat.js +709 -49
- package/dist/screens/history.js +9 -7
- package/dist/screens/settings.js +131 -14
- package/dist/screens/start.js +50 -16
- package/dist/selection.js +60 -0
- package/dist/state.js +1 -0
- package/dist/terminal/input.js +34 -5
- package/dist/terminal/renderer.js +58 -12
- package/dist/terminal/restore.js +148 -0
- package/dist/ui.js +16 -0
- package/dist/web/auth.js +82 -0
- package/dist/web/hub.js +180 -0
- package/dist/web/server.js +688 -0
- package/dist/web/session.js +572 -0
- package/dist/web/settings.js +101 -0
- package/dist/width.js +20 -0
- package/package.json +6 -4
- package/web/README.md +210 -0
- package/web/app.js +1100 -0
- package/web/index.html +90 -0
- package/web/settings.js +193 -0
- package/web/style.css +1088 -0
- package/web/turns.js +52 -0
package/dist/bin.js
CHANGED
|
@@ -1,9 +1,181 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import process from "node:process";
|
|
3
3
|
import { App } from "./app.js";
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
import { installCrashGuard } from "./terminal/restore.js";
|
|
5
|
+
import { startWebServer } from "./web/server.js";
|
|
6
|
+
const DEFAULT_WEB_PORT = 8765;
|
|
7
|
+
/** Binding here makes the UI reachable from every interface. */
|
|
8
|
+
const WILDCARD_HOST = "0.0.0.0";
|
|
9
|
+
function parsePort(raw) {
|
|
10
|
+
if (!raw)
|
|
11
|
+
throw new Error("missing port number");
|
|
12
|
+
if (!/^\d+$/.test(raw))
|
|
13
|
+
throw new Error(`invalid port "${raw}"`);
|
|
14
|
+
const port = Number(raw);
|
|
15
|
+
if (port < 0 || port > 65535)
|
|
16
|
+
throw new Error(`port out of range: ${raw}`);
|
|
17
|
+
return port;
|
|
18
|
+
}
|
|
19
|
+
function envPort() {
|
|
20
|
+
const raw = process.env.NEUTRINO_WEB_PORT;
|
|
21
|
+
if (!raw)
|
|
22
|
+
return null;
|
|
23
|
+
try {
|
|
24
|
+
return parsePort(raw);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function printHelp() {
|
|
31
|
+
process.stdout.write(`Neutrino
|
|
32
|
+
|
|
33
|
+
Usage:
|
|
34
|
+
neutrino Start the terminal UI.
|
|
35
|
+
neutrino --web-port <port> Start the terminal UI and mount the web UI.
|
|
36
|
+
neutrino web [--port <port>] Start only the web UI (serves saved history).
|
|
37
|
+
|
|
38
|
+
Options:
|
|
39
|
+
-p, --port <port> Web server port (web mode). Default: ${DEFAULT_WEB_PORT}
|
|
40
|
+
--host <host> Interface to bind. Default: 127.0.0.1
|
|
41
|
+
--lan Short for --host ${WILDCARD_HOST}: reachable from other
|
|
42
|
+
devices on your LAN or VPN.
|
|
43
|
+
--no-auth Serve without password login.
|
|
44
|
+
--web-port <n> Also mount the web UI while the TUI runs.
|
|
45
|
+
-h, --help Show this help.
|
|
46
|
+
|
|
47
|
+
Environment:
|
|
48
|
+
NEUTRINO_WEB_PORT Same as --web-port for the TUI, or the port in web mode.
|
|
49
|
+
NEUTRINO_WEB_HOST Same as --host.
|
|
50
|
+
|
|
51
|
+
The web UI can read every saved conversation and drive an agent with shell
|
|
52
|
+
access, so it binds to loopback by default. Log in with the permanent password
|
|
53
|
+
(default: 114514). Change it in Settings. --no-auth disables password login.
|
|
54
|
+
`);
|
|
55
|
+
}
|
|
56
|
+
/** Read the value that follows a flag, so `--host` alone is an error. */
|
|
57
|
+
function requireValue(argv, index, flag) {
|
|
58
|
+
const value = argv[index];
|
|
59
|
+
if (value === undefined || value.trim() === "") {
|
|
60
|
+
throw new Error(`missing value for ${flag}`);
|
|
61
|
+
}
|
|
62
|
+
return value;
|
|
63
|
+
}
|
|
64
|
+
function parseArgs(argv) {
|
|
65
|
+
const options = { mode: "tui", port: null, lan: false, noAuth: false };
|
|
66
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
67
|
+
const arg = argv[i];
|
|
68
|
+
if (arg === "web") {
|
|
69
|
+
options.mode = "web";
|
|
70
|
+
}
|
|
71
|
+
else if (arg === "--port" || arg === "-p") {
|
|
72
|
+
options.port = parsePort(argv[++i]);
|
|
73
|
+
}
|
|
74
|
+
else if (arg.startsWith("--port=")) {
|
|
75
|
+
options.port = parsePort(arg.slice("--port=".length));
|
|
76
|
+
}
|
|
77
|
+
else if (arg === "--web-port") {
|
|
78
|
+
options.port = parsePort(argv[++i]);
|
|
79
|
+
}
|
|
80
|
+
else if (arg.startsWith("--web-port=")) {
|
|
81
|
+
options.port = parsePort(arg.slice("--web-port=".length));
|
|
82
|
+
}
|
|
83
|
+
else if (arg === "--host") {
|
|
84
|
+
options.host = requireValue(argv, ++i, "--host");
|
|
85
|
+
}
|
|
86
|
+
else if (arg.startsWith("--host=")) {
|
|
87
|
+
options.host = arg.slice("--host=".length);
|
|
88
|
+
}
|
|
89
|
+
else if (arg === "--lan") {
|
|
90
|
+
options.lan = true;
|
|
91
|
+
}
|
|
92
|
+
else if (arg === "--no-auth") {
|
|
93
|
+
options.noAuth = true;
|
|
94
|
+
}
|
|
95
|
+
else if (arg === "--help" || arg === "-h") {
|
|
96
|
+
printHelp();
|
|
97
|
+
process.exit(0);
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
throw new Error(`unknown argument "${arg}"`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (options.lan && options.host) {
|
|
104
|
+
throw new Error("use either --lan or --host, not both");
|
|
105
|
+
}
|
|
106
|
+
return options;
|
|
107
|
+
}
|
|
108
|
+
/** Print every address a client can open, plus what it takes to open one. */
|
|
109
|
+
function reportAccess(handle) {
|
|
110
|
+
process.stdout.write(`Neutrino web UI listening on ${handle.host}:${handle.port}\n`);
|
|
111
|
+
const multiple = handle.urls.length > 1;
|
|
112
|
+
process.stdout.write(multiple ? "Open the address that matches your device:\n" : "Open:\n");
|
|
113
|
+
for (const url of handle.urls)
|
|
114
|
+
process.stdout.write(` ${url}\n`);
|
|
115
|
+
const wildcard = handle.host === "0.0.0.0" || handle.host === "::";
|
|
116
|
+
if (wildcard && !handle.urls.some((url) => !url.includes("//127.0.0.1:"))) {
|
|
117
|
+
// Bound to every interface, yet nothing outside this machine is reachable:
|
|
118
|
+
// usually Wi-Fi is off, the VPN is down, or the only adapters left are
|
|
119
|
+
// virtual ones we deliberately hide.
|
|
120
|
+
process.stdout.write("note: no reachable network address found, so only this machine can connect.\n");
|
|
121
|
+
}
|
|
122
|
+
if (handle.authRequired) {
|
|
123
|
+
process.stdout.write("Sign in with your permanent Web password (default: 114514). Change it in Settings.\n");
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
process.stdout.write("Password login is disabled.\n");
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
let options;
|
|
130
|
+
try {
|
|
131
|
+
options = parseArgs(process.argv.slice(2));
|
|
132
|
+
}
|
|
133
|
+
catch (err) {
|
|
134
|
+
process.stderr.write(`neutrino: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
135
|
+
process.stderr.write("Run 'neutrino --help' for usage.\n");
|
|
136
|
+
process.exit(2);
|
|
137
|
+
}
|
|
138
|
+
// Installed before either mode starts: a crash must leave a usable terminal
|
|
139
|
+
// (TUI) and a log line (both), never a shell flooded with mouse reports.
|
|
140
|
+
installCrashGuard();
|
|
141
|
+
const host = options.lan ? WILDCARD_HOST : options.host || process.env.NEUTRINO_WEB_HOST || undefined;
|
|
142
|
+
const auth = !options.noAuth;
|
|
143
|
+
if (options.mode === "web") {
|
|
144
|
+
// Headless: serve the saved history (and any live sessions in this process,
|
|
145
|
+
// which for a standalone server is none).
|
|
146
|
+
const port = options.port ?? envPort() ?? DEFAULT_WEB_PORT;
|
|
147
|
+
try {
|
|
148
|
+
const handle = await startWebServer({ port, host, auth });
|
|
149
|
+
reportAccess(handle);
|
|
150
|
+
process.stdout.write("Serving saved conversations. Press Ctrl+C to stop.\n");
|
|
151
|
+
const shutdown = () => {
|
|
152
|
+
handle.close().finally(() => process.exit(0));
|
|
153
|
+
};
|
|
154
|
+
process.on("SIGINT", shutdown);
|
|
155
|
+
process.on("SIGTERM", shutdown);
|
|
156
|
+
}
|
|
157
|
+
catch (err) {
|
|
158
|
+
process.stderr.write(`neutrino: failed to start web server: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
159
|
+
process.exit(1);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
if (!process.stdout.isTTY || !process.stdin.isTTY) {
|
|
164
|
+
process.stderr.write("neutrino requires an interactive terminal.\n");
|
|
165
|
+
process.stderr.write("To serve the web UI without a terminal, run 'neutrino web --port <port>'.\n");
|
|
166
|
+
process.exit(1);
|
|
167
|
+
}
|
|
168
|
+
const app = new App();
|
|
169
|
+
const webPort = options.port ?? envPort();
|
|
170
|
+
if (webPort !== null) {
|
|
171
|
+
try {
|
|
172
|
+
const handle = await app.enableWeb(webPort, host, auth);
|
|
173
|
+
reportAccess(handle);
|
|
174
|
+
}
|
|
175
|
+
catch (err) {
|
|
176
|
+
process.stderr.write(`neutrino: failed to start web server: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
177
|
+
process.exit(1);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
await app.start();
|
|
7
181
|
}
|
|
8
|
-
const app = new App();
|
|
9
|
-
await app.start();
|
package/dist/colors.js
CHANGED
|
@@ -8,6 +8,8 @@ export const C = {
|
|
|
8
8
|
toolHeader: [153, 217, 234], // cyan
|
|
9
9
|
amber: [224, 175, 104], // #E0AF68
|
|
10
10
|
white: [226, 232, 240],
|
|
11
|
+
orange: [255, 158, 61], // #FF9E3D approval highlight
|
|
12
|
+
orangeBg: [61, 36, 14], // #3D240E dim orange behind approval cards
|
|
11
13
|
green: [115, 218, 202],
|
|
12
14
|
red: [247, 118, 142],
|
|
13
15
|
codeBg: [27, 35, 49], // dark blue-grey used behind code blocks
|
package/dist/compact.js
CHANGED
|
@@ -6,6 +6,25 @@
|
|
|
6
6
|
* part into a plain transcript. The actual "ask the model to summarise" step
|
|
7
7
|
* lives in `LLMClient.summarize`, so this module stays trivially unit-testable.
|
|
8
8
|
*/
|
|
9
|
+
/** Ignore stale/corrupt metadata rather than hiding any original messages. */
|
|
10
|
+
export function normalizeContext(value, messageCount) {
|
|
11
|
+
if (!value || typeof value !== "object")
|
|
12
|
+
return null;
|
|
13
|
+
const context = value;
|
|
14
|
+
if (typeof context.end !== "number" || !Number.isInteger(context.end) ||
|
|
15
|
+
context.end <= 0 || context.end > messageCount ||
|
|
16
|
+
typeof context.summary !== "string" || !context.summary.trim())
|
|
17
|
+
return null;
|
|
18
|
+
return { end: context.end, summary: context.summary };
|
|
19
|
+
}
|
|
20
|
+
/** Derive a bounded model history while leaving the complete transcript intact. */
|
|
21
|
+
export function modelHistory(messages, context) {
|
|
22
|
+
const original = messages.filter((message) => message.role !== "system");
|
|
23
|
+
const valid = normalizeContext(context, original.length);
|
|
24
|
+
return valid
|
|
25
|
+
? [{ role: "summary", content: valid.summary }, ...original.slice(valid.end)]
|
|
26
|
+
: original;
|
|
27
|
+
}
|
|
9
28
|
/** How many trailing messages are always kept verbatim when compressing. */
|
|
10
29
|
export const COMPACT_KEEP = 4;
|
|
11
30
|
/** Per-message character budget fed to the summariser, to bound its own input. */
|
package/dist/config.js
CHANGED
|
@@ -2,12 +2,32 @@ import fs from "node:fs";
|
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
6
|
+
import { sameCwd } from "./cwd.js";
|
|
7
|
+
import { normalizeContext } from "./compact.js";
|
|
5
8
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
6
9
|
const PKG_ROOT = path.resolve(__dirname, "..");
|
|
7
10
|
export const NEUTRINO_DIR = path.join(os.homedir(), ".neutrino");
|
|
8
11
|
export const MODEL_FILE = path.join(NEUTRINO_DIR, "model.json");
|
|
9
12
|
export const SESSION_DIR = path.join(NEUTRINO_DIR, "session");
|
|
10
13
|
export const CONFIG_FILE = path.join(NEUTRINO_DIR, "config.json");
|
|
14
|
+
export const DEFAULT_CONTEXT_WINDOW = 1_000_000;
|
|
15
|
+
export const CONTEXT_COMPRESSION_RATIO = 0.8;
|
|
16
|
+
export const CONTEXT_WINDOW_OPTIONS = [32_000, 64_000, 128_000, 200_000, 256_000, 512_000, 1_000_000, 2_000_000, 4_000_000];
|
|
17
|
+
export function isContextWindow(value) {
|
|
18
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value > 0;
|
|
19
|
+
}
|
|
20
|
+
export function contextWindow(value) {
|
|
21
|
+
return isContextWindow(value) ? value : DEFAULT_CONTEXT_WINDOW;
|
|
22
|
+
}
|
|
23
|
+
/** Reload the exact provider/model so settings also apply to active chats. */
|
|
24
|
+
export function modelContextWindow(provider, model) {
|
|
25
|
+
const saved = loadModels().providers.find((p) => p.name === provider)?.models.find((m) => m.name === model.name);
|
|
26
|
+
return contextWindow((saved ?? model).context_window);
|
|
27
|
+
}
|
|
28
|
+
export function contextCompressionThreshold(provider, model) {
|
|
29
|
+
return Math.max(1, Math.floor(modelContextWindow(provider, model) * CONTEXT_COMPRESSION_RATIO));
|
|
30
|
+
}
|
|
11
31
|
export const DEFAULT_MODELS = {
|
|
12
32
|
providers: [
|
|
13
33
|
{
|
|
@@ -36,9 +56,16 @@ export const DEFAULT_CONFIG = {
|
|
|
36
56
|
language: "en",
|
|
37
57
|
theme: "default",
|
|
38
58
|
default_intensity: "High",
|
|
39
|
-
//
|
|
40
|
-
|
|
41
|
-
|
|
59
|
+
// Default for new conversations; each conversation can override it.
|
|
60
|
+
approval_mode: "request",
|
|
61
|
+
// Delete-class commands need a human unless this and Auto Approval are on.
|
|
62
|
+
auto_approve_extreme_risk: false,
|
|
63
|
+
// Audible cue when a turn ends or an approval request is waiting.
|
|
64
|
+
sound_alerts: true,
|
|
65
|
+
// Model that reviews approval requests. Empty = same model as the chat.
|
|
66
|
+
approval_model: "",
|
|
67
|
+
// Initialized as a salted hash of the default Web password on first use.
|
|
68
|
+
web_password_hash: "",
|
|
42
69
|
};
|
|
43
70
|
export const INTENSITY_LEVELS = ["Low", "Medium", "High", "Max", "Ultra"];
|
|
44
71
|
export const INTENSITY_PARAMS = {
|
|
@@ -48,32 +75,28 @@ export const INTENSITY_PARAMS = {
|
|
|
48
75
|
Max: { temperature: 1.0, top_p: 1.0 },
|
|
49
76
|
Ultra: { temperature: 1.5, top_p: 1.0 },
|
|
50
77
|
};
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
{ value: 16000, label: "16K tokens" },
|
|
59
|
-
{ value: 32000, label: "32K tokens" },
|
|
60
|
-
{ value: 64000, label: "64K tokens" },
|
|
61
|
-
{ value: 128000, label: "128K tokens" },
|
|
62
|
-
];
|
|
78
|
+
export function normalizeApprovalMode(value) {
|
|
79
|
+
// Config files are hand-edited: "Auto" or " autO " must not silently
|
|
80
|
+
// downgrade the conversation to a prompt on every tool call.
|
|
81
|
+
return typeof value === "string" && value.trim().toLowerCase() === "auto"
|
|
82
|
+
? "auto"
|
|
83
|
+
: "request";
|
|
84
|
+
}
|
|
63
85
|
export function ensureDirs() {
|
|
64
86
|
fs.mkdirSync(NEUTRINO_DIR, { recursive: true });
|
|
65
87
|
fs.mkdirSync(SESSION_DIR, { recursive: true });
|
|
66
88
|
}
|
|
67
89
|
export function loadModels() {
|
|
68
|
-
ensureDirs();
|
|
69
|
-
if (!fs.existsSync(MODEL_FILE)) {
|
|
70
|
-
saveModels(DEFAULT_MODELS);
|
|
71
|
-
return DEFAULT_MODELS;
|
|
72
|
-
}
|
|
73
90
|
try {
|
|
91
|
+
ensureDirs();
|
|
92
|
+
if (!fs.existsSync(MODEL_FILE)) {
|
|
93
|
+
saveModels(DEFAULT_MODELS);
|
|
94
|
+
return DEFAULT_MODELS;
|
|
95
|
+
}
|
|
74
96
|
return JSON.parse(fs.readFileSync(MODEL_FILE, "utf-8"));
|
|
75
97
|
}
|
|
76
98
|
catch {
|
|
99
|
+
// Same reasoning as loadConfig: this sits on the approval path.
|
|
77
100
|
return DEFAULT_MODELS;
|
|
78
101
|
}
|
|
79
102
|
}
|
|
@@ -82,15 +105,17 @@ export function saveModels(data) {
|
|
|
82
105
|
fs.writeFileSync(MODEL_FILE, JSON.stringify(data, null, 2), "utf-8");
|
|
83
106
|
}
|
|
84
107
|
export function loadConfig() {
|
|
85
|
-
ensureDirs();
|
|
86
|
-
if (!fs.existsSync(CONFIG_FILE)) {
|
|
87
|
-
saveConfig(DEFAULT_CONFIG);
|
|
88
|
-
return { ...DEFAULT_CONFIG };
|
|
89
|
-
}
|
|
90
108
|
try {
|
|
109
|
+
ensureDirs();
|
|
110
|
+
if (!fs.existsSync(CONFIG_FILE)) {
|
|
111
|
+
saveConfig(DEFAULT_CONFIG);
|
|
112
|
+
return { ...DEFAULT_CONFIG };
|
|
113
|
+
}
|
|
91
114
|
return JSON.parse(fs.readFileSync(CONFIG_FILE, "utf-8"));
|
|
92
115
|
}
|
|
93
116
|
catch {
|
|
117
|
+
// The approval gate reads this on every tool call; a directory we cannot
|
|
118
|
+
// create or a file we cannot parse must fall back, not throw.
|
|
94
119
|
return { ...DEFAULT_CONFIG };
|
|
95
120
|
}
|
|
96
121
|
}
|
|
@@ -98,6 +123,18 @@ export function saveConfig(data) {
|
|
|
98
123
|
ensureDirs();
|
|
99
124
|
fs.writeFileSync(CONFIG_FILE, JSON.stringify(data, null, 2), "utf-8");
|
|
100
125
|
}
|
|
126
|
+
/** Look a model up by name across every configured provider. */
|
|
127
|
+
export function findModel(name) {
|
|
128
|
+
const wanted = name.trim();
|
|
129
|
+
if (!wanted)
|
|
130
|
+
return null;
|
|
131
|
+
for (const [provider, models] of Object.entries(getModelsByProvider())) {
|
|
132
|
+
const model = models.find((m) => m.name === wanted);
|
|
133
|
+
if (model)
|
|
134
|
+
return { provider, model };
|
|
135
|
+
}
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
101
138
|
export function getModelsByProvider() {
|
|
102
139
|
const data = loadModels();
|
|
103
140
|
const result = {};
|
|
@@ -106,10 +143,30 @@ export function getModelsByProvider() {
|
|
|
106
143
|
}
|
|
107
144
|
return result;
|
|
108
145
|
}
|
|
109
|
-
|
|
146
|
+
/** Resolve a provider/model pair exactly, including when names repeat across providers. */
|
|
147
|
+
export function resolveModelSelection(value, requireKey = true) {
|
|
148
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
149
|
+
throw new Error("invalid model selection");
|
|
150
|
+
const input = value;
|
|
151
|
+
if (typeof input.provider !== "string" || typeof input.model !== "string" ||
|
|
152
|
+
typeof input.intensity !== "string")
|
|
153
|
+
throw new Error("provider, model and intensity are required");
|
|
154
|
+
if (!INTENSITY_LEVELS.includes(input.intensity))
|
|
155
|
+
throw new Error("invalid thinking intensity");
|
|
156
|
+
const provider = loadModels().providers.find((p) => p.name === input.provider);
|
|
157
|
+
if (!provider)
|
|
158
|
+
throw new Error("unknown provider");
|
|
159
|
+
const model = provider.models.find((m) => m.name === input.model);
|
|
160
|
+
if (!model)
|
|
161
|
+
throw new Error("unknown model for this provider");
|
|
162
|
+
if (requireKey && !model.api_key?.trim())
|
|
163
|
+
throw new Error("this model has no API key configured");
|
|
164
|
+
return { provider: provider.name, model: { ...model }, intensity: input.intensity };
|
|
165
|
+
}
|
|
166
|
+
export function saveSession(messages, modelName, provider, intensity, cwd, sessionId, tokens, context, approvalMode) {
|
|
110
167
|
ensureDirs();
|
|
111
168
|
const now = new Date();
|
|
112
|
-
const id = sessionId ?? now.toISOString().replace(/[:.]/g, "-")
|
|
169
|
+
const id = sessionId ?? `${now.toISOString().replace(/[:.]/g, "-")}-${randomUUID().slice(0, 8)}`;
|
|
113
170
|
const existing = sessionId ? loadSession(sessionId) : null;
|
|
114
171
|
const created_at = existing ? existing.created_at : now.toISOString();
|
|
115
172
|
let title = "";
|
|
@@ -130,9 +187,37 @@ export function saveSession(messages, modelName, provider, intensity, cwd, sessi
|
|
|
130
187
|
title,
|
|
131
188
|
messages,
|
|
132
189
|
tokens,
|
|
190
|
+
approval_mode: normalizeApprovalMode(approvalMode ?? existing?.approval_mode),
|
|
133
191
|
};
|
|
192
|
+
const validContext = normalizeContext(context, messages.filter((m) => m.role !== "system").length);
|
|
193
|
+
if (validContext)
|
|
194
|
+
session.context = validContext;
|
|
134
195
|
const filePath = path.join(SESSION_DIR, `${id}.json`);
|
|
135
|
-
|
|
196
|
+
// Keep the last complete transcript intact if writing or replacing fails.
|
|
197
|
+
// The temporary file shares the destination directory so rename is atomic.
|
|
198
|
+
const tempPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
|
|
199
|
+
const contents = JSON.stringify(session, null, 2);
|
|
200
|
+
let fd;
|
|
201
|
+
try {
|
|
202
|
+
fd = fs.openSync(tempPath, "wx", 0o600);
|
|
203
|
+
fs.writeFileSync(fd, contents, "utf-8");
|
|
204
|
+
fs.fsyncSync(fd);
|
|
205
|
+
fs.closeSync(fd);
|
|
206
|
+
fd = undefined;
|
|
207
|
+
fs.renameSync(tempPath, filePath);
|
|
208
|
+
}
|
|
209
|
+
finally {
|
|
210
|
+
if (fd !== undefined) {
|
|
211
|
+
try {
|
|
212
|
+
fs.closeSync(fd);
|
|
213
|
+
}
|
|
214
|
+
catch { /* Preserve the write error. */ }
|
|
215
|
+
}
|
|
216
|
+
try {
|
|
217
|
+
fs.rmSync(tempPath, { force: true });
|
|
218
|
+
}
|
|
219
|
+
catch { /* Best-effort cleanup. */ }
|
|
220
|
+
}
|
|
136
221
|
return id;
|
|
137
222
|
}
|
|
138
223
|
export function loadSession(sessionId) {
|
|
@@ -186,6 +271,14 @@ export function listSessions() {
|
|
|
186
271
|
sessions.sort((a, b) => (b.updated_at || b.created_at).localeCompare(a.updated_at || a.created_at));
|
|
187
272
|
return sessions;
|
|
188
273
|
}
|
|
274
|
+
/**
|
|
275
|
+
* Sessions that were started in `cwd` (the directory neutrino runs in).
|
|
276
|
+
* The history menu is scoped to the current project so sessions started in
|
|
277
|
+
* other directories do not clutter it.
|
|
278
|
+
*/
|
|
279
|
+
export function listSessionsForCwd(cwd = process.cwd()) {
|
|
280
|
+
return listSessions().filter((s) => sameCwd(s.cwd, cwd));
|
|
281
|
+
}
|
|
189
282
|
function readAsset(...candidates) {
|
|
190
283
|
for (const candidate of candidates) {
|
|
191
284
|
try {
|
package/dist/cwd.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helpers for deciding whether a stored session belongs to the directory the
|
|
3
|
+
* agent is currently running in.
|
|
4
|
+
*
|
|
5
|
+
* Every agent records the working directory in its own flavour — `C:\a\b`,
|
|
6
|
+
* `C:/a/b`, a trailing slash, a differently cased drive letter, or a WSL style
|
|
7
|
+
* `/mnt/c/a/b` — so the comparison has to be forgiving about the spelling and
|
|
8
|
+
* strict about the actual location.
|
|
9
|
+
*/
|
|
10
|
+
/** Reduce a directory path to a canonical form that can be compared for equality. */
|
|
11
|
+
export function normalizeCwd(dir) {
|
|
12
|
+
let p = (dir ?? "").trim();
|
|
13
|
+
if (!p)
|
|
14
|
+
return "";
|
|
15
|
+
// The Windows long-path prefix ("\\?\C:\a") carries no meaning for us.
|
|
16
|
+
p = p.replace(/^\\\\\?\\/, "");
|
|
17
|
+
p = p.replace(/\\/g, "/").replace(/\/{2,}/g, "/");
|
|
18
|
+
let drive = "";
|
|
19
|
+
let rest = p;
|
|
20
|
+
const wslDrive = /^\/(?:mnt|cygdrive)\/([a-zA-Z])(\/.*)?$/.exec(p);
|
|
21
|
+
const winDrive = /^([a-zA-Z]):(\/.*)?$/.exec(p);
|
|
22
|
+
if (wslDrive) {
|
|
23
|
+
drive = wslDrive[1];
|
|
24
|
+
rest = wslDrive[2] ?? "/";
|
|
25
|
+
}
|
|
26
|
+
else if (winDrive) {
|
|
27
|
+
drive = winDrive[1];
|
|
28
|
+
rest = winDrive[2] ?? "/";
|
|
29
|
+
}
|
|
30
|
+
if (drive) {
|
|
31
|
+
if (rest.length > 1)
|
|
32
|
+
rest = rest.replace(/\/+$/, "");
|
|
33
|
+
// Drive paths are Windows paths, which are case-insensitive.
|
|
34
|
+
return `${drive.toLowerCase()}:${(rest || "/").toLowerCase()}`;
|
|
35
|
+
}
|
|
36
|
+
// POSIX paths are case-sensitive: only drop trailing separators.
|
|
37
|
+
return p.length > 1 ? p.replace(/\/+$/, "") : p;
|
|
38
|
+
}
|
|
39
|
+
/** True when both paths point at the same directory. Empty paths never match. */
|
|
40
|
+
export function sameCwd(a, b) {
|
|
41
|
+
const na = normalizeCwd(a);
|
|
42
|
+
const nb = normalizeCwd(b);
|
|
43
|
+
return na !== "" && na === nb;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Claude Code names each project directory after the working directory with
|
|
47
|
+
* every non-alphanumeric character replaced by a dash. That lets us attribute
|
|
48
|
+
* transcripts that do not carry an explicit `cwd` field.
|
|
49
|
+
*/
|
|
50
|
+
export function claudeProjectKey(dir) {
|
|
51
|
+
return normalizeCwd(dir)
|
|
52
|
+
.replace(/[^a-zA-Z0-9]/g, "-")
|
|
53
|
+
.toLowerCase();
|
|
54
|
+
}
|
package/dist/header.js
CHANGED
|
@@ -2,6 +2,14 @@ import { C } from "./colors.js";
|
|
|
2
2
|
import { truncateByWidth } from "./width.js";
|
|
3
3
|
export function drawHeader(buf, state) {
|
|
4
4
|
const w = buf.width;
|
|
5
|
+
if (state.approval) {
|
|
6
|
+
// A tool call is waiting for an answer: the whole bar turns orange so it
|
|
7
|
+
// cannot be missed while the agent sits paused mid-turn.
|
|
8
|
+
const bg = C.orange;
|
|
9
|
+
buf.fillRect(0, 0, w, 1, { bg });
|
|
10
|
+
buf.centerText(0, "Request Approval", { fg: C.black, bg, bold: true });
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
5
13
|
if (state.isThinking) {
|
|
6
14
|
// Flashing indicator: the whole bar alternates between highlighted and
|
|
7
15
|
// dark while the agent is working, so the user always notices it.
|