@xingjisoft/neutrino 0.1.7 → 0.2.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/README.md +151 -25
- package/assets/planner.txt +35 -0
- package/assets/prompt.txt +57 -21
- package/assets/step.txt +14 -0
- package/assets/subagent.txt +20 -0
- package/dist/app.js +44 -2
- package/dist/bin.js +38 -13
- package/dist/config.js +58 -1
- package/dist/header.js +5 -0
- package/dist/llm/client.js +101 -142
- package/dist/llm/plan.js +452 -0
- package/dist/llm/retry.js +209 -0
- package/dist/llm/subagent.js +137 -0
- package/dist/llm/tools.js +43 -1
- package/dist/screens/chat.js +384 -51
- package/dist/screens/settings.js +30 -1
- package/dist/state.js +1 -0
- package/dist/web/session.js +235 -24
- package/package.json +2 -2
- package/web/README.md +46 -10
- package/web/app.js +52 -16
- package/web/index.html +55 -17
- package/web/style.css +2070 -1093
package/dist/screens/settings.js
CHANGED
|
@@ -5,7 +5,7 @@ import { drawHeader } from "../header.js";
|
|
|
5
5
|
import { centerVertically, drawHint } from "../ui.js";
|
|
6
6
|
import { hashWebPassword } from "../web/auth.js";
|
|
7
7
|
/** The settings that can be changed here, top to bottom. */
|
|
8
|
-
const ROWS = ["intensity", "compress", "approval_model", "extreme_approval", "sound", "web_password"];
|
|
8
|
+
const ROWS = ["intensity", "compress", "approval_model", "extreme_approval", "sound", "web_console", "web_password"];
|
|
9
9
|
export class SettingsScreen extends Screen {
|
|
10
10
|
intensityIdx = 2;
|
|
11
11
|
contextTokens = DEFAULT_CONTEXT_WINDOW;
|
|
@@ -16,6 +16,10 @@ export class SettingsScreen extends Screen {
|
|
|
16
16
|
extremeIdx = 0;
|
|
17
17
|
/** 0 = silent, 1 = audible cue on turn end and on approval requests. */
|
|
18
18
|
soundIdx = 1;
|
|
19
|
+
/** 0 = web UI off, 1 = mounted alongside the terminal. */
|
|
20
|
+
webIdx = 1;
|
|
21
|
+
/** Only a row the user actually touched rewrites the saved default. */
|
|
22
|
+
webChanged = false;
|
|
19
23
|
models = [];
|
|
20
24
|
rowIdx = 0;
|
|
21
25
|
lastHoverX = -1;
|
|
@@ -38,6 +42,10 @@ export class SettingsScreen extends Screen {
|
|
|
38
42
|
this.approvalModelIdx = approvalAt >= 0 ? approvalAt + 1 : 0;
|
|
39
43
|
this.extremeIdx = config.auto_approve_extreme_risk === true ? 1 : 0;
|
|
40
44
|
this.soundIdx = config.sound_alerts === false ? 0 : 1;
|
|
45
|
+
// A console started by an explicit flag is on even if the saved default is
|
|
46
|
+
// off, so reflect what is actually running first.
|
|
47
|
+
this.webIdx = (this.app.state.webUrl || config.web_console !== false) ? 1 : 0;
|
|
48
|
+
this.webChanged = false;
|
|
41
49
|
}
|
|
42
50
|
/** Change the value of a row by `delta` steps and persist it immediately. */
|
|
43
51
|
cycle(row, delta) {
|
|
@@ -62,6 +70,15 @@ export class SettingsScreen extends Screen {
|
|
|
62
70
|
else if (row === "sound") {
|
|
63
71
|
this.soundIdx = this.soundIdx === 1 ? 0 : 1;
|
|
64
72
|
}
|
|
73
|
+
else if (row === "web_console") {
|
|
74
|
+
this.webIdx = this.webIdx === 1 ? 0 : 1;
|
|
75
|
+
this.webChanged = true;
|
|
76
|
+
this.save();
|
|
77
|
+
// Mount or unmount immediately; a failed bind is shown as a toast and
|
|
78
|
+
// the saved preference stays on so the next launch tries again.
|
|
79
|
+
void this.app.setWebConsole(this.webIdx === 1);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
65
82
|
else {
|
|
66
83
|
const options = [...new Set([...CONTEXT_WINDOW_OPTIONS, this.contextTokens])].sort((a, b) => a - b);
|
|
67
84
|
const i = options.indexOf(this.contextTokens);
|
|
@@ -209,6 +226,14 @@ export class SettingsScreen extends Screen {
|
|
|
209
226
|
label: "Sound alerts",
|
|
210
227
|
value: this.soundIdx === 1 ? "On (turn end, approvals)" : "Off",
|
|
211
228
|
},
|
|
229
|
+
{
|
|
230
|
+
label: "Web console",
|
|
231
|
+
value: this.webIdx === 0
|
|
232
|
+
? "Off"
|
|
233
|
+
: this.app.state.webUrl
|
|
234
|
+
? `On · ${this.app.state.webUrl}`
|
|
235
|
+
: "On (not running)",
|
|
236
|
+
},
|
|
212
237
|
{
|
|
213
238
|
label: "Web password",
|
|
214
239
|
value: this.editingPassword ? "*".repeat(Math.min(this.passwordDraft.length, 24)) || "Type new password" : this.passwordMessage,
|
|
@@ -244,6 +269,10 @@ export class SettingsScreen extends Screen {
|
|
|
244
269
|
config.approval_model = this.approvalModelIdx === 0 ? "" : this.models[this.approvalModelIdx - 1] ?? "";
|
|
245
270
|
config.auto_approve_extreme_risk = this.extremeIdx === 1;
|
|
246
271
|
config.sound_alerts = this.soundIdx === 1;
|
|
272
|
+
if (this.webChanged) {
|
|
273
|
+
config.web_console = this.webIdx === 1;
|
|
274
|
+
this.webChanged = false;
|
|
275
|
+
}
|
|
247
276
|
saveConfig(config);
|
|
248
277
|
}
|
|
249
278
|
render(buf) {
|
package/dist/state.js
CHANGED
package/dist/web/session.js
CHANGED
|
@@ -10,10 +10,12 @@
|
|
|
10
10
|
* registers itself in the live hub, so the regular `/api/live/*` endpoints
|
|
11
11
|
* (snapshot, stream, pause, resume, message) work unchanged.
|
|
12
12
|
*/
|
|
13
|
-
import { findModel, contextCompressionThreshold, loadConfig, normalizeApprovalMode, loadModels, loadPrompt, saveSession, } from "../config.js";
|
|
13
|
+
import { findModel, contextCompressionThreshold, loadConfig, loadPlannerPrompt, loadStepPrompt, loadSubAgentPrompt, normalizeApprovalMode, planFirstEnabled, loadModels, loadPrompt, saveSession, } from "../config.js";
|
|
14
14
|
import { COMPACT_KEEP, planCompaction, transcriptFor, modelHistory, normalizeContext, } from "../compact.js";
|
|
15
15
|
import { LLMClient, describeError } from "../llm/client.js";
|
|
16
|
-
import {
|
|
16
|
+
import { advancePlan, buildPlannerInput, lastRequestText, needsPlan, parsePlan, planMessage, planTranscript, renderStepDirective, resumePlan, stopPlan, } from "../llm/plan.js";
|
|
17
|
+
import { DELEGATE_TOOL, executeTool, getSubAgentToolDefinitions, getToolDefinitions } from "../llm/tools.js";
|
|
18
|
+
import { buildSubAgentTask, formatSubAgentResult, runSubAgent as runSubAgentTask, } from "../llm/subagent.js";
|
|
17
19
|
import { assessToolCall } from "../llm/risk.js";
|
|
18
20
|
import { ApprovalGate, captureApprovalContext, describeApprovalIntent } from "../llm/approval.js";
|
|
19
21
|
import { SessionCheckpoint } from "../persistence.js";
|
|
@@ -25,6 +27,8 @@ const IDLE_DISPOSE_MS = 30 * 60 * 1000;
|
|
|
25
27
|
const MAX_TOOL_ROUNDS = 24;
|
|
26
28
|
/** Placeholder shown while a tool is still running. */
|
|
27
29
|
const TOOL_PENDING = "…";
|
|
30
|
+
/** A sub-agent gets the same tools minus delegation: one level, never a tree. */
|
|
31
|
+
const SUB_AGENT_TOOLS = getSubAgentToolDefinitions();
|
|
28
32
|
/** Marker for the model-only summary, separate from the original transcript. */
|
|
29
33
|
const SUMMARY_PREFIX = "Context was compressed. Summary of the earlier part:";
|
|
30
34
|
function parseArguments(raw) {
|
|
@@ -82,7 +86,15 @@ export class WebSession {
|
|
|
82
86
|
compressing = false;
|
|
83
87
|
context = null;
|
|
84
88
|
streamingContent = null;
|
|
89
|
+
/** The plan of the current turn, if this turn was planned. */
|
|
90
|
+
plan = null;
|
|
91
|
+
/** True while the planner agent is writing the steps. */
|
|
92
|
+
planning = false;
|
|
93
|
+
/** What the sub-agent is doing, or null when none is running. */
|
|
94
|
+
subagent = null;
|
|
85
95
|
saveError = null;
|
|
96
|
+
/** A retried request, so the browser can explain why the stream is quiet. */
|
|
97
|
+
retrying = null;
|
|
86
98
|
checkpoint = new SessionCheckpoint(() => {
|
|
87
99
|
const saved = this.writeSession();
|
|
88
100
|
this.publishLive();
|
|
@@ -105,6 +117,7 @@ export class WebSession {
|
|
|
105
117
|
this.sessionId = source.id;
|
|
106
118
|
this.messages = source.messages.slice();
|
|
107
119
|
this.context = normalizeContext(source.context, this.messages.filter((m) => m.role !== "system").length);
|
|
120
|
+
this.plan = resumePlan(source.plan ?? null);
|
|
108
121
|
this.tokens = {
|
|
109
122
|
prompt: source.tokens?.prompt ?? 0,
|
|
110
123
|
completion: source.tokens?.completion ?? 0,
|
|
@@ -165,7 +178,11 @@ export class WebSession {
|
|
|
165
178
|
queued: this.queued,
|
|
166
179
|
compressing: this.compressing,
|
|
167
180
|
saveError: this.saveError,
|
|
181
|
+
retrying: this.retrying
|
|
182
|
+
? { attempt: this.retrying.attempt, attempts: this.retrying.attempts, delayMs: this.retrying.delayMs }
|
|
183
|
+
: null,
|
|
168
184
|
tokens: { prompt: this.tokens.prompt, completion: this.tokens.completion },
|
|
185
|
+
subagent: this.subagent,
|
|
169
186
|
approval: this.pendingApproval
|
|
170
187
|
? liveApprovalDetails(this.pendingApproval.tool, this.pendingApproval.args, this.pendingApproval.risk, this.dir)
|
|
171
188
|
: null,
|
|
@@ -197,6 +214,7 @@ export class WebSession {
|
|
|
197
214
|
messages,
|
|
198
215
|
streamingContent: this.streamingContent,
|
|
199
216
|
context: this.context,
|
|
217
|
+
plan: this.plan,
|
|
200
218
|
};
|
|
201
219
|
}
|
|
202
220
|
livePause() {
|
|
@@ -212,6 +230,8 @@ export class WebSession {
|
|
|
212
230
|
}
|
|
213
231
|
this.busy = false;
|
|
214
232
|
this.retainStreamingContent();
|
|
233
|
+
this.subagent = null;
|
|
234
|
+
stopPlan(this.plan);
|
|
215
235
|
this.save();
|
|
216
236
|
this.publishLive();
|
|
217
237
|
}
|
|
@@ -278,20 +298,121 @@ export class WebSession {
|
|
|
278
298
|
this.streamingContent = "";
|
|
279
299
|
this.publishLive();
|
|
280
300
|
void (async () => {
|
|
281
|
-
await this.
|
|
282
|
-
if (token !== this.turnToken || this.paused)
|
|
283
|
-
return;
|
|
284
|
-
await this.agentLoop(this.buildApiMessages(turnMessages), token, approvalContext);
|
|
301
|
+
await this.runTurn(token, turnMessages, approvalContext);
|
|
285
302
|
})().catch((err) => {
|
|
286
303
|
if (token === this.turnToken)
|
|
287
304
|
this.failTurn(err);
|
|
288
305
|
});
|
|
289
306
|
}
|
|
307
|
+
/**
|
|
308
|
+
* One user turn, mirroring the terminal: plan first, then execute the steps in
|
|
309
|
+
* order, one agent loop per step. Without a usable plan the turn runs in one
|
|
310
|
+
* pass, exactly as it did before planning existed.
|
|
311
|
+
*/
|
|
312
|
+
async runTurn(token, turnMessages, approvalContext) {
|
|
313
|
+
await this.autoCompress(token);
|
|
314
|
+
if (token !== this.turnToken || this.paused)
|
|
315
|
+
return;
|
|
316
|
+
const plan = await this.makePlan(token);
|
|
317
|
+
if (token !== this.turnToken || this.paused)
|
|
318
|
+
return;
|
|
319
|
+
if (plan) {
|
|
320
|
+
this.plan = plan;
|
|
321
|
+
this.messages.push({ role: "assistant", content: planMessage(plan) });
|
|
322
|
+
this.save();
|
|
323
|
+
this.publishLive();
|
|
324
|
+
const template = loadStepPrompt();
|
|
325
|
+
for (let i = 0; i < plan.steps.length; i++) {
|
|
326
|
+
if (token !== this.turnToken || this.paused)
|
|
327
|
+
return;
|
|
328
|
+
this.setStep(i, "active");
|
|
329
|
+
const finished = await this.agentLoop(this.buildApiMessages(this.turnView(turnMessages), renderStepDirective(template, plan, i)), token, approvalContext);
|
|
330
|
+
if (token !== this.turnToken || this.paused)
|
|
331
|
+
return;
|
|
332
|
+
this.setStep(i, finished ? "done" : "failed");
|
|
333
|
+
if (!finished)
|
|
334
|
+
break;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
else {
|
|
338
|
+
await this.agentLoop(this.buildApiMessages(this.turnView(turnMessages)), token, approvalContext);
|
|
339
|
+
if (token !== this.turnToken || this.paused)
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
this.onTurnFinished();
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* The transcript this turn may see: the messages it started with plus what it
|
|
346
|
+
* added since. A user message queued while the turn runs belongs to the next
|
|
347
|
+
* turn, so the model never sees it early.
|
|
348
|
+
*/
|
|
349
|
+
turnView(snapshot) {
|
|
350
|
+
const view = snapshot.slice();
|
|
351
|
+
for (let i = snapshot.length; i < this.messages.length; i++) {
|
|
352
|
+
if (this.messages[i].role !== "user")
|
|
353
|
+
view.push(this.messages[i]);
|
|
354
|
+
}
|
|
355
|
+
return view;
|
|
356
|
+
}
|
|
357
|
+
/** Move the plan to one step and publish it. */
|
|
358
|
+
setStep(index, status) {
|
|
359
|
+
if (!advancePlan(this.plan, index, status))
|
|
360
|
+
return;
|
|
361
|
+
this.save();
|
|
362
|
+
this.publishLive();
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Run the planner agent over the current request. Null on every failure path:
|
|
366
|
+
* a plan must improve a turn, never be a new way for one to fail.
|
|
367
|
+
*/
|
|
368
|
+
async makePlan(token) {
|
|
369
|
+
if (!planFirstEnabled())
|
|
370
|
+
return null;
|
|
371
|
+
const request = lastRequestText(this.messages);
|
|
372
|
+
if (!request.trim())
|
|
373
|
+
return null;
|
|
374
|
+
// One action is not a plan: run the turn in one pass, as the terminal does.
|
|
375
|
+
if (!needsPlan(request))
|
|
376
|
+
return null;
|
|
377
|
+
this.planning = true;
|
|
378
|
+
this.publishLive();
|
|
379
|
+
try {
|
|
380
|
+
const client = new LLMClient(this.model, this.provider, this.intensity);
|
|
381
|
+
const answer = await client.complete(loadPlannerPrompt(), buildPlannerInput({ request, cwd: this.dir, transcript: planTranscript(this.messages) }), {
|
|
382
|
+
onRetry: (notice) => {
|
|
383
|
+
this.retrying = notice;
|
|
384
|
+
this.publishLive();
|
|
385
|
+
},
|
|
386
|
+
cancelled: () => token !== this.turnToken || this.paused,
|
|
387
|
+
});
|
|
388
|
+
if (client.usage) {
|
|
389
|
+
this.tokens.prompt += client.usage.prompt;
|
|
390
|
+
this.tokens.completion += client.usage.completion;
|
|
391
|
+
this.lastPromptTokens = client.usage.prompt;
|
|
392
|
+
}
|
|
393
|
+
if (token !== this.turnToken)
|
|
394
|
+
return null;
|
|
395
|
+
return parsePlan(answer);
|
|
396
|
+
}
|
|
397
|
+
catch {
|
|
398
|
+
return null;
|
|
399
|
+
}
|
|
400
|
+
finally {
|
|
401
|
+
this.planning = false;
|
|
402
|
+
this.retrying = null;
|
|
403
|
+
if (token === this.turnToken)
|
|
404
|
+
this.publishLive();
|
|
405
|
+
}
|
|
406
|
+
}
|
|
290
407
|
/** Ends a turn whose floating promise rejected, instead of crashing the app. */
|
|
291
408
|
failTurn(err) {
|
|
292
409
|
this.retainStreamingContent();
|
|
293
410
|
this.messages.push({ role: "assistant", content: `[Error: ${describeError(err)}]` });
|
|
294
411
|
this.streamingContent = null;
|
|
412
|
+
// The turn died outside the loop, so the step it was on never reported back.
|
|
413
|
+
const active = this.plan?.steps.findIndex((step) => step.status === "active") ?? -1;
|
|
414
|
+
if (active >= 0)
|
|
415
|
+
advancePlan(this.plan, active, "failed");
|
|
295
416
|
try {
|
|
296
417
|
this.onTurnFinished();
|
|
297
418
|
}
|
|
@@ -300,8 +421,12 @@ export class WebSession {
|
|
|
300
421
|
this.paused = false;
|
|
301
422
|
}
|
|
302
423
|
}
|
|
303
|
-
buildApiMessages(messages = this.messages) {
|
|
304
|
-
|
|
424
|
+
buildApiMessages(messages = this.messages, directive = "") {
|
|
425
|
+
// A step directive extends the system prompt: the plan is an instruction, not
|
|
426
|
+
// something the user just said.
|
|
427
|
+
const api = [
|
|
428
|
+
{ role: "system", content: directive ? `${loadPrompt()}\n\n${directive}` : loadPrompt() },
|
|
429
|
+
];
|
|
305
430
|
for (const m of modelHistory(messages, this.context)) {
|
|
306
431
|
if (m.role === "user" || m.role === "assistant") {
|
|
307
432
|
api.push({ role: m.role, content: m.content });
|
|
@@ -312,44 +437,75 @@ export class WebSession {
|
|
|
312
437
|
}
|
|
313
438
|
return api;
|
|
314
439
|
}
|
|
440
|
+
/**
|
|
441
|
+
* One agent conversation for a single request (or a single plan step). Returns
|
|
442
|
+
* true when the model finished on its own, false when the turn was cut short;
|
|
443
|
+
* the caller owns the turn's end because a planned turn runs this per step.
|
|
444
|
+
*/
|
|
315
445
|
async agentLoop(apiMessages, token, approvalContext) {
|
|
316
446
|
const tools = getToolDefinitions();
|
|
317
447
|
try {
|
|
318
448
|
for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
|
|
319
449
|
if (token !== this.turnToken || this.paused)
|
|
320
|
-
return;
|
|
450
|
+
return false;
|
|
321
451
|
const client = new LLMClient(this.model, this.provider, this.intensity);
|
|
322
452
|
let text = "";
|
|
323
453
|
this.streamingContent = "";
|
|
324
454
|
this.publishLive();
|
|
325
|
-
for await (const chunk of client.streamTurn(apiMessages, tools
|
|
455
|
+
for await (const chunk of client.streamTurn(apiMessages, tools, {
|
|
456
|
+
onRetry: (notice) => {
|
|
457
|
+
this.retrying = notice;
|
|
458
|
+
this.publishLive();
|
|
459
|
+
},
|
|
460
|
+
cancelled: () => token !== this.turnToken || this.paused,
|
|
461
|
+
})) {
|
|
462
|
+
this.retrying = null;
|
|
326
463
|
if (token !== this.turnToken || this.paused)
|
|
327
|
-
return;
|
|
464
|
+
return false;
|
|
328
465
|
text += chunk;
|
|
329
466
|
this.streamingContent = text;
|
|
330
467
|
this.checkpoint.schedule();
|
|
331
468
|
this.publishLive();
|
|
332
469
|
}
|
|
333
470
|
if (token !== this.turnToken || this.paused)
|
|
334
|
-
return;
|
|
471
|
+
return false;
|
|
335
472
|
this.streamingContent = null;
|
|
336
473
|
if (client.usage) {
|
|
337
474
|
this.tokens.prompt += client.usage.prompt;
|
|
338
475
|
this.tokens.completion += client.usage.completion;
|
|
339
476
|
this.lastPromptTokens = client.usage.prompt;
|
|
340
477
|
}
|
|
478
|
+
const toolCalls = client.toolCalls;
|
|
341
479
|
if (text.trim()) {
|
|
342
480
|
this.messages.push({ role: "assistant", content: text });
|
|
343
|
-
|
|
481
|
+
}
|
|
482
|
+
if (text.trim() || toolCalls.length > 0) {
|
|
483
|
+
// The assistant message and the tool results it produced must stay
|
|
484
|
+
// paired: a `tool` message without its `tool_calls` parent is
|
|
485
|
+
// rejected by every OpenAI-compatible endpoint. The reasoning is the
|
|
486
|
+
// thinking that produced the calls, which DeepSeek requires back.
|
|
487
|
+
apiMessages.push({
|
|
488
|
+
role: "assistant",
|
|
489
|
+
content: text,
|
|
490
|
+
reasoning_content: client.reasoningContent ?? "",
|
|
491
|
+
...(toolCalls.length > 0
|
|
492
|
+
? {
|
|
493
|
+
tool_calls: toolCalls.map((call) => ({
|
|
494
|
+
id: call.id,
|
|
495
|
+
type: "function",
|
|
496
|
+
function: { name: call.name, arguments: call.arguments },
|
|
497
|
+
})),
|
|
498
|
+
}
|
|
499
|
+
: {}),
|
|
500
|
+
});
|
|
344
501
|
}
|
|
345
502
|
this.save();
|
|
346
503
|
this.publishLive();
|
|
347
|
-
const toolCalls = client.toolCalls;
|
|
348
504
|
if (toolCalls.length === 0)
|
|
349
505
|
break;
|
|
350
506
|
for (const call of toolCalls) {
|
|
351
507
|
if (token !== this.turnToken || this.paused)
|
|
352
|
-
return;
|
|
508
|
+
return false;
|
|
353
509
|
const args = parseArguments(call.arguments);
|
|
354
510
|
const toolMsg = {
|
|
355
511
|
role: "tool",
|
|
@@ -365,7 +521,7 @@ export class WebSession {
|
|
|
365
521
|
context: captureApprovalContext(apiMessages).context,
|
|
366
522
|
});
|
|
367
523
|
if (token !== this.turnToken || this.paused)
|
|
368
|
-
return;
|
|
524
|
+
return false;
|
|
369
525
|
if (!decision.approved) {
|
|
370
526
|
// A malformed request was never shown to anyone, so there is no
|
|
371
527
|
// verdict to respect and the right move is a corrected retry.
|
|
@@ -382,27 +538,27 @@ export class WebSession {
|
|
|
382
538
|
}
|
|
383
539
|
let result;
|
|
384
540
|
try {
|
|
385
|
-
result = await
|
|
541
|
+
result = await this.runTool(call.name, args, toolMsg, token, apiMessages, approvalContext);
|
|
386
542
|
}
|
|
387
543
|
catch (err) {
|
|
388
544
|
result = `Error: ${err instanceof Error ? err.message : String(err)}`;
|
|
389
545
|
}
|
|
390
546
|
if (this.disposed)
|
|
391
|
-
return;
|
|
547
|
+
return false;
|
|
392
548
|
toolMsg.result = result;
|
|
393
549
|
this.save();
|
|
394
550
|
if (token !== this.turnToken || this.paused)
|
|
395
|
-
return;
|
|
551
|
+
return false;
|
|
396
552
|
apiMessages.push({ role: "tool", content: result, tool_call_id: call.id });
|
|
397
553
|
this.publishLive();
|
|
398
554
|
}
|
|
399
555
|
this.save();
|
|
400
556
|
}
|
|
401
|
-
|
|
557
|
+
return true;
|
|
402
558
|
}
|
|
403
559
|
catch (err) {
|
|
404
560
|
if (token !== this.turnToken)
|
|
405
|
-
return;
|
|
561
|
+
return false;
|
|
406
562
|
if (this.streamingContent) {
|
|
407
563
|
this.messages.push({ role: "assistant", content: this.streamingContent });
|
|
408
564
|
}
|
|
@@ -411,10 +567,11 @@ export class WebSession {
|
|
|
411
567
|
role: "assistant",
|
|
412
568
|
content: `[Error: ${err instanceof Error ? err.message : String(err)}]`,
|
|
413
569
|
});
|
|
414
|
-
|
|
570
|
+
return false;
|
|
415
571
|
}
|
|
416
572
|
}
|
|
417
573
|
onTurnFinished() {
|
|
574
|
+
this.retrying = null;
|
|
418
575
|
this.busy = false;
|
|
419
576
|
this.paused = false;
|
|
420
577
|
this.streamingContent = null;
|
|
@@ -463,6 +620,58 @@ export class WebSession {
|
|
|
463
620
|
}
|
|
464
621
|
return { model: this.model, provider: this.provider, intensity: this.intensity };
|
|
465
622
|
}
|
|
623
|
+
// ---- sub-agents ------------------------------------------------------
|
|
624
|
+
/**
|
|
625
|
+
* Run one approved tool call. `delegate` runs a child agent instead of
|
|
626
|
+
* touching the filesystem, so it is handled here, where the session's model,
|
|
627
|
+
* approval gate and abort signal live.
|
|
628
|
+
*/
|
|
629
|
+
async runTool(name, args, toolMsg, token, apiMessages, approvalContext) {
|
|
630
|
+
if (name !== DELEGATE_TOOL)
|
|
631
|
+
return executeTool(name, args, this.dir);
|
|
632
|
+
return this.delegate(args, toolMsg, token, apiMessages, approvalContext);
|
|
633
|
+
}
|
|
634
|
+
/**
|
|
635
|
+
* Run a sub-agent for one `delegate` call and return its report. The child
|
|
636
|
+
* works in its own transcript; only the report reaches this conversation, and
|
|
637
|
+
* its risky calls go through the same approval gate as the parent turn.
|
|
638
|
+
*/
|
|
639
|
+
async delegate(args, toolMsg, token, apiMessages, approvalContext) {
|
|
640
|
+
const task = String(args.task ?? "").trim();
|
|
641
|
+
if (!task)
|
|
642
|
+
return "[Error: delegate needs a task: what should the sub-agent do?]";
|
|
643
|
+
const context = typeof args.context === "string" ? args.context.trim() : "";
|
|
644
|
+
const approval = { ...approvalContext, context: captureApprovalContext(apiMessages).context };
|
|
645
|
+
const run = await runSubAgentTask({
|
|
646
|
+
createClient: () => new LLMClient(this.model, this.provider, this.intensity),
|
|
647
|
+
system: loadSubAgentPrompt(),
|
|
648
|
+
task: buildSubAgentTask({ task, context, cwd: this.dir }),
|
|
649
|
+
tools: SUB_AGENT_TOOLS,
|
|
650
|
+
approve: (name, callArgs) => this.requestApproval(name, callArgs, approval),
|
|
651
|
+
execute: (name, callArgs) => name === DELEGATE_TOOL
|
|
652
|
+
? Promise.resolve("[Error: sub-agents cannot delegate further]")
|
|
653
|
+
: executeTool(name, callArgs, this.dir),
|
|
654
|
+
onProgress: (label) => {
|
|
655
|
+
this.subagent = label;
|
|
656
|
+
toolMsg.result = `… ${label}`;
|
|
657
|
+
this.publishLive();
|
|
658
|
+
},
|
|
659
|
+
onUsage: (usage) => {
|
|
660
|
+
this.tokens.prompt += usage.prompt;
|
|
661
|
+
this.tokens.completion += usage.completion;
|
|
662
|
+
},
|
|
663
|
+
onRetry: (notice) => {
|
|
664
|
+
this.retrying = notice;
|
|
665
|
+
this.publishLive();
|
|
666
|
+
},
|
|
667
|
+
cancelled: () => token !== this.turnToken || this.paused || this.disposed,
|
|
668
|
+
});
|
|
669
|
+
this.subagent = null;
|
|
670
|
+
this.retrying = null;
|
|
671
|
+
toolMsg.result = TOOL_PENDING;
|
|
672
|
+
this.publishLive();
|
|
673
|
+
return formatSubAgentResult(run);
|
|
674
|
+
}
|
|
466
675
|
// ---- compression -----------------------------------------------------
|
|
467
676
|
compressThreshold() {
|
|
468
677
|
return contextCompressionThreshold(this.provider, this.model);
|
|
@@ -487,7 +696,9 @@ export class WebSession {
|
|
|
487
696
|
this.publishLive();
|
|
488
697
|
let summary = "";
|
|
489
698
|
try {
|
|
490
|
-
summary = await new LLMClient(this.model, this.provider, this.intensity).summarize(transcript
|
|
699
|
+
summary = await new LLMClient(this.model, this.provider, this.intensity).summarize(transcript, {
|
|
700
|
+
cancelled: () => token !== this.turnToken || this.paused,
|
|
701
|
+
});
|
|
491
702
|
}
|
|
492
703
|
catch {
|
|
493
704
|
summary = "";
|
|
@@ -538,7 +749,7 @@ export class WebSession {
|
|
|
538
749
|
if (chat.length === 0)
|
|
539
750
|
return true;
|
|
540
751
|
try {
|
|
541
|
-
this.sessionId = saveSession(chat, this.model.name, this.provider, this.intensity, this.dir, this.sessionId, this.tokens, this.context, this.approvalMode);
|
|
752
|
+
this.sessionId = saveSession(chat, this.model.name, this.provider, this.intensity, this.dir, this.sessionId, this.tokens, this.context, this.approvalMode, this.plan);
|
|
542
753
|
this.saveError = null;
|
|
543
754
|
return true;
|
|
544
755
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xingjisoft/neutrino",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "XINGJI Neutrino - a CLI coding agent in the terminal.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
},
|
|
19
19
|
"scripts": {
|
|
20
20
|
"build": "tsc -p tsconfig.json",
|
|
21
|
-
"test": "npm run build && node scripts/test-paste.mjs && node scripts/test-mouse.mjs && node scripts/test-cursor.mjs && node scripts/test-selection.mjs && node scripts/test-drag-scroll.mjs && node scripts/test-selection-performance.mjs && node scripts/test-copy.mjs && node scripts/test-scrollbar.mjs && node scripts/test-render.mjs && node scripts/test-drift.mjs && node scripts/test-sanitize.mjs && node scripts/test-markdown.mjs && node scripts/test-wrap.mjs && node scripts/test-fetch.mjs && node scripts/test-compact.mjs && node scripts/test-history-fold.mjs && node scripts/test-web-history.mjs && node scripts/test-turn-fold.mjs && node scripts/test-web-settings.mjs && node scripts/test-tools.mjs && node scripts/test-approval.mjs && node scripts/test-approval-review.mjs && node scripts/test-approval-ui.mjs && node scripts/test-web-approval-details.mjs && node scripts/test-approval-mode.mjs && node scripts/test-approval-context.mjs && node scripts/test-settings-render.mjs && node scripts/test-approval-flow.mjs && node scripts/test-web.mjs && node scripts/test-web-settings-http.mjs && node scripts/test-web-auth.mjs && node scripts/test-web-auth-ui.mjs && node scripts/test-interrupt.mjs && node scripts/test-terminal-lifecycle.mjs && node scripts/test-persistence.mjs && node scripts/test-resilience.mjs && node scripts/test-approval-timeout.mjs && node scripts/test-approval-hardening.mjs && node scripts/test-approval-extreme.mjs && node scripts/test-approval-reason.mjs",
|
|
21
|
+
"test": "npm run build && node scripts/test-paste.mjs && node scripts/test-mouse.mjs && node scripts/test-cursor.mjs && node scripts/test-selection.mjs && node scripts/test-drag-scroll.mjs && node scripts/test-selection-performance.mjs && node scripts/test-copy.mjs && node scripts/test-scrollbar.mjs && node scripts/test-render.mjs && node scripts/test-drift.mjs && node scripts/test-sanitize.mjs && node scripts/test-markdown.mjs && node scripts/test-wrap.mjs && node scripts/test-fetch.mjs && node scripts/test-compact.mjs && node scripts/test-history-fold.mjs && node scripts/test-web-history.mjs && node scripts/test-web-default.mjs && node scripts/test-turn-fold.mjs && node scripts/test-plan.mjs && node scripts/test-subagent.mjs && node scripts/test-web-settings.mjs && node scripts/test-tools.mjs && node scripts/test-approval.mjs && node scripts/test-approval-review.mjs && node scripts/test-approval-ui.mjs && node scripts/test-web-approval-details.mjs && node scripts/test-approval-mode.mjs && node scripts/test-approval-context.mjs && node scripts/test-settings-render.mjs && node scripts/test-approval-flow.mjs && node scripts/test-web.mjs && node scripts/test-web-settings-http.mjs && node scripts/test-web-auth.mjs && node scripts/test-web-auth-ui.mjs && node scripts/test-interrupt.mjs && node scripts/test-terminal-lifecycle.mjs && node scripts/test-persistence.mjs && node scripts/test-resilience.mjs && node scripts/test-approval-timeout.mjs && node scripts/test-approval-hardening.mjs && node scripts/test-approval-extreme.mjs && node scripts/test-approval-reason.mjs",
|
|
22
22
|
"prepare": "npm run build",
|
|
23
23
|
"web": "node dist/bin.js web"
|
|
24
24
|
},
|
package/web/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Neutrino Web UI
|
|
2
2
|
|
|
3
|
-
A small, dependency-free single page app that is served by the Neutrino CLI
|
|
4
|
-
|
|
3
|
+
A small, dependency-free single page app that is served by the Neutrino CLI at
|
|
4
|
+
<http://127.0.0.1:8765> whenever the terminal UI runs. It lets you:
|
|
5
5
|
|
|
6
6
|
- browse every saved conversation grouped by working directory (like Codex),
|
|
7
7
|
with only the five latest transcripts per path shown and the rest collapsed;
|
|
@@ -19,12 +19,15 @@ Nothing needs to be built — these are plain `.html`, `.css` and `.js` files.
|
|
|
19
19
|
|
|
20
20
|
## Running it
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
The terminal UI starts the website by default:
|
|
23
23
|
|
|
24
24
|
```sh
|
|
25
|
-
neutrino
|
|
25
|
+
neutrino
|
|
26
26
|
```
|
|
27
27
|
|
|
28
|
+
Use `--port` to move it, `--no-web` to skip it for that run, or turn it off
|
|
29
|
+
permanently in the terminal's **Settings → Web console**.
|
|
30
|
+
|
|
28
31
|
Or serve only the saved history, without a terminal:
|
|
29
32
|
|
|
30
33
|
```sh
|
|
@@ -41,8 +44,9 @@ The server binds to loopback by default. To open it from your phone or another
|
|
|
41
44
|
machine, bind an interface others can reach:
|
|
42
45
|
|
|
43
46
|
```sh
|
|
44
|
-
neutrino
|
|
45
|
-
neutrino
|
|
47
|
+
neutrino --lan # TUI + web UI, every interface (0.0.0.0)
|
|
48
|
+
neutrino --host 100.64.0.7 # TUI + web UI, one specific address
|
|
49
|
+
neutrino web --port 8765 --lan # headless web UI, every interface
|
|
46
50
|
```
|
|
47
51
|
|
|
48
52
|
Startup prints the addresses to open:
|
|
@@ -97,7 +101,7 @@ disables password login.
|
|
|
97
101
|
|
|
98
102
|
## Models and settings
|
|
99
103
|
|
|
100
|
-
Open a conversation to see the **Provider**, **Model** and **Thinking
|
|
104
|
+
Open a conversation to see the **Provider**, **Model** and **Thinking**
|
|
101
105
|
selectors. Choose a configured model and press **Apply to conversation**. The
|
|
102
106
|
next turn uses that selection, and it is saved with the transcript. The same
|
|
103
107
|
controls also update a conversation running in the terminal. Pause an active
|
|
@@ -108,7 +112,7 @@ selection. Changing the selectors and then using **Continue here** or sending a
|
|
|
108
112
|
message also uses the draft selection, so a transcript whose old model was
|
|
109
113
|
removed can still be continued.
|
|
110
114
|
|
|
111
|
-
The
|
|
115
|
+
The **Settings** (gear) button in the sidebar opens defaults for new conversations,
|
|
112
116
|
the context compression threshold, the approval reviewer model, and the
|
|
113
117
|
extreme-risk automatic approval option. Save settings explicitly; cancelling
|
|
114
118
|
leaves the saved values intact. Conversation model changes are separate from
|
|
@@ -119,12 +123,12 @@ Intensity uses the same Low–Ultra sampling settings as the terminal.
|
|
|
119
123
|
## History vs. live sessions
|
|
120
124
|
|
|
121
125
|
A saved transcript on disk is a finished record: nothing is running, so the
|
|
122
|
-
page shows it read-only (
|
|
126
|
+
page shows it read-only (a neutral `read-only` badge) and there is nothing to
|
|
123
127
|
pause.
|
|
124
128
|
|
|
125
129
|
To continue one, either
|
|
126
130
|
|
|
127
|
-
- press
|
|
131
|
+
- press **Continue here** in the header, or
|
|
128
132
|
- just start typing in the composer and press Enter.
|
|
129
133
|
|
|
130
134
|
Both create a *browser-owned session*: the server resumes the transcript from
|
|
@@ -140,6 +144,38 @@ Browser-owned sessions are dropped after 30 idle minutes, or immediately through
|
|
|
140
144
|
`POST /api/live/:key/close`. Sessions owned by the terminal cannot be closed from
|
|
141
145
|
the web.
|
|
142
146
|
|
|
147
|
+
## Plan-first turns
|
|
148
|
+
|
|
149
|
+
A browser-owned turn is planned before it runs, exactly like a turn in the
|
|
150
|
+
terminal: the planner agent writes the ordered steps, then each step is executed
|
|
151
|
+
one at a time. The plan is written into the transcript as an assistant message,
|
|
152
|
+
so it shows up in the conversation with everything else, and it is saved with the
|
|
153
|
+
session (`plan` in the session file) so a resumed transcript still knows its
|
|
154
|
+
steps.
|
|
155
|
+
|
|
156
|
+
The plan travels in every live snapshot as a structured field — no text parsing
|
|
157
|
+
needed by a client:
|
|
158
|
+
|
|
159
|
+
```json
|
|
160
|
+
"plan": {
|
|
161
|
+
"goal": "build and animate the scene",
|
|
162
|
+
"steps": [
|
|
163
|
+
{ "title": "Model the case from box.png", "detail": "match the reference", "status": "done" },
|
|
164
|
+
{ "title": "Model the monitor from vision.jpg", "status": "active" },
|
|
165
|
+
{ "title": "Animate the camera", "status": "pending" }
|
|
166
|
+
]
|
|
167
|
+
}
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
`status` is `pending`, `active`, `done` or `failed`; `stopped: true` marks a plan
|
|
171
|
+
whose turn was paused before the last step. The terminal draws this in its `ⓘ`
|
|
172
|
+
card; a browser client can render the same timeline from the snapshot. The page
|
|
173
|
+
itself currently shows the plan as the message it already is.
|
|
174
|
+
|
|
175
|
+
Planning is the default for both loops. It can be turned off with
|
|
176
|
+
`"plan_first": false` in `~/.neutrino/config.json` or `NEUTRINO_PLAN=off`; a
|
|
177
|
+
request that produces no usable plan then runs in one pass.
|
|
178
|
+
|
|
143
179
|
## How real-time works
|
|
144
180
|
|
|
145
181
|
A `ChatScreen` registers itself with the in-process hub when it is opened. Every
|