@otto-code/brain 0.8.9 → 0.8.12

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.
Files changed (59) hide show
  1. package/dist/commands/calibrate.js +9 -0
  2. package/dist/commands/catalog.d.ts +1 -0
  3. package/dist/commands/catalog.js +1 -0
  4. package/dist/commands/pull.d.ts +1 -0
  5. package/dist/commands/pull.js +12 -3
  6. package/dist/commands/search.d.ts +1 -0
  7. package/dist/commands/search.js +12 -2
  8. package/dist/config/index.d.ts +1 -1
  9. package/dist/config/index.js +1 -1
  10. package/dist/config/profile-edit.d.ts +88 -1
  11. package/dist/config/profile-edit.js +280 -29
  12. package/dist/config/profiles.js +16 -0
  13. package/dist/config/schema.d.ts +608 -0
  14. package/dist/config/schema.js +58 -0
  15. package/dist/config/store.js +7 -4
  16. package/dist/gguf.d.ts +7 -0
  17. package/dist/gguf.js +15 -2
  18. package/dist/models/download.d.ts +1 -1
  19. package/dist/models/download.js +2 -2
  20. package/dist/models/enrich.d.ts +6 -0
  21. package/dist/models/enrich.js +27 -1
  22. package/dist/models/index.d.ts +1 -1
  23. package/dist/models/index.js +4 -3
  24. package/dist/ops/calibrate.d.ts +38 -3
  25. package/dist/ops/calibrate.js +68 -19
  26. package/dist/ops/report.js +51 -1
  27. package/dist/ops/results.d.ts +57 -11
  28. package/dist/ops/results.js +75 -10
  29. package/dist/ops/sweep.d.ts +38 -1
  30. package/dist/ops/sweep.js +61 -10
  31. package/dist/runtime/args.d.ts +15 -2
  32. package/dist/runtime/args.js +60 -5
  33. package/dist/runtime/managed.js +2 -2
  34. package/dist/service/activity.d.ts +19 -0
  35. package/dist/service/activity.js +47 -4
  36. package/dist/service/host-api.d.ts +25 -4
  37. package/dist/service/host-api.js +82 -16
  38. package/dist/service/log-format.d.ts +18 -0
  39. package/dist/service/log-format.js +32 -0
  40. package/dist/service/router.d.ts +70 -2
  41. package/dist/service/router.js +219 -21
  42. package/dist/service/run-log.d.ts +6 -1
  43. package/dist/service/run-log.js +46 -4
  44. package/dist/service/scheduler.d.ts +227 -24
  45. package/dist/service/scheduler.js +395 -63
  46. package/dist/service/serve.d.ts +4 -0
  47. package/dist/service/serve.js +302 -117
  48. package/dist/service/status-events.d.ts +14 -1
  49. package/dist/service/status-events.js +111 -12
  50. package/dist/service/supervisor.d.ts +9 -7
  51. package/dist/service/supervisor.js +37 -12
  52. package/dist/sysmon.d.ts +15 -0
  53. package/dist/sysmon.js +56 -9
  54. package/dist/tui/app.d.ts +8 -2
  55. package/dist/tui/app.js +65 -17
  56. package/dist/types.d.ts +18 -0
  57. package/dist/vram.d.ts +37 -0
  58. package/dist/vram.js +57 -18
  59. package/package.json +1 -1
@@ -180,6 +180,10 @@ export function describeModel(model, options = {}) {
180
180
  reasoningEfforts.every((value) => typeof value === "string")) {
181
181
  entry.reasoning_efforts = reasoningEfforts;
182
182
  }
183
+ const reasoningEffortDefault = md["reasoning_effort_default"] ?? model.reasoningEffortDefault;
184
+ if (typeof reasoningEffortDefault === "string") {
185
+ entry.reasoning_effort_default = reasoningEffortDefault;
186
+ }
183
187
  if (state === "loaded" && profile && profile.contextSize) {
184
188
  // llama-server splits -c across --parallel slots, so the window a single
185
189
  // request actually gets is the total divided by the concurrency.
@@ -277,13 +281,152 @@ function nextStreamId() {
277
281
  * bench command builds its own throwaway router and simply never reads it.
278
282
  */
279
283
  const reasoningTracker = new ReasoningTracker();
284
+ /**
285
+ * Map an OpenAI-compatible effort request onto a model's own chat-template
286
+ * arguments. llama.cpp does not know every model's dialect: Qwen3.8 calls the
287
+ * controls `enable_thinking` and `reasoning_effort`, for example. Only catalog
288
+ * entries that declare these names are rewritten, so generic models and GPT-OSS
289
+ * keep their existing server-native request handling.
290
+ */
291
+ export function applyModelReasoningTemplate(body, model) {
292
+ const template = model.reasoningTemplate;
293
+ const advertised = model.reasoningEfforts;
294
+ if (!template || !Array.isArray(advertised))
295
+ return body;
296
+ let parsed;
297
+ try {
298
+ parsed = JSON.parse(body.toString("utf8"));
299
+ }
300
+ catch {
301
+ return body;
302
+ }
303
+ if (!isRecord(parsed) || typeof parsed.reasoning_effort !== "string")
304
+ return body;
305
+ const requested = parsed.reasoning_effort.toLowerCase();
306
+ const knownEfforts = new Set(advertised.map((value) => value.toLowerCase()));
307
+ const isDisabled = requested === "off" || requested === "none";
308
+ const isEnabled = requested === "on" || knownEfforts.has(requested);
309
+ if (!isDisabled && !isEnabled)
310
+ return body;
311
+ const suppliedKwargs = parsed.chat_template_kwargs;
312
+ if (suppliedKwargs !== undefined && !isRecord(suppliedKwargs))
313
+ return body;
314
+ const templateKwargs = { ...suppliedKwargs };
315
+ templateKwargs[template.enableThinkingArgument] = !isDisabled;
316
+ if (knownEfforts.has(requested)) {
317
+ templateKwargs[template.effortArgument] = requested;
318
+ }
319
+ else {
320
+ // The generic On selection means the model's native default, not a stale
321
+ // explicit effort that happened to be supplied by a previous client.
322
+ delete templateKwargs[template.effortArgument];
323
+ }
324
+ const { reasoning_effort: _reasoningEffort, ...withoutReasoningEffort } = parsed;
325
+ return Buffer.from(JSON.stringify({ ...withoutReasoningEffort, chat_template_kwargs: templateKwargs }), "utf8");
326
+ }
327
+ /**
328
+ * Pin the request to one llama-server slot by adding the engine's own
329
+ * `id_slot` field to the request body (host API v3).
330
+ *
331
+ * Why pin instead of guess: the OpenAI-compatible stream chunks never carry the
332
+ * slot id, so without a pin the router can only correlate a request to a slot
333
+ * by elimination, and with several concurrent requests that guess is exactly
334
+ * the lie the Overview panel used to tell. llama-server honors the pin on every
335
+ * completion endpoint: if the named slot is free the task lands there, and if
336
+ * it is busy the engine DEFERS the task internally - it never reassigns the
337
+ * task elsewhere and never fails the request - so the slot this router names is
338
+ * always the slot the request ends up on (possibly after waiting on it).
339
+ *
340
+ * `null` returns the body untouched: no pin, and therefore no join data for
341
+ * this request, which is the same degraded state as an older brain. A body this
342
+ * cannot parse is forwarded exactly as-is - an unfamiliar request must reach
343
+ * llama-server and get llama-server's own answer, not a 400 invented here.
344
+ */
345
+ export function pinSlot(body, slotId) {
346
+ if (slotId === null)
347
+ return body;
348
+ let parsed;
349
+ try {
350
+ parsed = JSON.parse(body.toString("utf8"));
351
+ }
352
+ catch {
353
+ return body;
354
+ }
355
+ if (!isRecord(parsed))
356
+ return body;
357
+ parsed.id_slot = slotId;
358
+ return Buffer.from(JSON.stringify(parsed), "utf8");
359
+ }
360
+ /**
361
+ * Wipe one llama-server slot's retained KV state, and RESOLVE only once the
362
+ * engine has acknowledged the wipe.
363
+ *
364
+ * This is the engine-side half of the scheduler's OWNERSHIP fix. The engine
365
+ * never clears a released slot's prompt, so a slot handed to a different chat
366
+ * would keep the previous chat's KV and bleed its topics into the new chat's
367
+ * thinking. The router erases the slot the moment the scheduler hands it off;
368
+ * the engine's task queue runs in arrival order, so resolving on the
369
+ * acknowledgment is what guarantees the clean state sits in the queue ahead of
370
+ * the completion the scheduler posts right after.
371
+ *
372
+ * The route is `POST /slots?action=erase&id_slot=N` - llama.cpp's own slot
373
+ * action. It answers 200 `{id, id_slot, n_erased}` on success and a
374
+ * `NOT_SUPPORTED` error when the server was not launched with a slot-save path;
375
+ * either way this resolves (never rejects), because an erase that cannot be
376
+ * performed degrades to the old behavior rather than failing the completion.
377
+ *
378
+ * NOTE: `action` and `id_slot` MUST travel in the query string, not the JSON
379
+ * body. llama-server's `POST /slots` handler reads both via `req.get_param()`,
380
+ * which is built only from query + path params (b10441 tools/server/server-http.cpp,
381
+ * `server_http_req::params` = "path_params + query_params"; the body is a
382
+ * separate field the handler never parses for this route). A body-only request
383
+ * reaches `std::stoi("")` and answers 400 "Invalid slot ID" - the erase then
384
+ * silently no-ops and the bleed survives. The body must stay empty for the
385
+ * same reason `handle_slots_erase` ignores it entirely.
386
+ */
387
+ export function eraseSlot(host, port, slotId) {
388
+ return new Promise((resolve) => {
389
+ const req = http.request({
390
+ host,
391
+ port,
392
+ path: `/slots?action=erase&id_slot=${slotId}`,
393
+ method: "POST",
394
+ timeout: 3000,
395
+ }, (res) => {
396
+ res.resume();
397
+ res.on("end", () => resolve());
398
+ });
399
+ req.on("timeout", () => req.destroy());
400
+ req.on("error", () => resolve());
401
+ req.end();
402
+ });
403
+ }
404
+ /**
405
+ * The eraser the scheduler needs, bound to one engine endpoint. Extracted so
406
+ * the router (which builds its own scheduler) and the service (which builds a
407
+ * shared one and passes it in) hand the scheduler the SAME transport rather
408
+ * than each spelling the request.
409
+ */
410
+ export function createSlotEraser(host, port) {
411
+ return (slotId) => eraseSlot(host, port, slotId);
412
+ }
280
413
  /**
281
414
  * Forward a buffered completion body to the resident llama-server and stream the
282
415
  * reply back, teeing non-streaming bodies for classification. Resolves once the
283
416
  * client response is fully concluded (it owns the response in every outcome,
284
417
  * including upstream errors), so the scheduler can move to the next turn.
418
+ *
419
+ * The slot this request is pinned to arrives as `options.slot`, named by the
420
+ * scheduler at the moment of admission (see `Scheduler.onSlotFree`). That is
421
+ * the only race-free place to choose it: by then the model is resident (slot
422
+ * ids do not survive a model switch), the admission sample has just counted
423
+ * the slot free, and every sibling admitted in the same pass has been named a
424
+ * DISTINCT id. `null` means the engine could not be sampled or reported no
425
+ * per-slot rows - the request then runs unpinned with no join data, exactly
426
+ * the degraded state of an older brain.
285
427
  */
286
- function proxyBuffered({ agent, supervisor, telemetry, logger, req, res, body, reasoning = null, }) {
428
+ function proxyBuffered({ agent, model, supervisor, telemetry, logger, req, res, body, reasoning = null, slot, }) {
429
+ const slotId = slot ?? null;
287
430
  return new Promise((resolve) => {
288
431
  let settled = false;
289
432
  const streamId = nextStreamId();
@@ -300,7 +443,8 @@ function proxyBuffered({ agent, supervisor, telemetry, logger, req, res, body, r
300
443
  // Injected here, not at queue time: the scheduler may switch models between
301
444
  // buffering and dispatch, and the addendum belongs to whichever model ends
302
445
  // up resident, which is the one `supervisor.profile` now describes.
303
- const outbound = injectSystemAddendum(body, supervisor.profile?.chatSystemAddendum ?? null, completionShape(req.url));
446
+ const withSystemAddendum = injectSystemAddendum(body, supervisor.profile?.chatSystemAddendum ?? null, completionShape(req.url));
447
+ const outbound = pinSlot(applyModelReasoningTemplate(withSystemAddendum, model), slotId);
304
448
  const headers = {};
305
449
  for (const [name, value] of Object.entries(req.headers)) {
306
450
  if (!HOP_BY_HOP.has(name.toLowerCase()))
@@ -309,6 +453,7 @@ function proxyBuffered({ agent, supervisor, telemetry, logger, req, res, body, r
309
453
  headers.host = `${supervisor.host}:${supervisor.internalPort}`;
310
454
  headers["content-length"] = Buffer.byteLength(outbound);
311
455
  const started = Date.now();
456
+ logger?.info?.(`dispatching ${req.method ?? "POST"} ${req.url ?? "/v1/chat/completions"} to ${model.displayName}`);
312
457
  const upstream = http.request({
313
458
  host: supervisor.host,
314
459
  port: supervisor.internalPort,
@@ -362,6 +507,7 @@ function proxyBuffered({ agent, supervisor, telemetry, logger, req, res, body, r
362
507
  streamed: true,
363
508
  verdict: !sawContent && sawReasoning ? "reasoning-only" : "ok",
364
509
  });
510
+ logger?.info?.(`completed streamed ${req.method ?? "POST"} ${req.url ?? "/v1/chat/completions"} for ${model.displayName}`);
365
511
  done();
366
512
  });
367
513
  upstreamRes.pipe(res);
@@ -389,6 +535,7 @@ function proxyBuffered({ agent, supervisor, telemetry, logger, req, res, body, r
389
535
  ...(analysis ?? { verdict: "ok" }),
390
536
  };
391
537
  telemetry.record(entry);
538
+ logger?.info?.(`completed ${req.method ?? "POST"} ${req.url ?? "/v1/chat/completions"} for ${model.displayName} in ${entry.ms}ms (${entry.verdict})`);
392
539
  if (entry.verdict === "reasoning-only") {
393
540
  logger?.warn?.(`reasoning-only response: ${entry.outputTokens} tokens, ${entry.reasoningChars} reasoning chars, 0 content`);
394
541
  }
@@ -398,6 +545,12 @@ function proxyBuffered({ agent, supervisor, telemetry, logger, req, res, body, r
398
545
  res.on("close", () => {
399
546
  if (!res.writableFinished && !upstream.destroyed)
400
547
  upstream.destroy();
548
+ // The client is gone - interrupt, chat switch, socket death. Destroying
549
+ // the upstream request does not reliably fire "aborted"/"error" on the
550
+ // already-open response stream, so this is the authoritative release:
551
+ // without it a departed client's job never resolves and pins its model's
552
+ // slot forever, wedging the whole queue behind it (needs a reboot).
553
+ done();
401
554
  });
402
555
  res.on("error", () => {
403
556
  if (!upstream.destroyed)
@@ -410,13 +563,21 @@ function proxyBuffered({ agent, supervisor, telemetry, logger, req, res, body, r
410
563
  verdict: "failed",
411
564
  error: error.message,
412
565
  });
413
- sendError(res, 502, `Upstream llama-server error: ${error.message}`);
566
+ logger?.warn(`request to ${model.displayName} failed: ${error.message}`);
567
+ // The client may already be gone; writing a 502 into a dead socket
568
+ // throws and would kill this handler before done() releases the slot.
569
+ if (!res.writableEnded && !res.destroyed) {
570
+ sendError(res, 502, `Upstream llama-server error: ${error.message}`);
571
+ }
414
572
  done();
415
573
  });
416
574
  // This is the first authoritative inference-stage signal: the request is
417
575
  // being dispatched to llama-server and is waiting for prompt processing or
418
- // its first output delta.
576
+ // its first output delta. The slot association is set once here, at the
577
+ // same moment, so `observe` stays free of any per-chunk slot work.
419
578
  reasoning?.begin(streamId);
579
+ if (slotId !== null)
580
+ reasoning?.setSlot(streamId, slotId);
420
581
  upstream.end(outbound);
421
582
  });
422
583
  }
@@ -544,22 +705,41 @@ function scheduleCompletion({ req, res, agent, supervisor, telemetry, logger, sc
544
705
  }
545
706
  const body = Buffer.concat(chunks);
546
707
  let modelName = null;
708
+ let session = null;
547
709
  try {
548
710
  const parsed = JSON.parse(body.toString("utf8"));
549
- modelName = isRecord(parsed) && typeof parsed.model === "string" ? parsed.model : null;
711
+ if (isRecord(parsed)) {
712
+ modelName = typeof parsed.model === "string" ? parsed.model : null;
713
+ // The standard prompt_cache_key is the chat's stable identity: Otto's
714
+ // provider sends its session id there, and a third-party client that
715
+ // uses it for prompt caching sends one too. Brain reads it for
716
+ // session-affine scheduling; the OpenAI-compatible contract is
717
+ // untouched either way.
718
+ session =
719
+ typeof parsed.prompt_cache_key === "string" && parsed.prompt_cache_key.length > 0
720
+ ? parsed.prompt_cache_key
721
+ : null;
722
+ }
550
723
  }
551
724
  catch {
552
725
  /* leave null */
553
726
  }
554
727
  const gate = modelGate(modelName);
555
728
  if (!gate.ok) {
729
+ logger?.warn(`refused ${req.method ?? "POST"} ${req.url ?? "completion"}: ${gate.message}`);
556
730
  sendError(res, gate.status, gate.message);
557
731
  return;
558
732
  }
559
733
  const model = gate.model;
560
- scheduler
561
- .submit(model, () => proxyBuffered({
734
+ // The slot is named by the scheduler at admission time (one distinct id per
735
+ // job, drawn from the pass's own free-slot sample). It is not sampled here
736
+ // at queue time - that could name a slot of an engine about to be
737
+ // relaunched - and not at dispatch time either, where a sibling admitted in
738
+ // the same pass could see the same slot free and pin it too.
739
+ let slot = null;
740
+ const queued = scheduler.submit(model, () => proxyBuffered({
562
741
  agent,
742
+ model,
563
743
  supervisor,
564
744
  telemetry,
565
745
  logger,
@@ -567,8 +747,10 @@ function scheduleCompletion({ req, res, agent, supervisor, telemetry, logger, sc
567
747
  res,
568
748
  body,
569
749
  reasoning: reasoningTracker,
570
- }))
571
- .catch((error) => sendError(res, 502, `could not serve ${model.displayName}: ${errorMessage(error)}`));
750
+ slot,
751
+ }), { session, onSlotFree: (id) => (slot = id) });
752
+ logger?.info?.(`queued ${req.method ?? "POST"} ${req.url ?? "completion"} for ${model.displayName}; queue depth ${scheduler.stats().queued}`);
753
+ queued.catch((error) => sendError(res, 502, `could not serve ${model.displayName}: ${errorMessage(error)}`));
572
754
  });
573
755
  }
574
756
  // The bench ranking is read from disk (one JSON per run). A completion request
@@ -576,29 +758,41 @@ function scheduleCompletion({ req, res, agent, supervisor, telemetry, logger, sc
576
758
  // (rare), so the router caches the ranking and re-reads it at most once per
577
759
  // window - the cheap time-based trigger.
578
760
  const RANKING_TTL_MS = 60000;
579
- export function createRouter({ supervisor, telemetry, logger, getCatalog = null, loadModel = null, loadRanking = () => rankModels(), queryGpuInfo = queryGpu, version = null, getConfig = null, getEvals = null, getLockModel = () => false, getDefaultModel = () => null, applyConfigPatch = null, getAllowConfigWrite = () => false, hostApi = null, getResources = null, statusEvents = null, }) {
761
+ export function createRouter({ supervisor, telemetry, logger, getCatalog = null, loadModel = null, loadRanking = () => rankModels(), queryGpuInfo = queryGpu, version = null, getConfig = null, getEvals = null, getLockModel = () => false, getDefaultModel = () => null, applyConfigPatch = null, getAllowConfigWrite = () => false, hostApi = null, getResources = null, statusEvents = null, scheduler: suppliedScheduler = null, }) {
580
762
  // llama-server may close an idle response socket while this scheduler holds
581
763
  // the next request in queue. A reused keep-alive socket then fails as
582
764
  // ECONNRESET ("socket hang up") before the queued request reaches inference.
583
765
  // Inference time dwarfs localhost connection setup, so isolate each request
584
766
  // instead of letting a second client inherit a stale upstream connection.
585
767
  const agent = new http.Agent({ keepAlive: false, maxSockets: 32 });
586
- const scheduler = loadModel
587
- ? new Scheduler({
588
- supervisor,
589
- loadModel,
590
- logger: (m) => logger?.warn?.(m),
591
- onChange: statusEvents ? () => statusEvents.notify() : null,
592
- })
593
- : null;
768
+ // Erase an engine slot's KV when it is handed to a different chat. The
769
+ // endpoint is the engine's own, on the private port - never on the public
770
+ // one - so a remote client cannot wipe a chat's cache out from under it.
771
+ const eraseSlot = createSlotEraser(supervisor.host, supervisor.internalPort);
772
+ const scheduler = suppliedScheduler ??
773
+ (loadModel
774
+ ? new Scheduler({
775
+ supervisor,
776
+ loadModel,
777
+ logger: (m) => logger?.warn?.(m),
778
+ onChange: statusEvents ? () => statusEvents.notify() : null,
779
+ eraseSlot,
780
+ })
781
+ : null);
594
782
  // A (re)start means whatever produced the current warning no longer applies -
595
783
  // either a different model is now resident, or the same one just picked up an
596
784
  // edited profile (e.g. a lowered reasoning budget). Either way the recent
597
785
  // window is stale, so start it clean rather than let old records blame a
598
- // config that is no longer running.
786
+ // config that is no longer running. The scheduler's slot owners go the same
787
+ // way: the engine's slots do not survive the relaunch, so a stale owner entry
788
+ // would make the next admission erase a FRESH slot or mistake a keyless job
789
+ // for one it owns. The model-switch case is cleared by the scheduler itself;
790
+ // this catches the relaunch that keeps the same model resident.
599
791
  supervisor.on("state", ({ state }) => {
600
- if (state === "starting")
792
+ if (state === "starting") {
601
793
  telemetry.reset();
794
+ scheduler?.forgetSlots();
795
+ }
602
796
  });
603
797
  // GPU total VRAM is static hardware, so it is queried once at startup and
604
798
  // cached. Absent (no nvidia-smi) or not-yet-resolved leaves the fit predicate
@@ -763,8 +957,12 @@ export function createRouter({ supervisor, telemetry, logger, getCatalog = null,
763
957
  sendError(res, 400, result.error);
764
958
  return;
765
959
  }
960
+ logger?.info?.("received host configuration update");
766
961
  applyConfigPatch(result.body)
767
- .then((cfg) => sendJson(res, cfg))
962
+ .then((cfg) => {
963
+ logger?.info?.("applied host configuration update");
964
+ return sendJson(res, cfg);
965
+ })
768
966
  .catch((err) => sendError(res, 500, `could not apply config: ${errorMessage(err)}`));
769
967
  });
770
968
  return;
@@ -1,6 +1,11 @@
1
1
  export interface BrainRunLog {
2
2
  path: string;
3
- write(line: string): void;
3
+ write(line: string): string[];
4
+ /** The current Brain-service session's durable tail, including every source. */
5
+ tail(limit: number): {
6
+ lines: string[];
7
+ total: number;
8
+ };
4
9
  }
5
10
  /** Start a fresh Brain log and prune only expired Brain run logs. */
6
11
  export declare function createBrainRunLog(env?: NodeJS.ProcessEnv): BrainRunLog;
@@ -1,7 +1,8 @@
1
1
  /** Durable, per-service-run diagnostics for Otto Brain. */
2
- import { appendFileSync, mkdirSync, readdirSync, rmSync, statSync } from "node:fs";
2
+ import { appendFileSync, mkdirSync, openSync, readSync, closeSync, readdirSync, rmSync, statSync, } from "node:fs";
3
3
  import path from "node:path";
4
4
  import { resolveBrainPaths } from "../config/paths.js";
5
+ import { timestampBrainLogLine } from "./log-format.js";
5
6
  const RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
6
7
  const RUN_LOG_SUFFIX = "-brain.log";
7
8
  /** Start a fresh Brain log and prune only expired Brain run logs. */
@@ -18,14 +19,55 @@ export function createBrainRunLog(env = process.env) {
18
19
  pruneBrainRunLogs(logsDir, startedAt.getTime());
19
20
  }
20
21
  catch { }
22
+ let lineCount = 0;
21
23
  const write = (line) => {
24
+ const lines = line.split(/\r?\n|\r/u).filter((entry) => entry.length > 0);
25
+ if (lines.length === 0)
26
+ return [];
22
27
  try {
23
- appendFileSync(filePath, `${new Date().toISOString()} ${line}\n`, "utf8");
28
+ const timestamp = new Date().toISOString().slice(11, 23);
29
+ const entries = lines.map((entry) => timestampBrainLogLine(timestamp, entry));
30
+ appendFileSync(filePath, `${entries.join("\n")}\n`, "utf8");
31
+ lineCount += lines.length;
32
+ return entries;
33
+ }
34
+ catch {
35
+ return [];
24
36
  }
25
- catch { }
26
37
  };
27
38
  write(`Brain service started (pid ${process.pid})`);
28
- return { path: filePath, write };
39
+ return {
40
+ path: filePath,
41
+ write,
42
+ tail(limit) {
43
+ const count = Math.max(1, Math.floor(limit));
44
+ // The Brain log is append-only for the service lifetime. Read only enough
45
+ // of its end for the live viewer, rather than loading an unbounded bench
46
+ // or download log every two-second poll.
47
+ try {
48
+ const size = statSync(filePath).size;
49
+ const bytes = Math.min(size, Math.max(64 * 1024, count * 1024));
50
+ const fd = openSync(filePath, "r");
51
+ try {
52
+ const data = Buffer.alloc(bytes);
53
+ readSync(fd, data, 0, bytes, size - bytes);
54
+ const text = data.toString("utf8");
55
+ const firstNewline = text.indexOf("\n");
56
+ const completeLines = bytes < size && firstNewline !== -1 ? text.slice(firstNewline + 1) : text;
57
+ return {
58
+ lines: completeLines.split(/\r?\n/u).filter(Boolean).slice(-count),
59
+ total: lineCount,
60
+ };
61
+ }
62
+ finally {
63
+ closeSync(fd);
64
+ }
65
+ }
66
+ catch {
67
+ return { lines: [], total: lineCount };
68
+ }
69
+ },
70
+ };
29
71
  }
30
72
  export function pruneBrainRunLogs(logsDir, now = Date.now()) {
31
73
  for (const entry of readdirSync(logsDir, { withFileTypes: true })) {