@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
@@ -78,7 +78,10 @@ export interface RecordModel {
78
78
  * a bad score - `gpuLayers` short of the model's layer count silently runs part
79
79
  * of it on the CPU, `parallelSlots` splits the context between slots so the
80
80
  * effective window is a fraction of `contextSize`, and `extraArgs` can override
81
- * anything above.
81
+ * anything above. Schema 3 added the settings that landed after the setup-capture
82
+ * change - `preserveReasoning`, `contextMultiplier`, `cachedChats`, the sampler
83
+ * values, and the hosting-profile identity - so the record keeps tracking the
84
+ * profile instead of silently stopping at the field set it was born with.
82
85
  */
83
86
  export interface RecordProfile {
84
87
  contextSize: number;
@@ -93,6 +96,28 @@ export interface RecordProfile {
93
96
  batchSize: number | null;
94
97
  ubatchSize: number | null;
95
98
  extraArgs: string[];
99
+ /**
100
+ * Schema 3+. Absent on schema 2 records, which predate the setting.
101
+ * Tri-state: true/false as chosen, null for "the template's own default".
102
+ */
103
+ preserveReasoning: boolean | null;
104
+ /** Schema 3+. RoPE extension factor; 1 is the GGUF-native window. */
105
+ contextMultiplier: number;
106
+ /** Schema 3+. Chats parked in system RAM; 0 leaves llama.cpp's default. */
107
+ cachedChats: number;
108
+ /** Schema 3+. The sampler the run was served with, server-level for every task. */
109
+ temperature: number | null;
110
+ topP: number | null;
111
+ topK: number | null;
112
+ minP: number | null;
113
+ presencePenalty: number | null;
114
+ repeatPenalty: number | null;
115
+ /**
116
+ * Schema 3+. The Brain-owned hosting profile in effect (id, not text): its
117
+ * chat template and system-prompt addendum change what the model is asked
118
+ * to do, so a run under one profile is not comparable to one under another.
119
+ */
120
+ hostingProfileId: string | null;
96
121
  }
97
122
  /**
98
123
  * How the run was actually set up, as opposed to how it was configured.
@@ -173,10 +198,12 @@ export interface RecordTask {
173
198
  /**
174
199
  * One stored benchmark run - the shared shape used across results and report.
175
200
  *
176
- * `schema` is 2 as of the setup-capture change: 2 carries the full profile plus
177
- * `setup` and `suite`; 1 carried six profile fields and neither. Readers must
178
- * treat everything added in 2 as absent on an older record rather than assuming
179
- * it, because those runs are still perfectly good scores - they just cannot say
201
+ * `schema` is 3 as of the profile-completeness change: 3 carries every profile
202
+ * field that reaches llama-server, including the sampler values and the hosting
203
+ * profile; 2 carried the profile as of the setup-capture change plus `setup`
204
+ * and `suite`; 1 carried six profile fields and neither. Readers must treat
205
+ * everything added after a record's schema as absent rather than assuming it,
206
+ * because those runs are still perfectly good scores - they just cannot say
180
207
  * what they were measured with.
181
208
  */
182
209
  export interface RunRecord {
@@ -283,14 +310,33 @@ export interface TaskColumn {
283
310
  category: string;
284
311
  }
285
312
  declare function slugify(text: string): string;
313
+ /**
314
+ * llama.cpp's own sampler defaults, the values an untouched profile stores
315
+ * verbatim (see `ProfileSchema`). A key token is only emitted when the profile
316
+ * deviates from these, which is what makes an untouched profile keep its
317
+ * historical key after the widening.
318
+ */
319
+ declare const ENGINE_SAMPLER_DEFAULTS: {
320
+ readonly temperature: 0.8;
321
+ readonly topP: 0.95;
322
+ readonly topK: 40;
323
+ readonly minP: 0.05;
324
+ readonly presencePenalty: 0;
325
+ readonly repeatPenalty: 1;
326
+ };
286
327
  /**
287
328
  * Stable identity for "same model, same settings", so reruns can be grouped.
288
329
  *
289
- * Deliberately unchanged when the record grew: this string is the grouping key
290
- * every stored run was written with, and widening it would not re-key history -
291
- * it would split each model's past runs from its future ones and quietly reset
292
- * every variance figure on the page. New settings are recorded as data, not
293
- * folded in here.
330
+ * The base four tokens predate the record and are never rewritten - a stored
331
+ * run's key is its key, and re-deriving the same string for the same setup is
332
+ * what lets old runs stay in their old groups. The tokens added in schema 3 are
333
+ * appended ONLY when they deviate from the engine default, because that is what
334
+ * the key means: the effective configuration, and a default is the historical
335
+ * configuration. An untouched profile therefore keeps exactly the key it had
336
+ * before the setting existed, so variance figures for unchanged setups survive
337
+ * the widening; a run that differs only in, say, `contextMultiplier` gets a
338
+ * new key instead of silently merging into its predecessor's group and
339
+ * contaminating that group's consistency line.
294
340
  */
295
341
  declare function configKey(profile: Profile | null): string;
296
342
  declare function save({ model, profile, report, gpu, runtime, archiveId, system, args, fit, calibration, suite, timestamp, }: SaveOptions): SaveResult;
@@ -322,5 +368,5 @@ declare function variance(records?: RunRecord[]): VarianceRow[];
322
368
  declare function rankModels(records?: RunRecord[]): RankedModel[];
323
369
  /** All task ids seen across a set of records, in a stable order. */
324
370
  declare function taskColumns(records: RunRecord[]): TaskColumn[];
325
- export { RESULTS_DIR, save, loadAll, latestPerConfig, grouped, variance, stats, rankModels, taskColumns, configKey, slugify, };
371
+ export { RESULTS_DIR, save, loadAll, latestPerConfig, grouped, variance, stats, rankModels, taskColumns, configKey, slugify, ENGINE_SAMPLER_DEFAULTS, };
326
372
  //# sourceMappingURL=results.d.ts.map
@@ -29,24 +29,77 @@ function slugify(text) {
29
29
  .replace(/^-|-$/g, "")
30
30
  .slice(0, 80);
31
31
  }
32
+ /**
33
+ * llama.cpp's own sampler defaults, the values an untouched profile stores
34
+ * verbatim (see `ProfileSchema`). A key token is only emitted when the profile
35
+ * deviates from these, which is what makes an untouched profile keep its
36
+ * historical key after the widening.
37
+ */
38
+ const ENGINE_SAMPLER_DEFAULTS = {
39
+ temperature: 0.8,
40
+ topP: 0.95,
41
+ topK: 40,
42
+ minP: 0.05,
43
+ presencePenalty: 0,
44
+ repeatPenalty: 1,
45
+ };
32
46
  /**
33
47
  * Stable identity for "same model, same settings", so reruns can be grouped.
34
48
  *
35
- * Deliberately unchanged when the record grew: this string is the grouping key
36
- * every stored run was written with, and widening it would not re-key history -
37
- * it would split each model's past runs from its future ones and quietly reset
38
- * every variance figure on the page. New settings are recorded as data, not
39
- * folded in here.
49
+ * The base four tokens predate the record and are never rewritten - a stored
50
+ * run's key is its key, and re-deriving the same string for the same setup is
51
+ * what lets old runs stay in their old groups. The tokens added in schema 3 are
52
+ * appended ONLY when they deviate from the engine default, because that is what
53
+ * the key means: the effective configuration, and a default is the historical
54
+ * configuration. An untouched profile therefore keeps exactly the key it had
55
+ * before the setting existed, so variance figures for unchanged setups survive
56
+ * the widening; a run that differs only in, say, `contextMultiplier` gets a
57
+ * new key instead of silently merging into its predecessor's group and
58
+ * contaminating that group's consistency line.
40
59
  */
41
60
  function configKey(profile) {
42
61
  if (!profile)
43
62
  return "unknown";
44
- return [
63
+ const parts = [
45
64
  `ctx${profile.contextSize}`,
46
65
  `kv${profile.cacheTypeK}-${profile.cacheTypeV}`,
47
66
  `rb${profile.reasoningBudget}`,
48
67
  profile.vision ? "vision" : "novision",
49
- ].join("_");
68
+ ];
69
+ if (profile.contextMultiplier !== undefined && profile.contextMultiplier > 1) {
70
+ parts.push(`x${profile.contextMultiplier}`);
71
+ }
72
+ if (profile.cachedChats !== undefined && profile.cachedChats > 0) {
73
+ parts.push(`cc${profile.cachedChats}`);
74
+ }
75
+ if (profile.preserveReasoning === true)
76
+ parts.push("pr");
77
+ else if (profile.preserveReasoning === false)
78
+ parts.push("nopr");
79
+ // The sampler deviates from the engine default only when the user set it -
80
+ // an untouched profile stores llama.cpp's own defaults verbatim, and those
81
+ // are the default, so they carry no token. Each entry pairs the short token
82
+ // name with the `ENGINE_SAMPLER_DEFAULTS` key the default is looked up by -
83
+ // the two deliberately differ, so conflating them would emit every sampler
84
+ // value on every key.
85
+ const sampler = [
86
+ ["temp", profile.temperature, "temperature"],
87
+ ["p", profile.topP, "topP"],
88
+ ["k", profile.topK, "topK"],
89
+ ["minp", profile.minP, "minP"],
90
+ ["pres", profile.presencePenalty, "presencePenalty"],
91
+ ["rep", profile.repeatPenalty, "repeatPenalty"],
92
+ ];
93
+ for (const [name, value, defaultKey] of sampler) {
94
+ if (typeof value === "number" &&
95
+ Number.isFinite(value) &&
96
+ value !== ENGINE_SAMPLER_DEFAULTS[defaultKey]) {
97
+ parts.push(`${name}${value}`);
98
+ }
99
+ }
100
+ if (profile.hostingProfileId)
101
+ parts.push(`hp${profile.hostingProfileId}`);
102
+ return parts.join("_");
50
103
  }
51
104
  /** Read a numeric GGUF metadata field, which is `null` when the header lacked it. */
52
105
  function metadataNumber(model, key) {
@@ -83,7 +136,7 @@ function buildSetup({ args, fit, calibration, }) {
83
136
  function save({ model, profile, report, gpu = null, runtime = null, archiveId = null, system = null, args = null, fit = null, calibration = null, suite = null, timestamp = new Date(), }) {
84
137
  fs.mkdirSync(RESULTS_DIR, { recursive: true });
85
138
  const record = {
86
- schema: 2,
139
+ schema: 3,
87
140
  ranAt: timestamp.toISOString(),
88
141
  model: {
89
142
  id: model?.id ?? null,
@@ -99,7 +152,9 @@ function save({ model, profile, report, gpu = null, runtime = null, archiveId =
99
152
  },
100
153
  // The whole profile, not a summary of it. `ProfileSchema` is `.passthrough()`
101
154
  // and its numeric fields carry defaults, so read them defensively: a profile
102
- // written by an older brain can be missing anything below `vision`.
155
+ // written by an older brain can be missing anything below `vision`, and a
156
+ // schema-1-era profile can be missing the sampler values entirely (they
157
+ // were implicit in llama.cpp then).
103
158
  profile: profile
104
159
  ? {
105
160
  contextSize: profile.contextSize,
@@ -114,6 +169,16 @@ function save({ model, profile, report, gpu = null, runtime = null, archiveId =
114
169
  batchSize: profile.batchSize ?? null,
115
170
  ubatchSize: profile.ubatchSize ?? null,
116
171
  extraArgs: profile.extraArgs ?? [],
172
+ preserveReasoning: profile.preserveReasoning ?? null,
173
+ contextMultiplier: profile.contextMultiplier ?? 1,
174
+ cachedChats: profile.cachedChats ?? 0,
175
+ temperature: profile.temperature ?? null,
176
+ topP: profile.topP ?? null,
177
+ topK: profile.topK ?? null,
178
+ minP: profile.minP ?? null,
179
+ presencePenalty: profile.presencePenalty ?? null,
180
+ repeatPenalty: profile.repeatPenalty ?? null,
181
+ hostingProfileId: profile.hostingProfileId ?? null,
117
182
  }
118
183
  : null,
119
184
  setup: buildSetup({ args, fit, calibration }),
@@ -318,5 +383,5 @@ function taskColumns(records) {
318
383
  }
319
384
  return [...seen.entries()].map(([id, category]) => ({ id, category }));
320
385
  }
321
- export { RESULTS_DIR, save, loadAll, latestPerConfig, grouped, variance, stats, rankModels, taskColumns, configKey, slugify, };
386
+ export { RESULTS_DIR, save, loadAll, latestPerConfig, grouped, variance, stats, rankModels, taskColumns, configKey, slugify, ENGINE_SAMPLER_DEFAULTS, };
322
387
  //# sourceMappingURL=results.js.map
@@ -7,7 +7,19 @@ import type { Profile } from "../config/schema.js";
7
7
  * Thinking models default to an unrestricted budget (-1) and will happily burn
8
8
  * an entire token allowance reasoning, returning no content at all. The right
9
9
  * cap is model-specific, so measure it: run one long-horizon task per candidate
10
- * budget and score by delivered content per second.
10
+ * budget and take the largest budget that still delivers the whole task. See
11
+ * rankSweepResults for why "largest" and not "fastest".
12
+ */
13
+ /**
14
+ * The candidates a sweep tries, in ascending order of thinking room.
15
+ *
16
+ * Capped at 1536 deliberately. The ranking prefers the largest budget that
17
+ * still delivers, so whatever sits at the top of this list is what a healthy
18
+ * thinking model gets recommended - the list itself is the ceiling, and 1536 is
19
+ * the value every hand-set profile has run on without trouble. Each extra
20
+ * candidate also costs a full model load plus a long generation, so the ladder
21
+ * earns its length. `-1` stays because a model that fails under every finite cap
22
+ * still needs an answer; it ranks last and wins only in that case.
11
23
  */
12
24
  export declare const DEFAULT_BUDGETS: number[];
13
25
  export declare const LONG_TASK: string;
@@ -75,5 +87,30 @@ export interface SweepOptions {
75
87
  onProgress?: (event: SweepProgress) => void;
76
88
  }
77
89
  export declare function sweep({ runtime, model, profile, budgets, maxTokens, temperature, internalPort, supervisor: optionsSupervisor, onProgress, }: SweepOptions): Promise<SweepReport>;
90
+ /**
91
+ * Rank a sweep's trials, best first. `ranked[0].budget` is the recommendation.
92
+ *
93
+ * Delivery decides first: a budget that dropped a file did not do the job.
94
+ * Among the budgets that delivered everything, prefer the **largest** - the most
95
+ * thinking room a model can have without failing to deliver.
96
+ *
97
+ * That tie used to break on content per second, which was backwards. LONG_TASK
98
+ * is pure output, so reasoning earns nothing on it and only costs wall clock:
99
+ * every budget large enough to finish delivered all four files, the tie-break
100
+ * therefore decided every sweep, and the smallest candidate won by construction
101
+ * - 0 included. That is how models ended up capped at 0 and 512, tight enough
102
+ * that llama-server guillotines an ordinary thought mid-sentence, injects
103
+ * `--reasoning-budget-message`, and the model's unfinished reasoning carries on
104
+ * over the content channel as user-visible prose. The sweep's job is to find the
105
+ * cap past which a model degenerates into reasoning forever, not the cap that
106
+ * types fastest. See the "reasoning budget makes thinking bleed into prose"
107
+ * finding in Otto Knowledge.
108
+ *
109
+ * `-1` is the one budget "largest" must not read as large: an unrestricted
110
+ * budget is the failure this package exists to prevent. It ranks below every
111
+ * finite cap and is recommended only when nothing else survived, which means a
112
+ * cap of any size broke the model.
113
+ */
114
+ export declare function rankSweepResults(results: readonly SweepResult[]): SweepResult[];
78
115
  export {};
79
116
  //# sourceMappingURL=sweep.d.ts.map
package/dist/ops/sweep.js CHANGED
@@ -6,9 +6,23 @@ import { DEFAULT_INTERNAL_PORT, Supervisor } from "../service/supervisor.js";
6
6
  * Thinking models default to an unrestricted budget (-1) and will happily burn
7
7
  * an entire token allowance reasoning, returning no content at all. The right
8
8
  * cap is model-specific, so measure it: run one long-horizon task per candidate
9
- * budget and score by delivered content per second.
9
+ * budget and take the largest budget that still delivers the whole task. See
10
+ * rankSweepResults for why "largest" and not "fastest".
10
11
  */
11
- export const DEFAULT_BUDGETS = [0, 512, 1536, 3072, -1];
12
+ /**
13
+ * The candidates a sweep tries, in ascending order of thinking room.
14
+ *
15
+ * Capped at 1536 deliberately. The ranking prefers the largest budget that
16
+ * still delivers, so whatever sits at the top of this list is what a healthy
17
+ * thinking model gets recommended - the list itself is the ceiling, and 1536 is
18
+ * the value every hand-set profile has run on without trouble. Each extra
19
+ * candidate also costs a full model load plus a long generation, so the ladder
20
+ * earns its length. `-1` stays because a model that fails under every finite cap
21
+ * still needs an answer; it ranks last and wins only in that case.
22
+ */
23
+ export const DEFAULT_BUDGETS = [0, 512, 1536, -1];
24
+ /** llama.cpp's "no cap at all" sentinel, which a sweep must never recommend. */
25
+ const UNRESTRICTED_BUDGET = -1;
12
26
  export const LONG_TASK = "Write a complete Python implementation of a thread-safe LRU cache with TTL " +
13
27
  "expiry. Produce FOUR separate complete files, each fully implemented with no " +
14
28
  "placeholders or elisions:\n" +
@@ -87,7 +101,7 @@ export async function sweep({ runtime, model, profile, budgets = DEFAULT_BUDGETS
87
101
  const supervisor = optionsSupervisor ?? new Supervisor({ runtime, internalPort });
88
102
  onProgress({ phase: "loading", budget });
89
103
  try {
90
- await supervisor.start(model, { ...profile, reasoningBudget: budget }, { preserveLogs: Boolean(optionsSupervisor) });
104
+ await supervisor.start(model, { ...profile, reasoningBudget: budget });
91
105
  onProgress({ phase: "generating", budget });
92
106
  const trial = await runTrial({ supervisor, maxTokens, temperature });
93
107
  results.push({ budget, ...trial, error: null });
@@ -109,13 +123,7 @@ export async function sweep({ runtime, model, profile, budgets = DEFAULT_BUDGETS
109
123
  await new Promise((resolve) => setTimeout(resolve, 2500));
110
124
  }
111
125
  }
112
- // Prefer runs that delivered every file; break ties on content per second.
113
- const viable = results.filter((r) => !r.error && r.contentChars > 0);
114
- const ranked = [...viable].sort((a, b) => {
115
- if (b.filesDelivered !== a.filesDelivered)
116
- return b.filesDelivered - a.filesDelivered;
117
- return b.contentPerSecond - a.contentPerSecond;
118
- });
126
+ const ranked = rankSweepResults(results);
119
127
  return {
120
128
  results,
121
129
  recommended: ranked.length ? ranked[0].budget : null,
@@ -123,4 +131,47 @@ export async function sweep({ runtime, model, profile, budgets = DEFAULT_BUDGETS
123
131
  sweptAt: new Date().toISOString(),
124
132
  };
125
133
  }
134
+ /**
135
+ * Rank a sweep's trials, best first. `ranked[0].budget` is the recommendation.
136
+ *
137
+ * Delivery decides first: a budget that dropped a file did not do the job.
138
+ * Among the budgets that delivered everything, prefer the **largest** - the most
139
+ * thinking room a model can have without failing to deliver.
140
+ *
141
+ * That tie used to break on content per second, which was backwards. LONG_TASK
142
+ * is pure output, so reasoning earns nothing on it and only costs wall clock:
143
+ * every budget large enough to finish delivered all four files, the tie-break
144
+ * therefore decided every sweep, and the smallest candidate won by construction
145
+ * - 0 included. That is how models ended up capped at 0 and 512, tight enough
146
+ * that llama-server guillotines an ordinary thought mid-sentence, injects
147
+ * `--reasoning-budget-message`, and the model's unfinished reasoning carries on
148
+ * over the content channel as user-visible prose. The sweep's job is to find the
149
+ * cap past which a model degenerates into reasoning forever, not the cap that
150
+ * types fastest. See the "reasoning budget makes thinking bleed into prose"
151
+ * finding in Otto Knowledge.
152
+ *
153
+ * `-1` is the one budget "largest" must not read as large: an unrestricted
154
+ * budget is the failure this package exists to prevent. It ranks below every
155
+ * finite cap and is recommended only when nothing else survived, which means a
156
+ * cap of any size broke the model.
157
+ */
158
+ export function rankSweepResults(results) {
159
+ const viable = results.filter((result) => !result.error && result.contentChars > 0);
160
+ return [...viable].sort((a, b) => {
161
+ if (b.filesDelivered !== a.filesDelivered) {
162
+ return b.filesDelivered - a.filesDelivered;
163
+ }
164
+ const rankA = budgetRank(a.budget);
165
+ const rankB = budgetRank(b.budget);
166
+ if (rankA === rankB) {
167
+ return 0;
168
+ }
169
+ // Compared rather than subtracted: the sentinel's rank is -Infinity, and
170
+ // -Infinity - -Infinity is NaN, which would corrupt the whole sort.
171
+ return rankB > rankA ? 1 : -1;
172
+ });
173
+ }
174
+ function budgetRank(budget) {
175
+ return budget === UNRESTRICTED_BUDGET ? Number.NEGATIVE_INFINITY : budget;
176
+ }
126
177
  //# sourceMappingURL=sweep.js.map
@@ -3,11 +3,24 @@
3
3
  * needs. Runtime-source agnostic: works the same for an LM Studio runtime or a
4
4
  * managed one, since both resolve to a `Runtime` (exe + optional vendorDir).
5
5
  */
6
- import type { Profile } from "../config/schema.js";
6
+ import type { Calibration, Profile } from "../config/schema.js";
7
7
  import type { Model, Runtime } from "../types.js";
8
8
  export interface ServeTarget {
9
9
  port: number;
10
10
  host?: string;
11
+ /** llama.cpp log threshold; 3 preserves its upstream default Info output. */
12
+ logVerbosity?: number;
13
+ /**
14
+ * A directory the engine may use for its slot save/restore/erase actions
15
+ * (`POST /slots`). Passing it is what UNLOCKS the `action=erase` route:
16
+ * llama.cpp refuses every `POST /slots` action when `--slot-save-path` is not
17
+ * set, and the scheduler's cross-chat KV-bleed fix erases a slot's retained
18
+ * KV the moment it is handed to a different chat. The directory must exist -
19
+ * the engine validates it at launch and throws otherwise - so the caller
20
+ * creates it. Absent (null) means the engine's own default: slot actions
21
+ * disabled, which is the pre-fix behavior.
22
+ */
23
+ slotSavePath?: string | null;
11
24
  }
12
25
  /**
13
26
  * Loader environment the child process needs so it can resolve its shared
@@ -28,7 +41,7 @@ export declare function buildEnv(runtime: Runtime, baseEnv?: NodeJS.ProcessEnv,
28
41
  * Only settings that demonstrably matter for stable local inference are emitted -
29
42
  * no experimental sampler knobs.
30
43
  */
31
- export declare function buildArgs(profile: Profile, { port, host }: ServeTarget, model?: Model): string[];
44
+ export declare function buildArgs(profile: Profile, { port, host, logVerbosity, slotSavePath }: ServeTarget, model?: Model, calibration?: Calibration | null): string[];
32
45
  /** The same command as a copy-pasteable shell line, for the TUI to display. */
33
46
  export declare function formatCommand(runtime: Runtime, args: string[]): string;
34
47
  //# sourceMappingURL=args.d.ts.map
@@ -1,3 +1,5 @@
1
+ import { promptCacheSize } from "../vram.js";
2
+ const MIB = 1024 * 1024;
1
3
  /**
2
4
  * Loader environment the child process needs so it can resolve its shared
3
5
  * libraries. Both the runtime dir and its vendor dir go first, ahead of the
@@ -29,7 +31,7 @@ export function buildEnv(runtime, baseEnv = process.env, platform = process.plat
29
31
  * Only settings that demonstrably matter for stable local inference are emitted -
30
32
  * no experimental sampler knobs.
31
33
  */
32
- export function buildArgs(profile, { port, host = "127.0.0.1" }, model) {
34
+ export function buildArgs(profile, { port, host = "127.0.0.1", logVerbosity = 3, slotSavePath = null }, model, calibration) {
33
35
  const args = [
34
36
  "-m",
35
37
  profile.modelPath ?? "",
@@ -47,8 +49,19 @@ export function buildArgs(profile, { port, host = "127.0.0.1" }, model) {
47
49
  host,
48
50
  "--port",
49
51
  String(port),
50
- "--no-webui",
52
+ "--no-ui",
53
+ "-lv",
54
+ String(logVerbosity),
51
55
  ];
56
+ // A slot-save directory unlocks the engine's `POST /slots` actions - the
57
+ // `action=erase` the scheduler needs to wipe a slot's retained KV before it
58
+ // is handed to a different chat (see OWNERSHIP in scheduler.ts). Without it
59
+ // llama.cpp refuses every slot action with "start it with --slot-save-path".
60
+ // The directory must already exist: the engine validates it at launch and
61
+ // throws otherwise, so the caller creates it before building the args.
62
+ if (slotSavePath) {
63
+ args.push("--slot-save-path", slotSavePath);
64
+ }
52
65
  if (profile.vision && profile.mmprojPath) {
53
66
  args.push("--mmproj", profile.mmprojPath);
54
67
  }
@@ -64,8 +77,44 @@ export function buildArgs(profile, { port, host = "127.0.0.1" }, model) {
64
77
  args.push("--reasoning-budget-message", profile.reasoningBudgetMessage);
65
78
  }
66
79
  }
80
+ // Whether the reasoning trace stays in the whole history or is trimmed to the
81
+ // last assistant message. Emitted only on an explicit choice: llama-server's
82
+ // own default is the template's, and there is no third flag spelling for "do
83
+ // what the template says" - the absence of the flag IS that state.
84
+ if (profile.preserveReasoning === true) {
85
+ args.push("--reasoning-preserve");
86
+ }
87
+ else if (profile.preserveReasoning === false) {
88
+ args.push("--no-reasoning-preserve");
89
+ }
90
+ // Sampler settings. Always emitted, because the profile stores llama.cpp's own
91
+ // defaults verbatim, so an untouched profile produces the run it always did
92
+ // while the values stay visible and editable rather than implicit.
93
+ for (const [flag, value] of [
94
+ ["--temp", profile.temperature],
95
+ ["--top-p", profile.topP],
96
+ ["--top-k", profile.topK],
97
+ ["--min-p", profile.minP],
98
+ ["--presence-penalty", profile.presencePenalty],
99
+ ["--repeat-penalty", profile.repeatPenalty],
100
+ ]) {
101
+ if (typeof value === "number" && Number.isFinite(value)) {
102
+ args.push(flag, String(value));
103
+ }
104
+ }
67
105
  if (profile.parallelSlots)
68
106
  args.push("--parallel", String(profile.parallelSlots));
107
+ // Prompt cache: how much host RAM llama-server may use to park the KV state
108
+ // of chats that lost their slot, so returning to one is a bulk copy instead
109
+ // of a full re-prefill. Sized from the chat count the user chose, because a
110
+ // raw MiB figure is unusable without knowing this model's KV bytes/token.
111
+ // Emitted only when the size is real: with no measurement, the theoretical
112
+ // KV cost overestimates by multiples, and reserving multiples of the RAM
113
+ // actually needed is worse than leaving llama.cpp's own default in place.
114
+ const cache = model ? promptCacheSize(model, profile, calibration) : null;
115
+ if (cache && cache.source === "measured") {
116
+ args.push("--cache-ram", String(Math.max(1, Math.round(cache.totalBytes / MIB))));
117
+ }
69
118
  if (profile.contextMultiplier > 1) {
70
119
  const nativeContext = model?.metadata?.contextLength;
71
120
  args.push("--rope-scaling", "yarn", "--rope-scale", String(profile.contextMultiplier), ...(nativeContext ? ["--yarn-orig-ctx", String(nativeContext)] : []), ...(model?.metadata?.arch
@@ -78,9 +127,15 @@ export function buildArgs(profile, { port, host = "127.0.0.1" }, model) {
78
127
  args.push("-ub", String(profile.ubatchSize));
79
128
  if (profile.chatTemplateFile) {
80
129
  args.push("--chat-template-file", profile.chatTemplateFile);
81
- if (Object.keys(profile.chatTemplateKwargs ?? {}).length > 0) {
82
- args.push("--chat-template-kwargs", JSON.stringify(profile.chatTemplateKwargs));
83
- }
130
+ }
131
+ const templateKwargs = { ...profile.chatTemplateKwargs };
132
+ const preservation = model?.reasoningPreservation;
133
+ if (preservation?.templateArgument) {
134
+ templateKwargs[preservation.templateArgument] =
135
+ profile.preserveReasoning ?? preservation.default ?? false;
136
+ }
137
+ if (Object.keys(templateKwargs).length > 0) {
138
+ args.push("--chat-template-kwargs", JSON.stringify(templateKwargs));
84
139
  }
85
140
  if (profile.extraArgs && profile.extraArgs.length)
86
141
  args.push(...profile.extraArgs);
@@ -38,8 +38,8 @@
38
38
  * also needs `libcublas`, `libcudart` and `libnccl.so.2` - and NCCL ships in
39
39
  * neither NVIDIA redistributable. Do not reopen this without a measurement on
40
40
  * hardware that does *not* report NV_coopmat2, which is the one case where
41
- * the gap could still be real. Full evidence:
42
- * findings/linux-gpu-acceleration/2026-08-04-cuda-vs-vulkan-and-cuda-asset-origins.md
41
+ * the gap could still be real. Measured 2026-08-04; the full method and
42
+ * numbers are the "cuda vs vulkan" finding in Otto Knowledge.
43
43
  * - **No Linux asset ships `libgomp.so.1`**, which `llama-server` hard-links,
44
44
  * so a host without `libgomp1` installs a runtime that then exits 127 on
45
45
  * spawn. Windows bundles its OpenMP runtime (`libomp140.x86_64.dll`); Linux
@@ -64,6 +64,14 @@ export interface InferenceActivitySnapshot {
64
64
  processing: number;
65
65
  thinking: number;
66
66
  generating: number;
67
+ /**
68
+ * Per-slot stage join, present only when at least one tracked request was
69
+ * pinned to a llama-server slot by the router (host API v3). Keys are the
70
+ * engine's slot ids as reported by `/slots`; values are that request's
71
+ * proxy-side stage. Absent (not just empty) on brains that predate the join,
72
+ * which is how an old client tells "no join data" from "no pinned requests".
73
+ */
74
+ slotStages?: Record<string, InferenceStage>;
67
75
  }
68
76
  /**
69
77
  * Which in-flight completions are currently mid-thought.
@@ -91,6 +99,17 @@ export declare class ReasoningTracker {
91
99
  onChange(listener: () => void): () => void;
92
100
  /** A completion was dispatched to llama-server and awaits its first output delta. */
93
101
  begin(requestId: string): void;
102
+ /**
103
+ * Record the engine slot this request was pinned to. Called exactly once per
104
+ * request, at dispatch - the pin is injected into the outbound body before
105
+ * the request goes out, so the association exists before the first chunk and
106
+ * `observe` never has to learn about it.
107
+ *
108
+ * Idempotent and self-cleaning: a repeat for the same slot is a no-op, and a
109
+ * different slot replaces it, so a request that somehow moves slots (a
110
+ * restarted engine hands a task out again) reports where it is now.
111
+ */
112
+ setSlot(requestId: string, slotId: number): void;
94
113
  /** Note a chunk of `requestId`'s stream. Cheap enough to call per chunk. */
95
114
  observe(requestId: string, text: string): void;
96
115
  /** Forget the request. Must be called on end *and* on error, or the flag sticks. */