@xingjisoft/neutrino 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,572 @@
1
+ /**
2
+ * Headless conversation runner for the web UI.
3
+ *
4
+ * The TUI keeps its agent loop inside `ChatScreen`, glued to the renderer. The
5
+ * browser needs the same behaviour without a terminal, so this module resumes a
6
+ * saved transcript from disk and runs turns against the configured model:
7
+ * stream, execute tools, auto-compress, persist.
8
+ *
9
+ * A `WebSession` looks exactly like a TUI session from the outside: it
10
+ * registers itself in the live hub, so the regular `/api/live/*` endpoints
11
+ * (snapshot, stream, pause, resume, message) work unchanged.
12
+ */
13
+ import { findModel, contextCompressionThreshold, loadConfig, normalizeApprovalMode, loadModels, loadPrompt, saveSession, } from "../config.js";
14
+ import { COMPACT_KEEP, planCompaction, transcriptFor, modelHistory, normalizeContext, } from "../compact.js";
15
+ import { LLMClient, describeError } from "../llm/client.js";
16
+ import { executeTool, getToolDefinitions } from "../llm/tools.js";
17
+ import { assessToolCall } from "../llm/risk.js";
18
+ import { ApprovalGate, captureApprovalContext, describeApprovalIntent } from "../llm/approval.js";
19
+ import { SessionCheckpoint } from "../persistence.js";
20
+ import { writeCrashLog } from "../terminal/restore.js";
21
+ import { hub, liveApprovalDetails, } from "./hub.js";
22
+ /** Drop a resumed conversation nobody touched for this long. */
23
+ const IDLE_DISPOSE_MS = 30 * 60 * 1000;
24
+ /** Upper bound on tool round-trips inside a single turn. */
25
+ const MAX_TOOL_ROUNDS = 24;
26
+ /** Placeholder shown while a tool is still running. */
27
+ const TOOL_PENDING = "…";
28
+ /** Marker for the model-only summary, separate from the original transcript. */
29
+ const SUMMARY_PREFIX = "Context was compressed. Summary of the earlier part:";
30
+ function parseArguments(raw) {
31
+ const text = raw?.trim() ?? "";
32
+ if (!text)
33
+ return {};
34
+ try {
35
+ const parsed = JSON.parse(text);
36
+ if (parsed && typeof parsed === "object")
37
+ return parsed;
38
+ }
39
+ catch {
40
+ // Hand the raw payload over instead of failing the whole turn.
41
+ return { value: text };
42
+ }
43
+ return { value: text };
44
+ }
45
+ /**
46
+ * Look up the model recorded in the transcript. Returns a readable error
47
+ * instead of throwing, so the HTTP layer can answer with a clean 4xx.
48
+ */
49
+ export function resolveModel(modelName, providerName) {
50
+ const providers = loadModels().providers ?? [];
51
+ const scoped = providers.find((p) => p.name === providerName);
52
+ const model = scoped?.models?.find((m) => m.name === modelName);
53
+ if (!model) {
54
+ return { ok: false, error: `model "${modelName}" is not configured any more` };
55
+ }
56
+ if (!model.api_key || !model.api_key.trim()) {
57
+ return { ok: false, error: `model "${modelName}" has no API key configured` };
58
+ }
59
+ return { ok: true, model };
60
+ }
61
+ /** Build a web session from a saved transcript, or explain why we cannot. */
62
+ export function resumeSession(source, selection) {
63
+ const updated = selection ? { ...source, ...selection } : source;
64
+ const resolved = resolveModel(updated.model, updated.provider);
65
+ if (!resolved.ok)
66
+ return resolved;
67
+ return { ok: true, session: new WebSession(updated, resolved.model) };
68
+ }
69
+ export class WebSession {
70
+ key = null;
71
+ model;
72
+ provider;
73
+ intensity;
74
+ dir;
75
+ tokens;
76
+ messages;
77
+ sessionId;
78
+ busy = false;
79
+ approvalMode;
80
+ paused = false;
81
+ queued = null;
82
+ compressing = false;
83
+ context = null;
84
+ streamingContent = null;
85
+ saveError = null;
86
+ checkpoint = new SessionCheckpoint(() => {
87
+ const saved = this.writeSession();
88
+ this.publishLive();
89
+ return saved;
90
+ });
91
+ turnToken = 0;
92
+ lastPromptTokens = 0;
93
+ disposed = false;
94
+ idleTimer = null;
95
+ /** Non-null while a tool call waits for the browser to answer. */
96
+ pendingApproval = null;
97
+ /** Reviewer count while an automated review runs, null otherwise. */
98
+ reviewing = null;
99
+ constructor(source, model) {
100
+ this.model = model;
101
+ this.approvalMode = normalizeApprovalMode(source.approval_mode ?? loadConfig().approval_mode);
102
+ this.provider = source.provider;
103
+ this.intensity = source.intensity;
104
+ this.dir = source.cwd;
105
+ this.sessionId = source.id;
106
+ this.messages = source.messages.slice();
107
+ this.context = normalizeContext(source.context, this.messages.filter((m) => m.role !== "system").length);
108
+ this.tokens = {
109
+ prompt: source.tokens?.prompt ?? 0,
110
+ completion: source.tokens?.completion ?? 0,
111
+ };
112
+ }
113
+ /** Register with the live hub so the browser can watch this conversation. */
114
+ start() {
115
+ this.checkpoint.register();
116
+ this.key = hub.register(this);
117
+ this.save();
118
+ this.touch();
119
+ this.publishLive();
120
+ return this.key;
121
+ }
122
+ /** Detach from the hub. The transcript stays on disk. */
123
+ dispose() {
124
+ if (this.disposed)
125
+ return;
126
+ this.disposed = true;
127
+ this.turnToken++;
128
+ this.busy = false;
129
+ if (this.pendingApproval) {
130
+ this.pendingApproval.resolve(false);
131
+ this.pendingApproval = null;
132
+ }
133
+ this.clearIdleTimer();
134
+ this.checkpoint.dispose();
135
+ hub.unregister(this.key);
136
+ this.key = null;
137
+ }
138
+ // ---- LiveAgent -------------------------------------------------------
139
+ liveCwd() {
140
+ return this.dir;
141
+ }
142
+ liveConfigure(selection) {
143
+ if (this.disposed)
144
+ return { ok: false, error: "this session has ended" };
145
+ if (this.busy || this.pendingApproval) {
146
+ return { ok: false, error: "pause the current turn before changing models" };
147
+ }
148
+ this.model = { ...selection.model };
149
+ this.provider = selection.provider;
150
+ this.intensity = selection.intensity;
151
+ this.save();
152
+ this.publishLive();
153
+ return { ok: true };
154
+ }
155
+ liveMeta() {
156
+ return {
157
+ sessionId: this.sessionId,
158
+ title: this.title(),
159
+ provider: this.provider,
160
+ model: this.model.name,
161
+ intensity: this.intensity,
162
+ approvalMode: this.approvalMode,
163
+ busy: this.busy,
164
+ paused: this.paused,
165
+ queued: this.queued,
166
+ compressing: this.compressing,
167
+ saveError: this.saveError,
168
+ tokens: { prompt: this.tokens.prompt, completion: this.tokens.completion },
169
+ approval: this.pendingApproval
170
+ ? liveApprovalDetails(this.pendingApproval.tool, this.pendingApproval.args, this.pendingApproval.risk, this.dir)
171
+ : null,
172
+ reviewing: this.reviewing,
173
+ };
174
+ }
175
+ liveSnapshot(key) {
176
+ const messages = [];
177
+ for (const message of this.messages) {
178
+ if (message.role === "tool") {
179
+ messages.push({
180
+ role: "tool",
181
+ name: message.name,
182
+ arguments: message.arguments,
183
+ result: message.result,
184
+ });
185
+ }
186
+ else if (message.role === "summary") {
187
+ messages.push({ role: "summary", content: message.content, count: message.count });
188
+ }
189
+ else if (message.role === "user" || message.role === "assistant") {
190
+ messages.push({ role: message.role, content: message.content });
191
+ }
192
+ }
193
+ return {
194
+ ...this.liveMeta(),
195
+ key,
196
+ cwd: this.dir,
197
+ messages,
198
+ streamingContent: this.streamingContent,
199
+ context: this.context,
200
+ };
201
+ }
202
+ livePause() {
203
+ if (this.paused || (!this.busy && !this.queued))
204
+ return;
205
+ this.paused = true;
206
+ this.turnToken++;
207
+ // A queued decision would otherwise keep the promise (and the gate) alive.
208
+ if (this.pendingApproval) {
209
+ const pending = this.pendingApproval;
210
+ this.pendingApproval = null;
211
+ pending.resolve(false);
212
+ }
213
+ this.busy = false;
214
+ this.retainStreamingContent();
215
+ this.save();
216
+ this.publishLive();
217
+ }
218
+ liveResume() {
219
+ if (!this.paused)
220
+ return;
221
+ this.paused = false;
222
+ this.publishLive();
223
+ this.maybeFlushQueue();
224
+ }
225
+ liveSend(text) {
226
+ this.send(text);
227
+ }
228
+ liveApprove(approved) {
229
+ const pending = this.pendingApproval;
230
+ if (!pending)
231
+ return false;
232
+ this.pendingApproval = null;
233
+ pending.resolve(approved);
234
+ this.publishLive();
235
+ return true;
236
+ }
237
+ liveSetApprovalMode(mode) {
238
+ if (this.approvalMode === mode)
239
+ return;
240
+ this.approvalMode = mode;
241
+ const pending = this.pendingApproval;
242
+ if (pending) {
243
+ this.pendingApproval = null;
244
+ pending.resolve(null);
245
+ }
246
+ this.touch();
247
+ this.save();
248
+ this.publishLive();
249
+ }
250
+ // ---- turn driving ----------------------------------------------------
251
+ send(text) {
252
+ const trimmed = text.trim();
253
+ if (!trimmed || this.disposed)
254
+ return;
255
+ this.touch();
256
+ this.messages.push({ role: "user", content: trimmed });
257
+ this.save();
258
+ if (this.busy || this.paused) {
259
+ this.queued = trimmed;
260
+ this.publishLive();
261
+ return;
262
+ }
263
+ this.publishLive();
264
+ this.startTurn();
265
+ }
266
+ maybeFlushQueue() {
267
+ if (!this.queued || this.busy || this.paused)
268
+ return;
269
+ this.queued = null;
270
+ this.publishLive();
271
+ this.startTurn();
272
+ }
273
+ startTurn() {
274
+ const turnMessages = this.messages.slice();
275
+ const approvalContext = captureApprovalContext(turnMessages);
276
+ this.busy = true;
277
+ const token = ++this.turnToken;
278
+ this.streamingContent = "";
279
+ this.publishLive();
280
+ void (async () => {
281
+ await this.autoCompress(token);
282
+ if (token !== this.turnToken || this.paused)
283
+ return;
284
+ await this.agentLoop(this.buildApiMessages(turnMessages), token, approvalContext);
285
+ })().catch((err) => {
286
+ if (token === this.turnToken)
287
+ this.failTurn(err);
288
+ });
289
+ }
290
+ /** Ends a turn whose floating promise rejected, instead of crashing the app. */
291
+ failTurn(err) {
292
+ this.retainStreamingContent();
293
+ this.messages.push({ role: "assistant", content: `[Error: ${describeError(err)}]` });
294
+ this.streamingContent = null;
295
+ try {
296
+ this.onTurnFinished();
297
+ }
298
+ catch {
299
+ this.busy = false;
300
+ this.paused = false;
301
+ }
302
+ }
303
+ buildApiMessages(messages = this.messages) {
304
+ const api = [{ role: "system", content: loadPrompt() }];
305
+ for (const m of modelHistory(messages, this.context)) {
306
+ if (m.role === "user" || m.role === "assistant") {
307
+ api.push({ role: m.role, content: m.content });
308
+ }
309
+ else if (m.role === "summary") {
310
+ api.push({ role: "assistant", content: m.content });
311
+ }
312
+ }
313
+ return api;
314
+ }
315
+ async agentLoop(apiMessages, token, approvalContext) {
316
+ const tools = getToolDefinitions();
317
+ try {
318
+ for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
319
+ if (token !== this.turnToken || this.paused)
320
+ return;
321
+ const client = new LLMClient(this.model, this.provider, this.intensity);
322
+ let text = "";
323
+ this.streamingContent = "";
324
+ this.publishLive();
325
+ for await (const chunk of client.streamTurn(apiMessages, tools)) {
326
+ if (token !== this.turnToken || this.paused)
327
+ return;
328
+ text += chunk;
329
+ this.streamingContent = text;
330
+ this.checkpoint.schedule();
331
+ this.publishLive();
332
+ }
333
+ if (token !== this.turnToken || this.paused)
334
+ return;
335
+ this.streamingContent = null;
336
+ if (client.usage) {
337
+ this.tokens.prompt += client.usage.prompt;
338
+ this.tokens.completion += client.usage.completion;
339
+ this.lastPromptTokens = client.usage.prompt;
340
+ }
341
+ if (text.trim()) {
342
+ this.messages.push({ role: "assistant", content: text });
343
+ apiMessages.push({ role: "assistant", content: text });
344
+ }
345
+ this.save();
346
+ this.publishLive();
347
+ const toolCalls = client.toolCalls;
348
+ if (toolCalls.length === 0)
349
+ break;
350
+ for (const call of toolCalls) {
351
+ if (token !== this.turnToken || this.paused)
352
+ return;
353
+ const args = parseArguments(call.arguments);
354
+ const toolMsg = {
355
+ role: "tool",
356
+ name: call.name,
357
+ arguments: args,
358
+ result: TOOL_PENDING,
359
+ };
360
+ this.messages.push(toolMsg);
361
+ this.save();
362
+ this.publishLive();
363
+ const decision = await this.requestApproval(call.name, args, {
364
+ ...approvalContext,
365
+ context: captureApprovalContext(apiMessages).context,
366
+ });
367
+ if (token !== this.turnToken || this.paused)
368
+ return;
369
+ if (!decision.approved) {
370
+ // A malformed request was never shown to anyone, so there is no
371
+ // verdict to respect and the right move is a corrected retry.
372
+ const denied = decision.source === "input"
373
+ ? `[Not executed: ${decision.reason}]`
374
+ : `[Not executed: approval denied (${decision.source}): ${decision.reason}] ` +
375
+ describeApprovalIntent(approvalContext) +
376
+ "Do not retry this call unchanged; ask the user how to proceed.";
377
+ toolMsg.result = denied;
378
+ apiMessages.push({ role: "tool", content: denied, tool_call_id: call.id });
379
+ this.save();
380
+ this.publishLive();
381
+ continue;
382
+ }
383
+ let result;
384
+ try {
385
+ result = await executeTool(call.name, args, this.dir);
386
+ }
387
+ catch (err) {
388
+ result = `Error: ${err instanceof Error ? err.message : String(err)}`;
389
+ }
390
+ if (this.disposed)
391
+ return;
392
+ toolMsg.result = result;
393
+ this.save();
394
+ if (token !== this.turnToken || this.paused)
395
+ return;
396
+ apiMessages.push({ role: "tool", content: result, tool_call_id: call.id });
397
+ this.publishLive();
398
+ }
399
+ this.save();
400
+ }
401
+ this.onTurnFinished();
402
+ }
403
+ catch (err) {
404
+ if (token !== this.turnToken)
405
+ return;
406
+ if (this.streamingContent) {
407
+ this.messages.push({ role: "assistant", content: this.streamingContent });
408
+ }
409
+ this.streamingContent = null;
410
+ this.messages.push({
411
+ role: "assistant",
412
+ content: `[Error: ${err instanceof Error ? err.message : String(err)}]`,
413
+ });
414
+ this.onTurnFinished();
415
+ }
416
+ }
417
+ onTurnFinished() {
418
+ this.busy = false;
419
+ this.paused = false;
420
+ this.streamingContent = null;
421
+ this.save();
422
+ this.publishLive();
423
+ if (this.queued && !this.paused)
424
+ this.maybeFlushQueue();
425
+ else
426
+ this.touch();
427
+ }
428
+ // ---- approval gate ---------------------------------------------------
429
+ /** Classify a tool call and, when it is risky, wait for a decision. */
430
+ async requestApproval(tool, args, context = captureApprovalContext(this.messages)) {
431
+ const risk = assessToolCall(tool, args, this.dir);
432
+ if (!risk)
433
+ return { approved: true, source: "prompt", reason: "routine call" };
434
+ const gate = new ApprovalGate({
435
+ askHuman: () => new Promise((resolve) => {
436
+ this.pendingApproval = { tool, args, risk, resolve };
437
+ this.publishLive();
438
+ }),
439
+ reviewer: () => this.reviewerModel(),
440
+ autoApprovalEnabled: () => this.approvalMode === "auto",
441
+ extremeAutoApprovalEnabled: () => loadConfig().auto_approve_extreme_risk === true,
442
+ onReview: (info) => {
443
+ this.reviewing = info
444
+ ? {
445
+ total: info.total, round: info.round ?? 1, rounds: info.rounds ?? 1,
446
+ request: liveApprovalDetails(tool, args, risk, this.dir),
447
+ }
448
+ : null;
449
+ this.publishLive();
450
+ },
451
+ });
452
+ return gate.decide({ tool, args, cwd: this.dir, ...context, risk });
453
+ }
454
+ /** Reviewer model: `approval_model` when set, otherwise the chat model. */
455
+ reviewerModel() {
456
+ const name = String(loadConfig().approval_model ?? "").trim();
457
+ const found = name ? findModel(name) : null;
458
+ if (found) {
459
+ const usable = resolveModel(found.model.name, found.provider);
460
+ if (usable.ok) {
461
+ return { model: usable.model, provider: found.provider, intensity: this.intensity };
462
+ }
463
+ }
464
+ return { model: this.model, provider: this.provider, intensity: this.intensity };
465
+ }
466
+ // ---- compression -----------------------------------------------------
467
+ compressThreshold() {
468
+ return contextCompressionThreshold(this.provider, this.model);
469
+ }
470
+ async autoCompress(token) {
471
+ const threshold = this.compressThreshold();
472
+ if (threshold <= 0 || this.lastPromptTokens < threshold)
473
+ return;
474
+ const visible = this.messages.filter((m) => m.role !== "system");
475
+ const flat = modelHistory(this.messages, this.context).map((m) => m.role === "tool"
476
+ ? { role: "tool", name: m.name, content: m.result }
477
+ : { role: m.role, content: m.content });
478
+ const plan = planCompaction(flat, COMPACT_KEEP);
479
+ if (!plan)
480
+ return;
481
+ const end = visible.length - plan.tail.length;
482
+ if (end <= (this.context?.end ?? 0))
483
+ return;
484
+ const snapshotLen = this.messages.length;
485
+ const transcript = transcriptFor(plan.head);
486
+ this.compressing = true;
487
+ this.publishLive();
488
+ let summary = "";
489
+ try {
490
+ summary = await new LLMClient(this.model, this.provider, this.intensity).summarize(transcript);
491
+ }
492
+ catch {
493
+ summary = "";
494
+ }
495
+ this.compressing = false;
496
+ this.publishLive();
497
+ if (token !== this.turnToken || this.paused)
498
+ return;
499
+ if (this.messages.length !== snapshotLen)
500
+ return;
501
+ const content = summary.trim()
502
+ ? `${SUMMARY_PREFIX}\n\n${summary.trim()}`
503
+ : "Earlier messages are preserved in history, but could not be summarized for this request.";
504
+ this.context = { end, summary: content };
505
+ this.lastPromptTokens = 0;
506
+ this.save();
507
+ this.publishLive();
508
+ }
509
+ // ---- persistence -----------------------------------------------------
510
+ title() {
511
+ for (const m of this.messages) {
512
+ if (m.role === "user") {
513
+ const line = m.content.trim().split(/\r?\n/)[0] ?? "";
514
+ return line.length > 80 ? `${line.slice(0, 77)}…` : line;
515
+ }
516
+ }
517
+ return "Resumed chat";
518
+ }
519
+ retainStreamingContent() {
520
+ if (this.streamingContent) {
521
+ this.messages.push({ role: "assistant", content: this.streamingContent });
522
+ }
523
+ this.streamingContent = null;
524
+ }
525
+ save() {
526
+ this.checkpoint.flush();
527
+ }
528
+ writeSession() {
529
+ const chat = [];
530
+ for (const m of this.messages) {
531
+ if (m.role === "user" || m.role === "assistant" || m.role === "tool")
532
+ chat.push(m);
533
+ else if (m.role === "summary")
534
+ chat.push({ role: "assistant", content: m.content });
535
+ }
536
+ if (this.streamingContent)
537
+ chat.push({ role: "assistant", content: this.streamingContent });
538
+ if (chat.length === 0)
539
+ return true;
540
+ try {
541
+ this.sessionId = saveSession(chat, this.model.name, this.provider, this.intensity, this.dir, this.sessionId, this.tokens, this.context, this.approvalMode);
542
+ this.saveError = null;
543
+ return true;
544
+ }
545
+ catch (err) {
546
+ const detail = describeError(err);
547
+ if (this.saveError === null)
548
+ writeCrashLog("save session", err);
549
+ this.saveError = detail;
550
+ return false;
551
+ }
552
+ }
553
+ publishLive() {
554
+ if (this.key)
555
+ hub.notify(this.key);
556
+ }
557
+ touch() {
558
+ this.clearIdleTimer();
559
+ this.idleTimer = setTimeout(() => {
560
+ if (!this.busy && !this.queued)
561
+ this.dispose();
562
+ }, IDLE_DISPOSE_MS);
563
+ if (typeof this.idleTimer.unref === "function")
564
+ this.idleTimer.unref();
565
+ }
566
+ clearIdleTimer() {
567
+ if (!this.idleTimer)
568
+ return;
569
+ clearTimeout(this.idleTimer);
570
+ this.idleTimer = null;
571
+ }
572
+ }
@@ -0,0 +1,101 @@
1
+ import { DEFAULT_CONFIG, INTENSITY_LEVELS, contextWindow, isContextWindow, loadConfig, loadModels, resolveModelSelection, saveConfig, saveModels, } from "../config.js";
2
+ import { hashWebPassword, validateWebPassword } from "./auth.js";
3
+ /** Public settings use a whitelist: API keys and cached credentials stay on disk. */
4
+ export function getWebSettings() {
5
+ const config = { ...DEFAULT_CONFIG, ...loadConfig() };
6
+ const providers = loadModels().providers.map((provider) => ({
7
+ name: provider.name,
8
+ models: provider.models.map((model) => ({ name: model.name, configured: Boolean(model.api_key?.trim()), context_window: contextWindow(model.context_window) })),
9
+ }));
10
+ const preferred = providers.find((p) => p.name === config.selected_provider);
11
+ const provider = preferred ?? providers.find((p) => p.models.some((m) => m.configured)) ?? providers[0];
12
+ const model = provider?.models.find((m) => m.name === config.selected_model) ??
13
+ provider?.models.find((m) => m.configured) ?? provider?.models[0];
14
+ const intensity = String(config.selected_intensity ?? config.default_intensity);
15
+ return {
16
+ providers,
17
+ intensities: INTENSITY_LEVELS,
18
+ selection: {
19
+ provider: provider?.name ?? "",
20
+ model: model?.name ?? "",
21
+ intensity: INTENSITY_LEVELS.includes(intensity) ? intensity : "High",
22
+ },
23
+ settings: {
24
+ default_intensity: String(config.default_intensity),
25
+ model_context_window: model?.context_window ?? contextWindow(undefined),
26
+ approval_model: String(config.approval_model ?? ""),
27
+ auto_approve_extreme_risk: config.auto_approve_extreme_risk === true,
28
+ sound_alerts: config.sound_alerts !== false,
29
+ },
30
+ };
31
+ }
32
+ /** Validate the complete patch before writing anything, preserving unrelated settings. */
33
+ export function updateWebSettings(value) {
34
+ if (!value || typeof value !== "object" || Array.isArray(value))
35
+ throw new Error("invalid settings body");
36
+ const body = value;
37
+ if (Object.keys(body).some((key) => key !== "selection" && key !== "settings"))
38
+ throw new Error("unknown settings field");
39
+ const config = loadConfig();
40
+ if (body.settings !== undefined) {
41
+ if (!body.settings || typeof body.settings !== "object" || Array.isArray(body.settings))
42
+ throw new Error("invalid settings");
43
+ const patch = body.settings;
44
+ for (const [key, val] of Object.entries(patch)) {
45
+ if (key === "default_intensity") {
46
+ if (typeof val !== "string" || !INTENSITY_LEVELS.includes(val))
47
+ throw new Error("invalid default intensity");
48
+ config.default_intensity = val;
49
+ config.selected_intensity = val;
50
+ }
51
+ else if (key === "model_context_window") {
52
+ if (!isContextWindow(val))
53
+ throw new Error("invalid model context window");
54
+ }
55
+ else if (key === "approval_model") {
56
+ if (typeof val !== "string" || (val && !loadModels().providers.some((p) => p.models.some((m) => m.name === val))))
57
+ throw new Error("unknown approval model");
58
+ config[key] = val;
59
+ }
60
+ else if (key === "sound_alerts") {
61
+ if (typeof val !== "boolean")
62
+ throw new Error("sound alerts must be a boolean");
63
+ config[key] = val;
64
+ }
65
+ else if (key === "auto_approve_extreme_risk") {
66
+ if (typeof val !== "boolean")
67
+ throw new Error("auto-approval must be a boolean");
68
+ config[key] = val;
69
+ }
70
+ else if (key === "web_password") {
71
+ validateWebPassword(val);
72
+ }
73
+ else {
74
+ throw new Error("unknown settings field");
75
+ }
76
+ }
77
+ }
78
+ if (body.selection !== undefined) {
79
+ const chosen = resolveModelSelection(body.selection, false);
80
+ config.selected_provider = chosen.provider;
81
+ config.selected_model = chosen.model.name;
82
+ config.selected_model_url = chosen.model.url;
83
+ config.selected_model_api_key = chosen.model.api_key;
84
+ config.selected_model_streaming = chosen.model.supports_streaming;
85
+ config.selected_intensity = chosen.intensity;
86
+ config.default_intensity = chosen.intensity;
87
+ }
88
+ const password = body.settings?.web_password;
89
+ if (password !== undefined)
90
+ config.web_password_hash = hashWebPassword(password);
91
+ const window = body.settings?.model_context_window;
92
+ if (window !== undefined) {
93
+ const selection = body.selection ?? getWebSettings().selection;
94
+ const chosen = resolveModelSelection(selection, false);
95
+ const models = loadModels();
96
+ const model = models.providers.find((p) => p.name === chosen.provider).models.find((m) => m.name === chosen.model.name);
97
+ model.context_window = window;
98
+ saveModels(models);
99
+ }
100
+ saveConfig(config);
101
+ }
package/dist/width.js CHANGED
@@ -424,3 +424,23 @@ export function ellipsize(text, width) {
424
424
  return "…";
425
425
  return truncateByWidth(text, width - 1) + "…";
426
426
  }
427
+ /** Trim the beginning of `text`, keeping the end (useful for long paths). */
428
+ export function ellipsizeStart(text, width) {
429
+ if (width <= 0)
430
+ return "";
431
+ if (displayWidth(text) <= width)
432
+ return text;
433
+ if (width === 1)
434
+ return "…";
435
+ const chars = Array.from(text);
436
+ let out = "";
437
+ let used = 0;
438
+ for (let i = chars.length - 1; i >= 0; i--) {
439
+ const cw = wcwidth(chars[i].codePointAt(0) ?? 0);
440
+ if (used + cw > width - 1)
441
+ break;
442
+ out = chars[i] + out;
443
+ used += cw;
444
+ }
445
+ return "…" + out;
446
+ }