@bendyline/gezel-inference 1.0.7 → 1.0.9

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 (78) hide show
  1. package/dist/catalog/chat-models/ge/gemma4-12b-q4/manifest.json +46 -3
  2. package/dist/catalog/chat-models/ge/gemma4-12b-q4/versions/1.2.1/manifest.json +141 -0
  3. package/dist/catalog/chat-models/ge/gemma4-e4b-q4/manifest.json +5 -0
  4. package/dist/catalog/chat-models/index.json +1 -1
  5. package/dist/catalog/chat-models/or/ornith1.5-9b-q4/manifest.json +5 -0
  6. package/dist/catalog/chat-models/or/ornith1.5-9b-q8/manifest.json +5 -0
  7. package/dist/catalog/chat-models/qw/qwen3.5-9b-q4/manifest.json +5 -0
  8. package/dist/catalog/chat-models/qw/qwen3.8-27b-q4/manifest.json +5 -0
  9. package/dist/catalog/chat-models/qw/qwen3.8-flash-next-q2/manifest.json +24 -7
  10. package/dist/catalog/chat-models/qw/qwen3.8-flash-next-q2/versions/1.0.3/manifest.json +47 -0
  11. package/dist/catalog/chat-models/raw-index.json +1 -1
  12. package/dist/catalog/provenance.json +1 -1
  13. package/dist/engines/electron-native-reuse.d.ts +5 -4
  14. package/dist/engines/electron-native-reuse.js +1 -1
  15. package/dist/engines/native-file-manifest.json +12344 -698
  16. package/dist/engines/native-manifest.d.ts +4 -4
  17. package/dist/engines/native-manifest.js +40 -36
  18. package/dist/engines/resolver.d.ts +18 -17
  19. package/dist/engines/resolver.js +36 -1
  20. package/dist/http/model-inventory-cache.d.ts +1 -1
  21. package/dist/http/routes/engines.js +3 -3
  22. package/dist/http/routes/v1-images.d.ts +2 -1
  23. package/dist/http/routes/v1-images.js +36 -9
  24. package/dist/knowledge/manager.d.ts +3 -27
  25. package/dist/knowledge/manager.js +8 -364
  26. package/dist/knowledge/unified-search.d.ts +61 -0
  27. package/dist/knowledge/unified-search.js +373 -0
  28. package/dist/models/catalog-drift.d.ts +2 -0
  29. package/dist/models/catalog-drift.js +1 -1
  30. package/dist/models/catalog-payload-identity.d.ts +2 -2
  31. package/dist/models/catalog-payload-identity.js +10 -5
  32. package/dist/providers/active-install-message.js +1 -1
  33. package/dist/providers/catalog-model-config.d.ts +28 -0
  34. package/dist/providers/ds4/build-provider.js +35 -13
  35. package/dist/providers/ds4/residency.d.ts +77 -0
  36. package/dist/providers/ds4/residency.js +87 -6
  37. package/dist/providers/image/sd-cpp.js +19 -11
  38. package/dist/providers/llama-cpp/backend-health.d.ts +3 -2
  39. package/dist/providers/llama-cpp/backend-health.js +6 -3
  40. package/dist/providers/llama-cpp/backend-quarantine.js +1 -1
  41. package/dist/providers/llama-cpp/build-provider.js +168 -29
  42. package/dist/providers/llama-cpp/devices.js +4 -1
  43. package/dist/providers/llama-cpp/engine-flags.d.ts +8 -1
  44. package/dist/providers/llama-cpp/engine-flags.js +13 -8
  45. package/dist/providers/llama-cpp/gpu-startup-watch.d.ts +4 -4
  46. package/dist/providers/llama-cpp/gpu-startup-watch.js +7 -5
  47. package/dist/providers/llama-cpp/models.d.ts +10 -0
  48. package/dist/providers/llama-cpp/models.js +64 -10
  49. package/dist/providers/llama-cpp/offload-planner.d.ts +6 -0
  50. package/dist/providers/llama-cpp/offload-planner.js +1 -1
  51. package/dist/providers/llama-cpp/spec-draft.d.ts +45 -23
  52. package/dist/providers/llama-cpp/spec-draft.js +113 -24
  53. package/dist/providers/mlx/async-file-handoff.d.ts +9 -1
  54. package/dist/providers/mlx/async-file-handoff.js +32 -4
  55. package/dist/providers/mlx/provider.js +28 -16
  56. package/dist/providers/mlx/python/spec_decode.py +23 -2
  57. package/dist/providers/mlx/python/tool_args_json.py +59 -21
  58. package/dist/providers/mlx/runtime-diagnostics.d.ts +6 -0
  59. package/dist/providers/mlx/runtime-diagnostics.js +18 -1
  60. package/dist/providers/mlx/tool-call-protocol.d.ts +4 -3
  61. package/dist/providers/mlx/tool-call-protocol.js +4 -3
  62. package/dist/providers/mlx/venv.d.ts +19 -1
  63. package/dist/providers/mlx/venv.js +21 -2
  64. package/dist/providers/native/device-capacity-ledger.d.ts +4 -2
  65. package/dist/providers/native/device-capacity-ledger.js +35 -20
  66. package/dist/providers/native/device-capacity.d.ts +4 -0
  67. package/dist/providers/native/device-capacity.js +5 -0
  68. package/dist/providers/native/local-engine-runtime.d.ts +16 -1
  69. package/dist/providers/native/local-engine-runtime.js +142 -39
  70. package/dist/providers/native/process-identity.d.ts +38 -0
  71. package/dist/providers/native/process-identity.js +86 -0
  72. package/dist/providers/native/supervisor.d.ts +8 -1
  73. package/dist/providers/native/supervisor.js +16 -7
  74. package/dist/providers/types.d.ts +4 -0
  75. package/dist/remotes/types.d.ts +1 -1
  76. package/dist/runtime-lock.d.ts +3 -27
  77. package/dist/runtime-lock.js +7 -80
  78. package/package.json +4 -4
@@ -22,13 +22,17 @@ chunk by chunk. Everything else (ints, floats, bools, objects, arrays) is
22
22
  small and needs the whole value for `_convert_param_value`-style coercion, so
23
23
  those are buffered per-parameter and emitted when their `</parameter>` lands.
24
24
 
25
- Semantics are copied from mlx_vlm 0.6.6 `tool_parsers/qwen3_coder.py` and must
26
- stay identical; `test_tool_args_json.py` asserts that by differential-testing
27
- every case against the reference parser. In particular:
25
+ Semantics are copied from mlx_vlm 0.7.12 `tools/parsers/qwen3_coder.py` and
26
+ must stay identical; `tool_args_json_test.py` asserts that by differential-
27
+ testing every case against the reference parser. In particular:
28
28
 
29
29
  * exactly ONE leading and ONE trailing "\\n" is stripped from a value
30
- * a literal "null" (any case) becomes JSON null
30
+ * a literal "null" (any case) becomes JSON null, except in a declared
31
+ string parameter, which keeps the text unless the schema says
32
+ `"nullable": true`
31
33
  * a parameter absent from the schema is treated as a string
34
+ * a declared parameter without a type decodes JSON objects and arrays and
35
+ keeps anything else as text
32
36
  * unknown/odd types fall through to `ast.literal_eval`, as upstream does
33
37
 
34
38
  Chunk boundaries are the hazard throughout: `</parameter>`, `</function>` and
@@ -68,29 +72,59 @@ def arguments_config(func_name: str, tools: Optional[Any]) -> Dict[str, Any]:
68
72
  return {}
69
73
 
70
74
 
75
+ def _declared_string(param: Any) -> bool:
76
+ return (
77
+ isinstance(param, dict)
78
+ and isinstance(param.get("type"), str)
79
+ and param["type"].strip().lower() in _STRING_TYPES
80
+ )
81
+
82
+
71
83
  def is_string_param(param_name: str, param_config: Dict[str, Any]) -> bool:
72
84
  """Can this parameter stream, or must its value be buffered whole?
73
85
 
74
86
  Unknown parameters count as strings — upstream returns the raw value when
75
- the schema has no entry, and raw-string is the streamable case.
87
+ the schema has no entry, and raw-string is the streamable case. A declared
88
+ parameter without a type is buffered: its value may decode to an object.
76
89
  """
77
- param = param_config.get(param_name)
78
- if not param:
79
- return True
80
- declared = param.get("type")
81
- if declared is None:
90
+ if param_name not in param_config:
82
91
  return True
83
- return str(declared).strip().lower() in _STRING_TYPES
92
+ param = param_config[param_name]
93
+ if not isinstance(param, dict) or "type" not in param:
94
+ return False
95
+ return str(param["type"]).strip().lower() in _STRING_TYPES
96
+
97
+
98
+ def null_text_is_none(param_name: str, param_config: Dict[str, Any]) -> bool:
99
+ """Whether a complete value of "null" is JSON null for this parameter.
100
+
101
+ A declared string keeps the text unless the schema marks it nullable;
102
+ every other parameter, unknown ones included, reads it as null.
103
+ """
104
+ param = param_config.get(param_name)
105
+ if _declared_string(param):
106
+ return param.get("nullable") is True
107
+ return True
84
108
 
85
109
 
86
110
  def convert_param_value(value: str, param_name: str, param_config: Dict[str, Any]) -> Any:
87
111
  """Coerce a complete value, mirroring `_convert_param_value`."""
112
+ param = param_config.get(param_name)
113
+ if _declared_string(param):
114
+ if value.lower() == "null" and param.get("nullable") is True:
115
+ return None
116
+ return value
88
117
  if value.lower() == "null":
89
118
  return None
90
- param = param_config.get(param_name)
91
- if not param:
119
+ if param_name not in param_config:
92
120
  return value
93
- param_type = str(param["type"]).strip().lower() if "type" in param else "string"
121
+ if not isinstance(param, dict) or "type" not in param:
122
+ try:
123
+ decoded = json.loads(value)
124
+ except json.JSONDecodeError:
125
+ return value
126
+ return decoded if isinstance(decoded, (dict, list)) else value
127
+ param_type = str(param["type"]).strip().lower()
94
128
  if param_type in _STRING_TYPES:
95
129
  return value
96
130
  if param_type.startswith(("int", "uint", "long", "short", "unsigned")):
@@ -194,10 +228,10 @@ class ToolArgsJsonEncoder:
194
228
  self._leading_stripped = False
195
229
  self._opened_quote = False
196
230
  # NOTE: the opening quote is deliberately NOT emitted yet.
197
- # `_convert_param_value` maps a whole value of "null" to JSON
198
- # null BEFORE consulting the schema, so even a string-typed
199
- # parameter can turn out to be null — and by then a quote
200
- # would already be on the wire. Hold the first few bytes.
231
+ # A whole value of "null" is JSON null for an unknown or
232
+ # nullable string parameter (`null_text_is_none`) — and by
233
+ # then a quote would already be on the wire. Hold the first
234
+ # few bytes.
201
235
  continue
202
236
 
203
237
  end = self._buf.find(_PARAM_CLOSE)
@@ -228,8 +262,8 @@ class ToolArgsJsonEncoder:
228
262
  # * strip exactly one leading newline
229
263
  # * strip exactly one trailing newline (so the final newline is
230
264
  # held until we know whether anything follows it)
231
- # * a complete value of "null" is JSON null, not a string, so
232
- # the opening quote waits until the value is longer than
265
+ # * a complete value of "null" can be JSON null, not a string,
266
+ # so the opening quote waits until the value is longer than
233
267
  # "null" or the parameter closes
234
268
  if not self._leading_stripped:
235
269
  if chunk.startswith("\n"):
@@ -241,7 +275,11 @@ class ToolArgsJsonEncoder:
241
275
  self._value_buf += chunk
242
276
  if end < 0 and len(self._value_buf) <= 4:
243
277
  break
244
- if end >= 0 and self._value_buf.lower() == "null":
278
+ if (
279
+ end >= 0
280
+ and self._value_buf.lower() == "null"
281
+ and null_text_is_none(self._value_name, self.param_config)
282
+ ):
245
283
  out.append(f"{json.dumps(self._value_name)}:null")
246
284
  self._in_value = False
247
285
  continue
@@ -38,6 +38,12 @@ export declare function buildPreFirstByteAbortMessage(lastPrefill: {
38
38
  } | null): string;
39
39
  /** Build the user-facing message for an engine stream that died mid-turn. */
40
40
  export declare function buildMidStreamDropMessage(charsReceived: number, plannedStop: boolean, engineStillRunning?: boolean): string;
41
+ /**
42
+ * Prompt characters one attached image stands for. The sidecar bounds images
43
+ * to 1024px, so their patch tokens are bounded too, and base64 bytes are never
44
+ * read as language tokens.
45
+ */
46
+ export declare const MLX_IMAGE_RESERVED_CHARS = 8192;
41
47
  /** Estimate request tokens from message bodies and serialized tool schemas. */
42
48
  export declare function estimatePromptTokens(messages: unknown, tools: unknown): number;
43
49
  export declare const PRE_FIRST_BYTE_BASE_MS = 300000;
@@ -146,11 +146,28 @@ export function buildMidStreamDropMessage(charsReceived, plannedStop, engineStil
146
146
  }
147
147
  return `[Mac AI] the on-device engine dropped the connection mid-turn (${got}). This usually means the mlx server crashed, ran out of memory, or was restarted while the turn was streaming — this turn's work was lost. Retry the turn; if it keeps happening, restart the engine in Settings → On-device.`;
148
148
  }
149
+ /**
150
+ * Prompt characters one attached image stands for. The sidecar bounds images
151
+ * to 1024px, so their patch tokens are bounded too, and base64 bytes are never
152
+ * read as language tokens.
153
+ */
154
+ export const MLX_IMAGE_RESERVED_CHARS = 8192;
149
155
  /** Estimate request tokens from message bodies and serialized tool schemas. */
150
156
  export function estimatePromptTokens(messages, tools) {
151
157
  let chars = 0;
152
158
  try {
153
- chars += JSON.stringify(messages)?.length ?? 0;
159
+ // Counted as base64, one generated picture read as ~1.1M tokens: an
160
+ // image gezel's six renders and reads put the estimate past 6M while the
161
+ // engine prefilled 14K (qwen3.8-27b, 2026-11-04).
162
+ let images = 0;
163
+ const text = JSON.stringify(messages, (key, value) => {
164
+ if (key === 'images' && Array.isArray(value)) {
165
+ images += value.length;
166
+ return undefined;
167
+ }
168
+ return value;
169
+ });
170
+ chars += (text?.length ?? 0) + images * MLX_IMAGE_RESERVED_CHARS;
154
171
  }
155
172
  catch {
156
173
  /* circular or unstringifiable */
@@ -39,9 +39,10 @@ export declare function applyCallableToolGrammar(body: Record<string, unknown>,
39
39
  *
40
40
  * This deliberately checks presence only. A present-but-wrongly-typed value
41
41
  * remains the MCP validator's job; the MLX Hermes grammar promises only that
42
- * every declared required key is emitted before the call can close. Seeing a
43
- * missing key is therefore evidence that the live constrained-decoding path
44
- * broke its own contract, not an ordinary model validation miss.
42
+ * every declared required key is emitted before a constrained call can close.
43
+ * A missing key alone does not establish a decoder fault: calls salvaged from
44
+ * ordinary prose were not subject to that grammar. The caller must preserve
45
+ * their origin when deciding whether to disable speculative decoding.
45
46
  */
46
47
  export declare function missingTopLevelRequiredToolArgs(schema: Record<string, unknown> | undefined, args: Record<string, unknown>): string[];
47
48
  export declare function hermesRequiredArgGrammarRequested(body: Record<string, unknown>): boolean;
@@ -59,9 +59,10 @@ export function applyCallableToolGrammar(body, bridges) {
59
59
  *
60
60
  * This deliberately checks presence only. A present-but-wrongly-typed value
61
61
  * remains the MCP validator's job; the MLX Hermes grammar promises only that
62
- * every declared required key is emitted before the call can close. Seeing a
63
- * missing key is therefore evidence that the live constrained-decoding path
64
- * broke its own contract, not an ordinary model validation miss.
62
+ * every declared required key is emitted before a constrained call can close.
63
+ * A missing key alone does not establish a decoder fault: calls salvaged from
64
+ * ordinary prose were not subject to that grammar. The caller must preserve
65
+ * their origin when deciding whether to disable speculative decoding.
65
66
  */
66
67
  export function missingTopLevelRequiredToolArgs(schema, args) {
67
68
  const required = schema?.required;
@@ -78,9 +78,27 @@
78
78
  * instead of consuming the Metal working set. Do not treat the library pin
79
79
  * alone as proof that every community conversion is correct or fits.
80
80
  *
81
+ * 0.7.12 (2026-11-05): the project moved to Neywa-Labs/mlx-vlm; same PyPI
82
+ * name. It resolves mlx 0.32.4 (mlx-audio pins it exactly) and transformers
83
+ * 5.22. Every private `speculative.mtp` helper the assisted round loop calls
84
+ * is unchanged for Qwen 3.5; Gemma 4 targets now rebuild the drafter's shared
85
+ * KV from committed caches, which `shared_kv_after_round` follows. Its
86
+ * qwen3_coder parser keeps "null" as text in a string parameter, which
87
+ * `tool_args_json.py` now mirrors. Verified on an M5 Max against 0.7.1
88
+ * through the real sidecar: gemma4-e4b and qwen3.8-27b + MTP drafter render
89
+ * identical prompt token counts and identical tool-call, JSON and vision
90
+ * output; 0.7.1-written disk caches load and extend (Gemma 4 rotating,
91
+ * Qwen 3.5 hybrid). Greedy MTP matches greedy no-spec on code at 0-30k
92
+ * tokens; on prose both versions flip bf16 one-step ties (top-2 gap 0.125
93
+ * nats), which two non-speculative decode paths also disagree on, so prose
94
+ * is not an exactness probe. Clean A/B/B/A decode on qwen3.8-27b-q4: MTP on
95
+ * the vlm tower at parity to 10k and +19% at 50k; vlm tower without MTP 2.0x
96
+ * at 50k (7.3 -> 14.8 tok/s), now level with the mlx_lm tower; mlx_lm tower
97
+ * 0.94x at 0-10k, within run-to-run noise.
98
+ *
81
99
  * Keep upgrades explicit and re-sweep the MLX catalog whenever this pin
82
100
  * moves. */
83
- export declare const MLX_DEFAULT_PACKAGE_SPEC = "mlx-vlm==0.7.1";
101
+ export declare const MLX_DEFAULT_PACKAGE_SPEC = "mlx-vlm==0.7.12";
84
102
  /** Venv name passed to `UvRuntime.ensureVenv` (and its dir basename). */
85
103
  export declare const MLX_VENV_NAME = "mlx";
86
104
  /**
@@ -78,9 +78,27 @@
78
78
  * instead of consuming the Metal working set. Do not treat the library pin
79
79
  * alone as proof that every community conversion is correct or fits.
80
80
  *
81
+ * 0.7.12 (2026-11-05): the project moved to Neywa-Labs/mlx-vlm; same PyPI
82
+ * name. It resolves mlx 0.32.4 (mlx-audio pins it exactly) and transformers
83
+ * 5.22. Every private `speculative.mtp` helper the assisted round loop calls
84
+ * is unchanged for Qwen 3.5; Gemma 4 targets now rebuild the drafter's shared
85
+ * KV from committed caches, which `shared_kv_after_round` follows. Its
86
+ * qwen3_coder parser keeps "null" as text in a string parameter, which
87
+ * `tool_args_json.py` now mirrors. Verified on an M5 Max against 0.7.1
88
+ * through the real sidecar: gemma4-e4b and qwen3.8-27b + MTP drafter render
89
+ * identical prompt token counts and identical tool-call, JSON and vision
90
+ * output; 0.7.1-written disk caches load and extend (Gemma 4 rotating,
91
+ * Qwen 3.5 hybrid). Greedy MTP matches greedy no-spec on code at 0-30k
92
+ * tokens; on prose both versions flip bf16 one-step ties (top-2 gap 0.125
93
+ * nats), which two non-speculative decode paths also disagree on, so prose
94
+ * is not an exactness probe. Clean A/B/B/A decode on qwen3.8-27b-q4: MTP on
95
+ * the vlm tower at parity to 10k and +19% at 50k; vlm tower without MTP 2.0x
96
+ * at 50k (7.3 -> 14.8 tok/s), now level with the mlx_lm tower; mlx_lm tower
97
+ * 0.94x at 0-10k, within run-to-run noise.
98
+ *
81
99
  * Keep upgrades explicit and re-sweep the MLX catalog whenever this pin
82
100
  * moves. */
83
- export const MLX_DEFAULT_PACKAGE_SPEC = 'mlx-vlm==0.7.1';
101
+ export const MLX_DEFAULT_PACKAGE_SPEC = 'mlx-vlm==0.7.12';
84
102
  /** Venv name passed to `UvRuntime.ensureVenv` (and its dir basename). */
85
103
  export const MLX_VENV_NAME = 'mlx';
86
104
  /**
@@ -100,7 +118,8 @@ export function mlxVenvPackages(packageSpec) {
100
118
  // a dependency (0.6.6 did, which is the only reason the old list
101
119
  // worked). Dropping this pin kills the batch engine at import time.
102
120
  // 0.31.3 is also the combination used for the 0.7.1 Qwen4-Exp batch
103
- // compatibility check above; keep it independent of mlx-vlm's deps.
121
+ // compatibility check and the 0.7.12 verification above; keep it
122
+ // independent of mlx-vlm's deps.
104
123
  return [
105
124
  packageSpec ?? MLX_DEFAULT_PACKAGE_SPEC,
106
125
  'mlx-lm==0.31.3',
@@ -1,5 +1,6 @@
1
1
  import type { NativeCapacityCommand, NativeCapacityReply } from '@bendyline/gezel';
2
- import { readProcessIdentities } from './process-identity.js';
2
+ import { ProcessIdentityCache, processAlive, type readProcessIdentities } from './process-identity.js';
3
+ export { processAlive };
3
4
  export interface DeviceCapacitySample {
4
5
  budgetBytes: number;
5
6
  gpuBudgetBytes: number;
@@ -14,7 +15,6 @@ export interface DeviceCapacitySample {
14
15
  availableRamBytes?: number;
15
16
  serializeLoads: boolean;
16
17
  }
17
- export declare function processAlive(pid: number): boolean;
18
18
  /**
19
19
  * SQLite's process lock makes check-and-reserve atomic across GEZEL_HOME values.
20
20
  * Reservations survive a broker restart. A dead parent does not free the memory
@@ -22,11 +22,13 @@ export declare function processAlive(pid: number): boolean;
22
22
  */
23
23
  export declare class DeviceCapacityLedger {
24
24
  private readonly options;
25
+ private readonly identityCache;
25
26
  constructor(options: {
26
27
  directory: string;
27
28
  sample: () => Promise<DeviceCapacitySample>;
28
29
  alive?: (pid: number) => boolean;
29
30
  identities?: typeof readProcessIdentities;
31
+ identityCache?: ProcessIdentityCache;
30
32
  });
31
33
  execute(command: NativeCapacityCommand): Promise<NativeCapacityReply>;
32
34
  private transact;
@@ -2,7 +2,8 @@ import { chmod, mkdir } from 'node:fs/promises';
2
2
  import { createRequire } from 'node:module';
3
3
  import { join } from 'node:path';
4
4
  import { CapacityDeniedError } from './capacity-broker.js';
5
- import { readProcessIdentities } from './process-identity.js';
5
+ import { ProcessIdentityCache, processAlive, sharedProcessIdentities, } from './process-identity.js';
6
+ export { processAlive };
6
7
  // Keep the newer builtin behind a runtime require. The current tsup/esbuild
7
8
  // target rewrites a static `node:sqlite` import to the nonexistent npm package
8
9
  // `sqlite`, making the daemon bundle fail before it can write runtime state.
@@ -11,15 +12,6 @@ const { DatabaseSync } = nodeRequire('node:sqlite');
11
12
  function gb(bytes) {
12
13
  return `${(bytes / 1024 ** 3).toFixed(1)} GB`;
13
14
  }
14
- export function processAlive(pid) {
15
- try {
16
- process.kill(pid, 0);
17
- return true;
18
- }
19
- catch (error) {
20
- return error.code !== 'ESRCH';
21
- }
22
- }
23
15
  /**
24
16
  * SQLite's process lock makes check-and-reserve atomic across GEZEL_HOME values.
25
17
  * Reservations survive a broker restart. A dead parent does not free the memory
@@ -27,8 +19,18 @@ export function processAlive(pid) {
27
19
  */
28
20
  export class DeviceCapacityLedger {
29
21
  options;
22
+ identityCache;
30
23
  constructor(options) {
31
24
  this.options = options;
25
+ // An injected reader still goes through a cache, so tests see the reads
26
+ // production makes. An injected `alive` alone checks liveness only.
27
+ this.identityCache =
28
+ options.identityCache ??
29
+ (options.identities
30
+ ? new ProcessIdentityCache({ read: options.identities, alive: options.alive })
31
+ : options.alive
32
+ ? undefined
33
+ : sharedProcessIdentities);
32
34
  }
33
35
  async execute(command) {
34
36
  const sample = await this.options.sample();
@@ -43,18 +45,31 @@ export class DeviceCapacityLedger {
43
45
  .prepare('SELECT claim FROM claims')
44
46
  .all()
45
47
  .map((row) => JSON.parse(String(row.claim)));
46
- const pids = snapshot.flatMap((claim) => [
47
- claim.ownerPid,
48
- ...(claim.childPid === undefined ? [] : [claim.childPid]),
49
- ]);
50
- if (command.action === 'acquire')
51
- pids.push(command.ownerPid);
48
+ const record = [];
49
+ if (command.action === 'acquire' && !snapshot.some((claim) => claim.id === command.id))
50
+ record.push(command.ownerPid);
52
51
  if (command.action === 'bind')
53
- pids.push(command.childPid);
52
+ record.push(command.childPid);
53
+ // Stamps only decide which reservations are stale, and a stale one costs
54
+ // someone only while a request waits for memory. With no request
55
+ // waiting, liveness alone prunes, so the status check that runs before
56
+ // every engine request reads no stamps.
57
+ const confirm = new Map();
58
+ const stamp = (pid, identity) => {
59
+ if (pid !== undefined && identity !== undefined)
60
+ confirm.set(pid, [...(confirm.get(pid) ?? []), identity]);
61
+ };
62
+ if (command.action === 'acquire' || snapshot.some((claim) => claim.phase === 'waiting')) {
63
+ for (const claim of snapshot) {
64
+ stamp(claim.childPid, claim.childIdentity);
65
+ // An owner's stamp is only consulted while its claim is queued.
66
+ if (claim.phase === 'waiting')
67
+ stamp(claim.ownerPid, claim.ownerIdentity);
68
+ }
69
+ }
54
70
  // OS probes may spawn a helper. Never hold SQLite's writer lock while awaiting one.
55
- const probe = this.options.identities ?? (this.options.alive ? undefined : readProcessIdentities);
56
- const identities = command.action !== 'release' && probe
57
- ? await probe(pids).catch(() => new Map())
71
+ const identities = command.action !== 'release' && this.identityCache
72
+ ? await this.identityCache.lookup(record, confirm).catch(() => new Map())
58
73
  : new Map();
59
74
  db.exec('BEGIN IMMEDIATE');
60
75
  try {
@@ -77,6 +77,10 @@ export declare function staticCapacityCeilings(budget: Pick<CapacityBudget, 'bud
77
77
  budgetBytes: number;
78
78
  gpuBudgetBytes: number;
79
79
  };
80
+ /** The RAM ceiling the device ledger admits one launch under, for sizing a launch to fit it. */
81
+ export declare function deviceModelBudgetBytes(config: {
82
+ localEngineMemoryGb?: number | null | undefined;
83
+ }): Promise<number>;
80
84
  export declare function sampleDeviceCapacity(home?: string): Promise<DeviceCapacitySample>;
81
85
  export declare function localDeviceCapacity(home: string): DeviceCapacityLedger;
82
86
  /** Conservative fallback for media engines that do not expose a launch planner. */
@@ -155,6 +155,11 @@ export function staticCapacityCeilings(budget, ramBytes, configuredBytes, platfo
155
155
  : budget.fastBytes),
156
156
  };
157
157
  }
158
+ /** The RAM ceiling the device ledger admits one launch under, for sizing a launch to fit it. */
159
+ export async function deviceModelBudgetBytes(config) {
160
+ const ram = totalMemoryBytes();
161
+ return staticCapacityCeilings(await measuredCapacityBudget(), ram, configuredEngineMemoryBytes(config)).budgetBytes;
162
+ }
158
163
  export async function sampleDeviceCapacity(home) {
159
164
  const budget = await measuredCapacityBudget();
160
165
  const ram = totalMemoryBytes();
@@ -37,7 +37,7 @@ export declare class LocalEngineRuntime {
37
37
  /** Product chat may prefill its own sessions; the engine runtime never resolves them. */
38
38
  protected prefillCacheSession(_sessionId: string): Promise<boolean>;
39
39
  /** Cached provider instances keyed by provider name. */
40
- protected readonly providers: Map<"copilot" | "openai" | "anthropic" | "anthropic-cli" | "codex-cli" | "ollama" | "llama-cpp" | "mlx" | "ds4" | "apple-foundation-models" | "android-mlkit" | "remote", LLMProvider>;
40
+ protected readonly providers: Map<"copilot" | "openai" | "anthropic" | "anthropic-cli" | "codex-cli" | "ollama" | "llama-cpp" | "mlx" | "ds4" | "remote" | "apple-foundation-models" | "android-mlkit", LLMProvider>;
41
41
  protected readonly catalog: CatalogService;
42
42
  protected readonly llamaCppModels?: import('../llama-cpp/index.js').LlamaCppModelManager;
43
43
  protected readonly ds4Models?: import('../llama-cpp/index.js').LlamaCppModelManager;
@@ -384,6 +384,21 @@ export declare class LocalEngineRuntime {
384
384
  * the only guard against a window the engine cannot actually serve.
385
385
  */
386
386
  contextCeilingTokens?: number;
387
+ /** ds4 only: whether the launch would stream routed experts from SSD. */
388
+ ssdStreaming?: boolean;
389
+ /** ds4 only: `plannedResidentBytes` comes from the entry's measurement for this mode. */
390
+ memoryMeasured?: boolean;
391
+ /**
392
+ * ds4 only, streamed launches with a measured footprint: the expert cache
393
+ * the launcher would choose, so the model list's fit verdict comes from the
394
+ * same plan instead of a copy of its headroom rule.
395
+ */
396
+ expertCache?: {
397
+ cacheBytes: number;
398
+ requestedBytes: number;
399
+ clamped: boolean;
400
+ safe: boolean;
401
+ };
387
402
  }>;
388
403
  /**
389
404
  * Cancel a pending provider-queue entry by (provider, id). Resolves