@xingjisoft/neutrino 0.1.4 → 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.
package/dist/agents.js CHANGED
@@ -2,6 +2,7 @@ import fs from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { createRequire } from "node:module";
5
+ import { claudeProjectKey, sameCwd } from "./cwd.js";
5
6
  const HOME = os.homedir();
6
7
  function parseJsonlLines(filePath) {
7
8
  let raw;
@@ -27,6 +28,37 @@ function parseJsonlLines(filePath) {
27
28
  }
28
29
  return out;
29
30
  }
31
+ const HEAD_BYTES = 64 * 1024;
32
+ /**
33
+ * Read at most `maxBytes` from the start of a file.
34
+ *
35
+ * Session transcripts can be several megabytes, but the working directory is
36
+ * always recorded up front, so scanning just the head keeps the history list
37
+ * fast when a project holds many sessions.
38
+ */
39
+ function readHead(filePath, maxBytes = HEAD_BYTES) {
40
+ let fd = null;
41
+ try {
42
+ fd = fs.openSync(filePath, "r");
43
+ const len = Math.min(fs.fstatSync(fd).size, maxBytes);
44
+ if (len <= 0)
45
+ return "";
46
+ const buf = Buffer.alloc(len);
47
+ fs.readSync(fd, buf, 0, len, 0);
48
+ return buf.toString("utf-8");
49
+ }
50
+ catch {
51
+ return "";
52
+ }
53
+ finally {
54
+ if (fd !== null) {
55
+ try {
56
+ fs.closeSync(fd);
57
+ }
58
+ catch { }
59
+ }
60
+ }
61
+ }
30
62
  function contentText(content) {
31
63
  if (typeof content === "string")
32
64
  return content;
@@ -78,9 +110,27 @@ function claudeTitle(lines) {
78
110
  }
79
111
  return "";
80
112
  }
81
- function listClaude() {
113
+ const CWD_FIELD_RE = /"cwd"\s*:\s*("(?:[^"\\]|\\.)*")/;
114
+ /** Working directory recorded in a Claude Code transcript ("" when unknown). */
115
+ function claudeSessionCwd(filePath) {
116
+ const m = CWD_FIELD_RE.exec(readHead(filePath));
117
+ if (!m)
118
+ return "";
119
+ try {
120
+ const v = JSON.parse(m[1]);
121
+ return typeof v === "string" ? v : "";
122
+ }
123
+ catch {
124
+ return "";
125
+ }
126
+ }
127
+ function listClaude(cwd) {
82
128
  const metas = [];
129
+ const wantKey = claudeProjectKey(cwd);
83
130
  for (const dir of claudeProjectDirs()) {
131
+ // Claude names each project dir after its cwd, so the dir name still tells
132
+ // us the directory when a transcript predates the `cwd` field.
133
+ const dirKey = path.basename(dir).toLowerCase();
84
134
  let files;
85
135
  try {
86
136
  files = fs.readdirSync(dir);
@@ -99,6 +149,10 @@ function listClaude() {
99
149
  catch {
100
150
  continue;
101
151
  }
152
+ // History is scoped to the current project: skip other directories.
153
+ const sessionCwd = claudeSessionCwd(fp);
154
+ if (sessionCwd ? !sameCwd(sessionCwd, cwd) : dirKey !== wantKey)
155
+ continue;
102
156
  const id = f.slice(0, -".jsonl".length);
103
157
  const title = cleanTitle(claudeTitle(parseJsonlLines(fp)) || id.slice(0, 8));
104
158
  metas.push({
@@ -216,11 +270,41 @@ function codexMessageText(payload) {
216
270
  .join("")
217
271
  .trim();
218
272
  }
219
- function listCodex() {
273
+ /** Working directory recorded in a Codex rollout file ("" when unknown). */
274
+ function codexSessionCwd(filePath) {
275
+ let fallback = "";
276
+ for (const line of readHead(filePath).split("\n")) {
277
+ const t = line.trim();
278
+ if (!t)
279
+ continue;
280
+ let o;
281
+ try {
282
+ o = JSON.parse(t);
283
+ }
284
+ catch {
285
+ continue;
286
+ }
287
+ const payload = o.payload;
288
+ const c = typeof payload?.cwd === "string" ? payload.cwd : typeof o.cwd === "string" ? o.cwd : "";
289
+ if (!c)
290
+ continue;
291
+ // session_meta is authoritative; other records (turn_context) only serve
292
+ // as a fallback for older rollouts.
293
+ if (o.type === "session_meta")
294
+ return c;
295
+ if (!fallback)
296
+ fallback = c;
297
+ }
298
+ return fallback;
299
+ }
300
+ function listCodex(cwd) {
220
301
  const index = codexIndex();
221
302
  const files = codexRolloutFiles();
222
303
  const metas = [];
223
304
  for (const [id, fp] of files) {
305
+ // History is scoped to the current project: skip other directories.
306
+ if (!sameCwd(codexSessionCwd(fp), cwd))
307
+ continue;
224
308
  const info = index.get(id);
225
309
  let title = info?.title ?? "";
226
310
  let updated = info?.updated_at ?? "";
@@ -320,15 +404,28 @@ function opencodeModelName(modelJson) {
320
404
  return "";
321
405
  }
322
406
  }
323
- function listOpencode() {
407
+ function listOpencode(cwd) {
324
408
  const db = opencodeDbHandle();
325
409
  if (!db)
326
410
  return [];
327
- try {
328
- const rows = db
329
- .prepare("SELECT id, title, agent, model, time_created, time_updated FROM session ORDER BY time_updated DESC")
330
- .all();
331
- return rows.map((r) => ({
411
+ // Current schemas store the project directory on the session row; older ones
412
+ // keep it on the related project row. Try both, newest schema first.
413
+ const queries = [
414
+ "SELECT id, title, agent, model, time_created, time_updated, directory AS dir FROM session ORDER BY time_updated DESC",
415
+ "SELECT s.id AS id, s.title AS title, s.agent AS agent, s.model AS model, s.time_created AS time_created, s.time_updated AS time_updated, p.worktree AS dir FROM session s LEFT JOIN project p ON p.id = s.project_id ORDER BY s.time_updated DESC",
416
+ ];
417
+ for (const sql of queries) {
418
+ let rows;
419
+ try {
420
+ rows = db.prepare(sql).all();
421
+ }
422
+ catch {
423
+ continue; // this schema has no project path column, try the next query
424
+ }
425
+ // History is scoped to the current project: skip other directories.
426
+ return rows
427
+ .filter((r) => sameCwd(typeof r.dir === "string" ? r.dir : "", cwd))
428
+ .map((r) => ({
332
429
  id: String(r.id ?? ""),
333
430
  title: cleanTitle(typeof r.title === "string" ? r.title : "").slice(0, 80),
334
431
  created_at: iso(r.time_created),
@@ -336,9 +433,7 @@ function listOpencode() {
336
433
  model: opencodeModelName(r.model) || (typeof r.agent === "string" ? r.agent : ""),
337
434
  }));
338
435
  }
339
- catch {
340
- return [];
341
- }
436
+ return [];
342
437
  }
343
438
  function loadOpencode(id) {
344
439
  const db = opencodeDbHandle();
@@ -380,12 +475,16 @@ function loadOpencode(id) {
380
475
  }
381
476
  }
382
477
  // ---------------- Public API ----------------
383
- export function listAgentSessions(agent) {
478
+ /**
479
+ * Sessions of `agent` that were started in `cwd` (the directory neutrino runs
480
+ * in), newest first. Sessions from other directories are not shown.
481
+ */
482
+ export function listAgentSessions(agent, cwd = process.cwd()) {
384
483
  if (agent === "claude")
385
- return listClaude();
484
+ return listClaude(cwd);
386
485
  if (agent === "codex")
387
- return listCodex();
388
- return listOpencode();
486
+ return listCodex(cwd);
487
+ return listOpencode(cwd);
389
488
  }
390
489
  export function loadAgentMessages(agent, id) {
391
490
  if (agent === "claude")
package/dist/app.js CHANGED
@@ -2,6 +2,7 @@ import process from "node:process";
2
2
  import { Buffer } from "./terminal/buffer.js";
3
3
  import { InputReader } from "./terminal/input.js";
4
4
  import { Renderer } from "./terminal/renderer.js";
5
+ import { installCrashGuard, writeCrashLog } from "./terminal/restore.js";
5
6
  import { defaultState } from "./state.js";
6
7
  import { loadConfig, saveConfig } from "./config.js";
7
8
  import { C } from "./colors.js";
@@ -14,6 +15,7 @@ import { AgentSelectScreen } from "./screens/agentSelect.js";
14
15
  import { ModelSelectScreen } from "./screens/modelSelect.js";
15
16
  import { SettingsScreen } from "./screens/settings.js";
16
17
  import { StartScreen } from "./screens/start.js";
18
+ import { startWebServer } from "./web/server.js";
17
19
  const CURSOR_BLINK_MS = 530;
18
20
  const TOAST_MS = 1600;
19
21
  // How often the whole frame is re-emitted as a safety net, so terminal-side
@@ -34,6 +36,9 @@ export class App {
34
36
  toastTimer = null;
35
37
  repaintTimer = null;
36
38
  toast = "";
39
+ // Consecutive failed renders. A screen that cannot draw itself is fatal;
40
+ // a single bad frame is not.
41
+ renderFailures = 0;
37
42
  // Most recently rendered frame. Kept so a selection can be turned back into
38
43
  // text without having to re-run the screen's render.
39
44
  lastBuffer = null;
@@ -47,6 +52,7 @@ export class App {
47
52
  // landed, so the thumb stays under the pointer as it moves.
48
53
  barDrag = null;
49
54
  resizeHandler = () => this.onResize();
55
+ webHandle = null;
50
56
  constructor() {
51
57
  this.width = process.stdout.columns || 80;
52
58
  this.height = process.stdout.rows || 24;
@@ -76,7 +82,21 @@ export class App {
76
82
  this.state.selectedModelApiKey = String(config.selected_model_api_key ?? this.state.selectedModelApiKey);
77
83
  this.state.selectedModelStreaming = Boolean(config.selected_model_streaming ?? true);
78
84
  }
85
+ /**
86
+ * Mount the browser UI on the given port. Safe to call once; subsequent calls
87
+ * return the already-running server.
88
+ */
89
+ async enableWeb(port, host, auth = true) {
90
+ if (this.webHandle)
91
+ return this.webHandle;
92
+ this.webHandle = await startWebServer({ port, host, auth });
93
+ return this.webHandle;
94
+ }
79
95
  async start() {
96
+ // Installed before anything touches the terminal: from here on, a crash
97
+ // restores mouse tracking, raw mode and the alternate screen instead of
98
+ // handing the shell a terminal that types mouse reports.
99
+ installCrashGuard();
80
100
  this.renderer.init();
81
101
  this.input.start((ev) => this.handleEvent(ev));
82
102
  process.stdout.on("resize", this.resizeHandler);
@@ -112,6 +132,29 @@ export class App {
112
132
  this.render();
113
133
  }
114
134
  render() {
135
+ try {
136
+ this.renderFrame();
137
+ this.renderFailures = 0;
138
+ }
139
+ catch (err) {
140
+ // Rendering runs from timers and stream callbacks too, so an escaping
141
+ // throw here is equally fatal.
142
+ this.renderFailures++;
143
+ if (this.renderFailures === 1)
144
+ this.noteInternalError("render", err);
145
+ // A screen that cannot be drawn at all means there is no UI left; stop
146
+ // instead of spinning on a broken frame every 60 ms.
147
+ if (this.renderFailures > 30)
148
+ this.quit(1);
149
+ }
150
+ }
151
+ noteInternalError(kind, err) {
152
+ const file = writeCrashLog(kind, err);
153
+ const detail = err instanceof Error ? err.message : String(err);
154
+ const where = file ? ` - details in ${file}` : "";
155
+ this.showToast(`internal error (${kind}): ${detail}${where}`);
156
+ }
157
+ renderFrame() {
115
158
  // Start/stop the "thinking" blink animation. While the agent is thinking,
116
159
  // a timer toggles the blink phase and re-renders so the top status bar
117
160
  // flashes to draw the user's attention.
@@ -167,6 +210,16 @@ export class App {
167
210
  this.state.cursorOn = true;
168
211
  }
169
212
  handleEvent(ev) {
213
+ try {
214
+ this.dispatchEvent(ev);
215
+ }
216
+ catch (err) {
217
+ // This runs inside a stdin 'data' callback, where a throw is an uncaught
218
+ // exception that would kill the app mid-keystroke.
219
+ this.noteInternalError("input handler", err);
220
+ }
221
+ }
222
+ dispatchEvent(ev) {
170
223
  if (ev.type === "paste") {
171
224
  // Pasted text arrives with its newlines already flattened to spaces, so
172
225
  // it is inserted into the input instead of being submitted.
@@ -316,6 +369,24 @@ export class App {
316
369
  copyToClipboard(text);
317
370
  this.showToast(`Copied ${text.length} char${text.length === 1 ? "" : "s"}`);
318
371
  }
372
+ // Public so screens can surface transient status (e.g. "reviewing 3/3").
373
+ /**
374
+ * Audible cue for something the user is waiting on.
375
+ *
376
+ * Silent when `sound_alerts` is off, so the bell stays a preference rather
377
+ * than unavoidable noise. A pending approval rings twice: it is the one event
378
+ * that actually blocks the agent, and a single blip is easy to miss.
379
+ */
380
+ bell(kind = "turn") {
381
+ try {
382
+ if (loadConfig().sound_alerts === false)
383
+ return;
384
+ }
385
+ catch {
386
+ // A config we cannot read must not swallow the cue.
387
+ }
388
+ this.renderer.bell(kind === "approval" ? 2 : 1);
389
+ }
319
390
  showToast(text) {
320
391
  this.toast = text;
321
392
  if (this.toastTimer)
@@ -344,16 +415,20 @@ export class App {
344
415
  saveConfig(config);
345
416
  }
346
417
  startNewChat(text) {
418
+ this.restoreSelection();
347
419
  this.pushScreen(new ChatScreen(text));
348
420
  }
349
421
  loadSession(data) {
350
422
  const screen = new ChatScreen("");
351
423
  screen.sessionId = data.id;
352
424
  screen.loadedMessages = data.messages ?? [];
425
+ screen.loadedCwd = data.cwd ?? null;
353
426
  screen.loadedProvider = data.provider || null;
354
427
  screen.loadedModel = data.model || null;
355
428
  screen.loadedIntensity = data.intensity || null;
356
429
  screen.loadedTokens = data.tokens ?? null;
430
+ screen.loadedContext = data.context ?? null;
431
+ screen.loadedApprovalMode = data.approval_mode ?? null;
357
432
  if (data.provider)
358
433
  this.state.selectedProvider = data.provider;
359
434
  if (data.model)
@@ -382,7 +457,7 @@ export class App {
382
457
  return;
383
458
  this.pushScreen(new SettingsScreen());
384
459
  }
385
- quit() {
460
+ quit(exitCode = 0) {
386
461
  if (this.running)
387
462
  return;
388
463
  this.running = true;
@@ -404,8 +479,15 @@ export class App {
404
479
  clearTimeout(this.toastTimer);
405
480
  this.toastTimer = null;
406
481
  }
407
- this.input.stop();
482
+ // Disable mouse reporting before input returns to cooked/echo mode.
408
483
  this.renderer.dispose();
409
- process.exit(0);
484
+ try {
485
+ this.input.stop();
486
+ }
487
+ catch {
488
+ // Terminal restoration has already run, even if stdin is unusable.
489
+ }
490
+ void this.webHandle?.close();
491
+ process.exit(exitCode);
410
492
  }
411
493
  }
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
- if (!process.stdout.isTTY || !process.stdin.isTTY) {
5
- process.stderr.write("neutrino requires an interactive terminal.\n");
6
- process.exit(1);
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. */