@bendyline/gezel-inference 1.0.0 → 1.0.2

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 (116) hide show
  1. package/README.md +90 -8
  2. package/dist/cache/adapter.d.ts +7 -0
  3. package/dist/cache/controller.d.ts +12 -0
  4. package/dist/cache/controller.js +24 -0
  5. package/dist/catalog/chat-models/bt/btl4-35b-q4/manifest.json +1 -0
  6. package/dist/catalog/chat-models/gl/glm-5.2-754b-q2/manifest.json +1 -0
  7. package/dist/catalog/chat-models/index.json +1 -1
  8. package/dist/catalog/chat-models/lf/lfm2.5-24b-a2b-q4/manifest.json +122 -0
  9. package/dist/catalog/chat-models/lf/lfm2.5-24b-a2b-q4/versions/1.0.0/manifest.json +15 -0
  10. package/dist/catalog/chat-models/mi/minicpm-v4.7-35b-a3b-q4/manifest.json +185 -0
  11. package/dist/catalog/chat-models/mi/minicpm-v4.7-35b-a3b-q4/versions/1.0.0/manifest.json +94 -0
  12. package/dist/catalog/chat-models/mi/minicpm5-1b-q4/manifest.json +1 -0
  13. package/dist/catalog/chat-models/mi/minicpm5-2b-q4/manifest.json +1 -0
  14. package/dist/catalog/chat-models/qw/qwen3.5-0.8b-q4/manifest.json +1 -0
  15. package/dist/catalog/chat-models/raw-index.json +1 -1
  16. package/dist/catalog/provenance.json +1 -1
  17. package/dist/engines/electron-native-reuse.d.ts +9 -0
  18. package/dist/engines/electron-native-reuse.js +146 -45
  19. package/dist/engines/native-file-manifest.json +404 -404
  20. package/dist/engines/native-manifest.d.ts +11 -3
  21. package/dist/engines/native-manifest.js +48 -18
  22. package/dist/http/openai-compat/apple-model.d.ts +2 -0
  23. package/dist/http/openai-compat/apple-model.js +21 -1
  24. package/dist/http/openai-compat/chat-target.d.ts +12 -0
  25. package/dist/http/openai-compat/chat-target.js +27 -1
  26. package/dist/http/openai-compat/engine-failure.d.ts +25 -0
  27. package/dist/http/openai-compat/engine-failure.js +76 -0
  28. package/dist/http/openai-compat/responses-streaming.js +2 -1
  29. package/dist/http/openai-compat/responses-translate.d.ts +1 -1
  30. package/dist/http/openai-compat/translate.d.ts +1 -1
  31. package/dist/http/routes/engines.js +44 -3
  32. package/dist/http/routes/v1-chat.js +30 -8
  33. package/dist/http/routes/v1-models.d.ts +6 -0
  34. package/dist/http/routes/v1-models.js +137 -30
  35. package/dist/http/routes/v1-responses.js +16 -3
  36. package/dist/in-flight-requests.d.ts +19 -0
  37. package/dist/in-flight-requests.js +34 -0
  38. package/dist/inference-service.js +3 -3
  39. package/dist/knowledge/catalog-host.d.ts +9 -1
  40. package/dist/knowledge/catalog-host.js +2 -0
  41. package/dist/knowledge/manager.d.ts +14 -0
  42. package/dist/knowledge/manager.js +53 -6
  43. package/dist/knowledge/search-worker.d.ts +1 -1
  44. package/dist/knowledge/spatial-query.d.ts +2 -3
  45. package/dist/knowledge/spatial-query.js +12 -6
  46. package/dist/knowledge/worker-host.js +2 -0
  47. package/dist/memory/embed-core.d.ts +16 -0
  48. package/dist/memory/embed-core.js +31 -0
  49. package/dist/memory/embed-worker.d.ts +7 -3
  50. package/dist/memory/embed-worker.js +13 -4
  51. package/dist/memory/embeddings.d.ts +17 -2
  52. package/dist/memory/embeddings.js +36 -7
  53. package/dist/models/ensure.d.ts +2 -0
  54. package/dist/models/ensure.js +9 -0
  55. package/dist/perf/responsiveness.d.ts +14 -1
  56. package/dist/perf/responsiveness.js +77 -7
  57. package/dist/providers/llama-cpp/backend-health.d.ts +30 -0
  58. package/dist/providers/llama-cpp/backend-health.js +64 -0
  59. package/dist/providers/llama-cpp/backend-quarantine.d.ts +40 -0
  60. package/dist/providers/llama-cpp/backend-quarantine.js +100 -0
  61. package/dist/providers/llama-cpp/build-provider.js +140 -118
  62. package/dist/providers/llama-cpp/cache-adapter.d.ts +12 -0
  63. package/dist/providers/llama-cpp/cache-adapter.js +36 -1
  64. package/dist/providers/llama-cpp/devices.d.ts +11 -2
  65. package/dist/providers/llama-cpp/devices.js +10 -4
  66. package/dist/providers/llama-cpp/engine-flags.d.ts +0 -18
  67. package/dist/providers/llama-cpp/engine-flags.js +12 -18
  68. package/dist/providers/llama-cpp/gguf-metadata-async.js +7 -0
  69. package/dist/providers/llama-cpp/gguf-metadata.js +20 -3
  70. package/dist/providers/llama-cpp/gpu-startup-watch.d.ts +41 -0
  71. package/dist/providers/llama-cpp/gpu-startup-watch.js +58 -0
  72. package/dist/providers/llama-cpp/models.d.ts +6 -0
  73. package/dist/providers/llama-cpp/models.js +19 -1
  74. package/dist/providers/llama-cpp/offload-planner.d.ts +3 -18
  75. package/dist/providers/llama-cpp/offload-planner.js +3 -14
  76. package/dist/providers/llama-cpp/provider.d.ts +8 -0
  77. package/dist/providers/llama-cpp/provider.js +4 -1
  78. package/dist/providers/mlx/provider.js +14 -11
  79. package/dist/providers/mlx/python/batch_processors.py +26 -0
  80. package/dist/providers/mlx/python/gezel_mlx_server.py +88 -20
  81. package/dist/providers/mlx/python/qwen4_ple.py +9 -2
  82. package/dist/providers/mlx/python/response_format.py +374 -0
  83. package/dist/providers/mlx/python/spec_decode.py +5 -0
  84. package/dist/providers/mlx/python/think_budget.py +35 -1
  85. package/dist/providers/mlx/python/tool_grammar.py +62 -3
  86. package/dist/providers/mlx/reasoning-stream.d.ts +20 -2
  87. package/dist/providers/mlx/reasoning-stream.js +77 -16
  88. package/dist/providers/mlx/runtime-diagnostics.d.ts +16 -0
  89. package/dist/providers/mlx/runtime-diagnostics.js +29 -2
  90. package/dist/providers/native/capacity-broker.d.ts +51 -10
  91. package/dist/providers/native/capacity-broker.js +80 -9
  92. package/dist/providers/native/capacity-budget.d.ts +35 -3
  93. package/dist/providers/native/capacity-budget.js +49 -5
  94. package/dist/providers/native/device-capacity.d.ts +1 -1
  95. package/dist/providers/native/device-capacity.js +23 -5
  96. package/dist/providers/native/local-engine-runtime.js +15 -13
  97. package/dist/providers/native/measured-budget.js +2 -0
  98. package/dist/providers/native/supervisor.d.ts +25 -0
  99. package/dist/providers/native/supervisor.js +59 -7
  100. package/dist/remotes/identity.d.ts +1 -0
  101. package/dist/remotes/identity.js +6 -0
  102. package/dist/remotes/pinned-fetch.d.ts +2 -0
  103. package/dist/remotes/pinned-fetch.js +30 -1
  104. package/dist/remotes/types.d.ts +14 -0
  105. package/dist/runtime-lock.js +1 -1
  106. package/dist/system/amdgpu-adapters.d.ts +18 -0
  107. package/dist/system/amdgpu-adapters.js +55 -0
  108. package/dist/system/gpu-carve-out.d.ts +84 -0
  109. package/dist/system/gpu-carve-out.js +111 -0
  110. package/dist/system/memory.d.ts +17 -1
  111. package/dist/system/memory.js +105 -14
  112. package/dist/transformers-cache.d.ts +2 -0
  113. package/dist/transformers-cache.js +6 -3
  114. package/dist/utils/service-worker-entry.d.ts +4 -0
  115. package/dist/utils/service-worker-entry.js +4 -0
  116. package/package.json +14 -10
@@ -1,6 +1,9 @@
1
- import { externalGezelModelId, isRecommendedModel, resolveDistributionProfile, } from '@bendyline/gezel';
1
+ import { estimateManifestKvBytes, externalGezelModelId, isRecommendedModel, llamaCppBackendExclusion, localContextFloorTokens, rateModelsForDevice, resolveDistributionProfile, } from '@bendyline/gezel';
2
2
  import { resolveOnDeviceProvider } from '@bendyline/gezel/native';
3
3
  import { Hono } from 'hono';
4
+ import { currentLlamaCppBackend } from '../../providers/llama-cpp/backend-health.js';
5
+ import { CapacityBroker } from '../../providers/native/capacity-broker.js';
6
+ import { detectMemoryProfileCached } from '../../system/memory.js';
4
7
  import { appleSystemModel } from '../openai-compat/apple-model.js';
5
8
  import { resolveFallbackGezelId } from '../openai-compat/chat-target.js';
6
9
  /**
@@ -30,34 +33,110 @@ const PROVIDERS_TO_ENUMERATE = [
30
33
  'ds4',
31
34
  ];
32
35
  const LOCAL_PROVIDERS = ['llama-cpp', 'mlx', 'ds4'];
36
+ /**
37
+ * Engines that enforce `response_format` with a grammar: llama-server's
38
+ * `json_schema`, and the MLX sidecar's llguidance JSON grammar
39
+ * (`providers/mlx/python/response_format.py`). ds4 shares llama.cpp's request
40
+ * map, but nothing here shows its server enforces a schema, so it is not
41
+ * advertised.
42
+ */
43
+ function enforcesResponseFormat(provider) {
44
+ return provider === 'llama-cpp' || provider === 'mlx';
45
+ }
46
+ function catalogCapabilities(manifest, provider) {
47
+ return {
48
+ text: true,
49
+ tools: manifest.supportsTools,
50
+ structuredOutput: enforcesResponseFormat(provider),
51
+ images: false,
52
+ foregroundOnly: false,
53
+ };
54
+ }
33
55
  function catalogBackend(manifest) {
56
+ // ds4 has no portable fallback: `discoverNativeBinaries` stamps its binary
57
+ // only where a build for this host is on disk, which is the gate the
58
+ // Settings availability check reads too. Windows has none.
59
+ const ds4 = manifest.ds4 && process.env.GEZEL_DS4_SERVER_BIN ? 'ds4' : null;
34
60
  // Store hosts have no provisioned Python runtime. Only advertise models
35
61
  // backed by bundled native engines until a frozen MLX runtime ships.
36
62
  const store = resolveDistributionProfile().pythonProvisioning === 'frozen-only';
63
+ const llamaCpp = manifest.llamaCpp && !llamaCppBackendExclusion(manifest.llamaCpp, currentLlamaCppBackend());
37
64
  if (store)
38
- return manifest.llamaCpp ? 'llama-cpp' : manifest.ds4 ? 'ds4' : null;
65
+ return llamaCpp ? 'llama-cpp' : ds4;
39
66
  const preferred = resolveOnDeviceProvider(process.platform, process.arch);
40
67
  if (preferred === 'mlx' && manifest.mlx && !manifest.mlx.disabledReason)
41
68
  return 'mlx';
42
- if (preferred === 'llama-cpp' && manifest.llamaCpp)
69
+ if (preferred === 'llama-cpp' && llamaCpp)
43
70
  return 'llama-cpp';
44
- if (manifest.llamaCpp)
71
+ if (llamaCpp)
45
72
  return 'llama-cpp';
46
73
  if (manifest.mlx && !manifest.mlx.disabledReason && process.platform === 'darwin')
47
74
  return 'mlx';
48
- if (manifest.ds4)
49
- return 'ds4';
50
- return null;
75
+ return ds4;
76
+ }
77
+ /**
78
+ * What loading a catalog model would hold, priced as the capacity broker
79
+ * prices it: the catalog's measured pin when it has one, else the engine's
80
+ * estimate, plus KV at the context floor this machine launches with. ds4's
81
+ * figure already bounds its streamed working set, so it takes no extra KV.
82
+ */
83
+ function footprintBytes({ manifest, provider, source }, memory) {
84
+ const mmprojBytes = provider === 'llama-cpp' ? manifest.llamaCpp?.mmproj?.sizeBytes : undefined;
85
+ const weights = source.residentBytes ??
86
+ CapacityBroker.estimateResidentBytes(provider, source.approxSizeBytes, mmprojBytes ? { mmprojBytes } : undefined);
87
+ if (provider === 'ds4')
88
+ return weights;
89
+ const ctxTokens = localContextFloorTokens({
90
+ totalRamBytes: memory.totalRamBytes,
91
+ gpuVramBytes: fastGpuBytes(memory),
92
+ });
93
+ // MLX keeps its cache in f16; llama.cpp's default q8_0 is the helper's default.
94
+ return (weights +
95
+ estimateManifestKvBytes(manifest, {
96
+ ctxTokens,
97
+ ...(provider === 'mlx' ? { cacheScale: 1 } : {}),
98
+ }));
99
+ }
100
+ /**
101
+ * An integrated adapter's advertised allocation is shared system RAM, not a
102
+ * fast pool beside it, so fit treats that host as RAM-paced (as Settings does).
103
+ */
104
+ function fastGpuBytes(memory) {
105
+ return memory.gpuMemoryKind === 'integrated' ? null : memory.gpuVramBytes;
106
+ }
107
+ function recoDevice(memory) {
108
+ return {
109
+ platform: memory.platform,
110
+ gpuVramBytes: fastGpuBytes(memory),
111
+ gpuMemoryKind: memory.gpuMemoryKind,
112
+ totalRamBytes: memory.totalRamBytes,
113
+ usableBytes: memory.usableBytes,
114
+ budgetBytes: memory.budgetBytes,
115
+ };
51
116
  }
52
- async function downloadableCatalogEntries(ctx, created, existingIds) {
53
- const items = await ctx.catalog.list('chat-model').catch(() => []);
54
- return items
55
- .flatMap((item) => {
117
+ /** Curated order, for when this machine's memory could not be read. */
118
+ function byCuratedScore(a, b) {
119
+ const aRecommended = isRecommendedModel(a.manifest);
120
+ const bRecommended = isRecommendedModel(b.manifest);
121
+ if (aRecommended !== bRecommended)
122
+ return aRecommended ? -1 : 1;
123
+ const score = (b.manifest.recoScore ?? 0) - (a.manifest.recoScore ?? 0);
124
+ return score || (a.entry.name ?? a.entry.id).localeCompare(b.entry.name ?? b.entry.id);
125
+ }
126
+ /**
127
+ * Downloadable models, each rated against this machine (`fit`,
128
+ * `recommendation`) and listed best first. A model too big to run here stays
129
+ * listed with `fit.tier: 'too-big'` and a 0 score, so a client decides
130
+ * whether to hide it.
131
+ */
132
+ async function downloadableCatalogEntries(items, created, existingIds, memoryProfile) {
133
+ const candidates = items.flatMap((item) => {
56
134
  const manifest = item.manifest;
57
135
  if (manifest.kind !== 'chat-model')
58
136
  return [];
59
137
  const provider = catalogBackend(manifest);
60
- if (!provider)
138
+ const source = provider === 'mlx' ? manifest.mlx : provider === 'ds4' ? manifest.ds4 : manifest.llamaCpp;
139
+ if (!provider || !source)
61
140
  return [];
62
141
  const id = `${provider}:${manifest.id}`;
63
142
  if (existingIds.has(id))
@@ -65,6 +144,8 @@ async function downloadableCatalogEntries(ctx, created, existingIds) {
65
144
  return [
66
145
  {
67
146
  manifest,
147
+ provider,
148
+ source,
68
149
  entry: {
69
150
  id,
70
151
  object: 'model',
@@ -78,26 +159,41 @@ async function downloadableCatalogEntries(ctx, created, existingIds) {
78
159
  locality: 'on-device',
79
160
  download_bytes: manifest.approxSizeBytes,
80
161
  ...(manifest.contextWindow ? { context_window: manifest.contextWindow } : {}),
81
- capabilities: {
82
- text: true,
83
- tools: manifest.supportsTools,
84
- structuredOutput: false,
85
- images: false,
86
- foregroundOnly: false,
87
- },
162
+ capabilities: catalogCapabilities(manifest, provider),
88
163
  },
89
164
  },
90
165
  ];
91
- })
92
- .sort((a, b) => {
93
- const aRecommended = isRecommendedModel(a.manifest);
94
- const bRecommended = isRecommendedModel(b.manifest);
95
- if (aRecommended !== bRecommended)
96
- return aRecommended ? -1 : 1;
97
- const score = (b.manifest.recoScore ?? 0) - (a.manifest.recoScore ?? 0);
98
- return score || (a.entry.name ?? a.entry.id).localeCompare(b.entry.name ?? b.entry.id);
99
- })
100
- .map(({ entry }) => entry);
166
+ });
167
+ if (candidates.length === 0)
168
+ return [];
169
+ const memory = await memoryProfile().catch(() => null);
170
+ if (!memory)
171
+ return candidates.sort(byCuratedScore).map(({ entry }) => entry);
172
+ const rated = candidates.map((candidate) => {
173
+ const { entry, manifest, source } = candidate;
174
+ return {
175
+ id: entry.id,
176
+ residentBytes: footprintBytes(candidate, memory),
177
+ tags: manifest.tags,
178
+ supportsTools: manifest.supportsTools,
179
+ ...(manifest.recoScore !== undefined ? { recoScore: manifest.recoScore } : {}),
180
+ ...(manifest.licenseClass ? { licenseClass: manifest.licenseClass } : {}),
181
+ ...(source.quantization ? { quantization: source.quantization } : {}),
182
+ };
183
+ });
184
+ const entries = new Map(candidates.map(({ entry }) => [entry.id, entry]));
185
+ return rateModelsForDevice(rated, recoDevice(memory)).flatMap((rating) => {
186
+ const entry = entries.get(rating.id);
187
+ if (!entry)
188
+ return [];
189
+ return [
190
+ {
191
+ ...entry,
192
+ fit: { tier: rating.fit.tier, label: rating.fit.label, detail: rating.fit.detail },
193
+ recommendation: { score: rating.score, recommended: rating.recommended },
194
+ },
195
+ ];
196
+ });
101
197
  }
102
198
  async function buildModelEntries(ctx, created, options) {
103
199
  // Advertise every gezel as a selectable OpenAI "model". Keep the
@@ -128,6 +224,14 @@ async function buildModelEntries(ctx, created, options) {
128
224
  ...(gezel.id === fallbackGezelId ? { is_fallback: true } : {}),
129
225
  }));
130
226
  }
227
+ const catalogItems = await ctx.catalog.list('chat-model').catch(() => []);
228
+ // An installed model carries the capabilities its catalog entry
229
+ // advertised before download; a model with no entry stays unflagged
230
+ // rather than guessing its tools or images support.
231
+ const manifests = new Map();
232
+ for (const item of catalogItems)
233
+ if (item.manifest.kind === 'chat-model')
234
+ manifests.set(item.manifest.id, item.manifest);
131
235
  const providers = (options.localOnly ? LOCAL_PROVIDERS : PROVIDERS_TO_ENUMERATE).filter((provider) => provider !== 'mlx' || resolveDistributionProfile().pythonProvisioning !== 'frozen-only');
132
236
  const buckets = await Promise.all(providers.map(async (provider) => {
133
237
  try {
@@ -146,6 +250,9 @@ async function buildModelEntries(ctx, created, options) {
146
250
  : {}),
147
251
  ...(m.contextWindow ? { context_window: m.contextWindow } : {}),
148
252
  ...(m.supportsReasoning ? { supports_reasoning: true } : {}),
253
+ ...(enforcesResponseFormat(provider) && manifests.has(m.id)
254
+ ? { capabilities: catalogCapabilities(manifests.get(m.id), provider) }
255
+ : {}),
149
256
  }));
150
257
  }
151
258
  catch {
@@ -156,7 +263,7 @@ async function buildModelEntries(ctx, created, options) {
156
263
  }));
157
264
  const availableEntries = buckets.flat();
158
265
  const existingIds = new Set([...gezelEntries, ...availableEntries].map((entry) => entry.id));
159
- const downloadable = await downloadableCatalogEntries(ctx, created, existingIds);
266
+ const downloadable = await downloadableCatalogEntries(catalogItems, created, existingIds, options.memoryProfile ?? (() => detectMemoryProfileCached()));
160
267
  const apple = await appleSystemModel(created);
161
268
  return [...gezelEntries, ...apple, ...availableEntries, ...downloadable];
162
269
  }
@@ -3,10 +3,12 @@ import { Hono } from 'hono';
3
3
  import { bodyLimit } from 'hono/body-limit';
4
4
  import { streamSSE } from 'hono/streaming';
5
5
  import { ZodError } from 'zod';
6
+ import { UnsupportedResponseFormatError } from '../../providers/mlx/runtime-diagnostics.js';
6
7
  import { ExternalToolsUnsupportedError, ModelNotInstalledError, } from '../../providers/types.js';
7
8
  import { appendCallerOwnedActionLedger } from '../openai-compat/action-ledger.js';
8
9
  import { profileForCallerOwnedInference } from '../openai-compat/caller-owned-profile.js';
9
- import { resolveChatTarget } from '../openai-compat/chat-target.js';
10
+ import { resolveChatTarget, resolvePinnedModelTarget, } from '../openai-compat/chat-target.js';
11
+ import { engineFailure } from '../openai-compat/engine-failure.js';
10
12
  import { runResponsesNonStreaming, runResponsesStreaming, } from '../openai-compat/responses-streaming.js';
11
13
  import { ResponsesRequestSchema, translateResponsesRequest, } from '../openai-compat/responses-translate.js';
12
14
  import { flattenTranscriptIntoPrompt, parseGezelModelRef, resolveModelTarget, } from '../openai-compat/translate.js';
@@ -220,7 +222,7 @@ export function v1ResponsesRoutes(ctx, opts = {}) {
220
222
  event: 'error',
221
223
  data: JSON.stringify({
222
224
  type: 'error',
223
- code: 'provider_error',
225
+ code: engineFailure(err).code,
224
226
  message: 'The response stream ended unexpectedly.',
225
227
  param: null,
226
228
  }),
@@ -259,7 +261,15 @@ export function v1ResponsesRoutes(ctx, opts = {}) {
259
261
  if (err instanceof ExternalToolsUnsupportedError) {
260
262
  return invalidRequest(c, err.message, 'tools_not_supported_for_provider');
261
263
  }
264
+ if (err instanceof UnsupportedResponseFormatError) {
265
+ return invalidRequest(c, err.message, err.code);
266
+ }
262
267
  const message = err instanceof Error ? err.message : String(err);
268
+ const failure = engineFailure(err);
269
+ if (failure.code !== 'provider_error') {
270
+ log.warn(`Responses request failed (${failure.code}): ${message}`);
271
+ return c.json({ error: { message, type: failure.type, code: failure.code } }, failure.status);
272
+ }
263
273
  if (message.includes('messages must') ||
264
274
  message.includes('empty') ||
265
275
  message.includes('cannot be an assistant turn')) {
@@ -284,7 +294,10 @@ async function resolveResponsesTarget(model, ctx) {
284
294
  systemPrefix: '',
285
295
  };
286
296
  }
287
- throw new Error(`Unknown model "${model}". Use an explicit <provider>:<model> id or advertised gezel:<role>-<name> alias.`);
297
+ const pinned = await resolvePinnedModelTarget(ctx, model);
298
+ if (pinned)
299
+ return pinned;
300
+ throw new Error(`Unknown model "${model}". Use an explicit <provider>:<model> id, a model pinned as a default, or an advertised gezel:<role>-<name> alias.`);
288
301
  }
289
302
  function prependSystemPrefix(input, prefix) {
290
303
  if (!prefix)
@@ -0,0 +1,19 @@
1
+ /**
2
+ * The in-process transport's open requests, and the one handle that keeps Node
3
+ * running while any of them is open.
4
+ *
5
+ * The direct transport has no socket, so nothing a request awaits is
6
+ * guaranteed to hold the event loop: engine supervisors and workers unref their
7
+ * timers and threads so that an idle host never blocks exit. A plain Node
8
+ * script exited with code 13 ("unsettled top-level await") inside `chat()`
9
+ * while llama-server cold-started; Electron's own handles had hidden it.
10
+ */
11
+ export declare class InFlightRequests {
12
+ private readonly open;
13
+ private keepAlive;
14
+ get size(): number;
15
+ add(controller: AbortController): void;
16
+ delete(controller: AbortController): void;
17
+ /** Abort and forget every open request, including a body nobody will read. */
18
+ abortAll(): void;
19
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * The in-process transport's open requests, and the one handle that keeps Node
3
+ * running while any of them is open.
4
+ *
5
+ * The direct transport has no socket, so nothing a request awaits is
6
+ * guaranteed to hold the event loop: engine supervisors and workers unref their
7
+ * timers and threads so that an idle host never blocks exit. A plain Node
8
+ * script exited with code 13 ("unsettled top-level await") inside `chat()`
9
+ * while llama-server cold-started; Electron's own handles had hidden it.
10
+ */
11
+ export class InFlightRequests {
12
+ open = new Set();
13
+ keepAlive;
14
+ get size() {
15
+ return this.open.size;
16
+ }
17
+ add(controller) {
18
+ this.open.add(controller);
19
+ this.keepAlive ??= setInterval(() => { }, 2 ** 30);
20
+ }
21
+ delete(controller) {
22
+ if (!this.open.delete(controller) || this.open.size > 0)
23
+ return;
24
+ clearInterval(this.keepAlive);
25
+ this.keepAlive = undefined;
26
+ }
27
+ /** Abort and forget every open request, including a body nobody will read. */
28
+ abortAll() {
29
+ for (const controller of [...this.open]) {
30
+ controller.abort();
31
+ this.delete(controller);
32
+ }
33
+ }
34
+ }
@@ -18,6 +18,7 @@ import { v1KnowledgeRoutes } from './http/routes/v1-knowledge.js';
18
18
  import { v1ModelsEnsureRoutes } from './http/routes/v1-models-ensure.js';
19
19
  import { v1ModelsRoutes } from './http/routes/v1-models.js';
20
20
  import { v1ResponsesRoutes } from './http/routes/v1-responses.js';
21
+ import { InFlightRequests } from './in-flight-requests.js';
21
22
  import { KnowledgeManager } from './knowledge/manager.js';
22
23
  import { createWorkerCatalogHost } from './knowledge/worker-host.js';
23
24
  import { embeddingsHealth, openEmbeddings, shutdownEmbeddings } from './memory/embeddings.js';
@@ -44,14 +45,13 @@ export async function startService(options = {}) {
44
45
  const runtimeDir = join(home, 'runtime');
45
46
  const lock = await acquireSingleInstanceLock({ runtimeDir, lockPath: join(runtimeDir, 'lock') });
46
47
  const cleanup = [() => lock.release()];
47
- const requests = new Set();
48
+ const requests = new InFlightRequests();
48
49
  let stopping;
49
50
  let closed = false;
50
51
  const stop = () => {
51
52
  stopping ??= (async () => {
52
53
  closed = true;
53
- for (const controller of requests)
54
- controller.abort();
54
+ requests.abortAll();
55
55
  const errors = [];
56
56
  for (const close of cleanup.reverse())
57
57
  try {
@@ -7,7 +7,7 @@
7
7
  * (docs/gezk-format.md). The in-process implementation is test/CLI-only;
8
8
  * production fails closed if its worker becomes unavailable.
9
9
  */
10
- import type { CatalogAssetFile, CatalogAssetInfo, CatalogAssetRead, CatalogChunkHit, CatalogDocumentMeta, CatalogTopic, CatalogValidationReport } from '@bendyline/gezel-knowledge';
10
+ import type { CatalogAssetFile, CatalogAssetInfo, CatalogAssetRead, CatalogChunkHit, CatalogDocumentInitial, CatalogDocumentMeta, CatalogTopic, CatalogValidationReport } from '@bendyline/gezel-knowledge';
11
11
  import type { KnowledgeRadius } from '@bendyline/gezel';
12
12
  import type { SpatialMatch } from '@bendyline/gezel-knowledge';
13
13
  export interface MountSpec {
@@ -78,6 +78,14 @@ export interface KnowledgeCatalogHost {
78
78
  documents: CatalogDocumentMeta[];
79
79
  total: number;
80
80
  }>;
81
+ documentInitials(key: string, opts: {
82
+ topicId?: string;
83
+ descendants?: boolean;
84
+ }): Promise<CatalogDocumentInitial[]>;
85
+ documentPosition(key: string, documentId: string, opts: {
86
+ topicId?: string;
87
+ descendants?: boolean;
88
+ }): Promise<number | null>;
81
89
  nearbyDocuments(key: string, radius: KnowledgeRadius, opts: {
82
90
  offset?: number;
83
91
  limit?: number;
@@ -158,6 +158,8 @@ export async function createInProcessCatalogHost() {
158
158
  validate: async (rootDir, deep) => validateExtractedCatalog(rootDir, { deep }),
159
159
  topics: async (key) => mustGet(handles, key).topics(),
160
160
  documentsPage: async (key, opts) => mustGet(handles, key).documentsPage(opts),
161
+ documentInitials: async (key, opts) => mustGet(handles, key).documentInitials(opts),
162
+ documentPosition: async (key, documentId, opts) => mustGet(handles, key).documentPosition(documentId, opts),
161
163
  nearbyDocuments: async (key, radius, opts) => mustGet(handles, key).nearbyDocuments(radius, opts),
162
164
  getDocument: async (key, documentId) => mustGet(handles, key).getDocument(documentId),
163
165
  getChunk: async (key, documentId, chunkUid) => mustGet(handles, key).getChunk(documentId, chunkUid),
@@ -121,6 +121,12 @@ export declare class KnowledgeManager {
121
121
  * what the Knowledge screen's browser renders. Offline-safe: gilde is local.
122
122
  */
123
123
  available(): Promise<KnowledgeAvailableCatalog[]>;
124
+ /**
125
+ * The search model an install of a catalog with this profile would also
126
+ * download. None when the catalog searches with the daemon's own embedder
127
+ * (an install never fetches that) or the profile's pinned files are cached.
128
+ */
129
+ private searchModelDownloadBytes;
124
130
  /** Resolve a catalogId (URI authority) to its mounted info, or null. */
125
131
  mountedCatalog(catalogId: string): MountedInfo | null;
126
132
  /**
@@ -206,6 +212,14 @@ export declare class KnowledgeManager {
206
212
  limit?: number;
207
213
  descendants?: boolean;
208
214
  }): ReturnType<KnowledgeCatalogHost['documentsPage']>;
215
+ documentInitials(catalogId: string, opts: {
216
+ topicId?: string;
217
+ descendants?: boolean;
218
+ }): ReturnType<KnowledgeCatalogHost['documentInitials']>;
219
+ documentPosition(catalogId: string, documentId: string, opts: {
220
+ topicId?: string;
221
+ descendants?: boolean;
222
+ }): ReturnType<KnowledgeCatalogHost['documentPosition']>;
209
223
  getDocument(catalogId: string, documentId: string): ReturnType<KnowledgeCatalogHost['getDocument']>;
210
224
  /** One passage by its citation id, with the document's title and origin. */
211
225
  getPassage(catalogId: string, documentId: string, chunkUid: string): Promise<KnowledgePassageRead | null>;
@@ -13,7 +13,7 @@ import { readFile, readdir, rm, stat } from 'node:fs/promises';
13
13
  import { dirname, join, resolve } from 'node:path';
14
14
  import { fileURLToPath } from 'node:url';
15
15
  import { KnowledgeMachineInventorySchema, KnowledgeNearbyRequestSchema, createLogger, formatKnowledgeUri, parseKnowledgeUri, resolveSecurityPolicy, sameVectorSpace, } from '@bendyline/gezel';
16
- import { compareCatalogVersions, knowledgeEmbeddingProfile, readGezkManifest, } from '@bendyline/gezel-knowledge';
16
+ import { compareCatalogVersions, knowledgeEmbeddingProfile, knowledgeProfileQueryModelBytes, missingPinnedFiles, readGezkManifest, } from '@bendyline/gezel-knowledge';
17
17
  import { knowledgeCatalogVersionDir, knowledgeCatalogsDir, knowledgeDownloadsDir, } from '@bendyline/gezel/paths';
18
18
  import { HANDBOEK_KNOWLEDGE_CATALOG, HANDBOEK_KNOWLEDGE_PUBLISHER } from './bundled-catalog.js';
19
19
  import { scoreResult } from '@bendyline/gezel';
@@ -21,10 +21,11 @@ import { sharedKnowledgeRoot, sharedKnowledgeVersionDir, } from '../machine-engi
21
21
  import { embedKnowledgeQuery, sharesDaemonEmbedder } from '../memory/embeddings.js';
22
22
  import { ChatModelInstallRegistry } from '../models/install-registry.js';
23
23
  import { beginPerfWork } from '../perf/responsiveness.js';
24
+ import { HF_CACHE_DIR_ENV, transformersCacheDir } from '../transformers-cache.js';
24
25
  import { resolveKnowledgeCatalogSource } from './catalog-source.js';
25
26
  import { KNOWLEDGE_DOWNLOAD_KEY_PATTERN, installKnowledgeCatalog, knowledgeDownloadKey, pruneOtherKnowledgeCatalogVersions, } from './install.js';
26
27
  import { KnowledgeRegistry } from './registry.js';
27
- import { KnowledgeSpatialCursorError, encodeNearbyCursor, nearbyCursorOffset, regionalDocumentIdentity, } from './spatial-query.js';
28
+ import { KnowledgeSpatialCursorError, encodeNearbyCursor, nearbyCursorOffset, spatialDocumentIdentity, } from './spatial-query.js';
28
29
  import { resolveKnowledgeVectorFloors } from './vector-floors.js';
29
30
  const log = createLogger('knowledge');
30
31
  /** Explicit-search shard budget (S) across every active catalog (§3.4). */
@@ -59,6 +60,9 @@ function semanticSearchModeFor(profile) {
59
60
  return 'profile';
60
61
  return 'keyword-only';
61
62
  }
63
+ function searchModelOf(profile) {
64
+ return { profileId: profile.id, repo: profile.model.repo };
65
+ }
62
66
  /**
63
67
  * Explicit search fuses three arms per catalog by reciprocal rank (k = 60):
64
68
  * the vector chunks (best chunk per document, by rerank cosine), the
@@ -409,10 +413,18 @@ export class KnowledgeManager {
409
413
  const shared = await this.sharedInventoryKeys();
410
414
  const activeIds = new Set(this.installs.active().map((a) => a.id));
411
415
  const partials = await this.partialDownloadKeys();
416
+ const modelBytes = new Map();
417
+ for (const item of gilde.values()) {
418
+ const profileId = item.embeddingProfile.id;
419
+ if (!modelBytes.has(profileId)) {
420
+ modelBytes.set(profileId, await this.searchModelDownloadBytes(profileId));
421
+ }
422
+ }
412
423
  return [...gilde.values()]
413
424
  .sort((a, b) => a.name.localeCompare(b.name))
414
425
  .map((item) => {
415
426
  const entry = registry.find((c) => c.ref.publisherId === item.publisherId && c.ref.catalogId === item.id);
427
+ const searchModelDownloadBytes = modelBytes.get(item.embeddingProfile.id);
416
428
  return {
417
429
  id: item.id,
418
430
  publisherId: item.publisherId,
@@ -451,9 +463,24 @@ export class KnowledgeManager {
451
463
  sharedOnDevice: shared.has(`${item.publisherId}/${item.id}/${item.version}/${item.sha256}`),
452
464
  installing: activeIds.has(item.id),
453
465
  incompleteDownload: partials.has(item.sha256.slice(0, 16)),
466
+ ...(searchModelDownloadBytes ? { searchModelDownloadBytes } : {}),
454
467
  };
455
468
  });
456
469
  }
470
+ /**
471
+ * The search model an install of a catalog with this profile would also
472
+ * download. None when the catalog searches with the daemon's own embedder
473
+ * (an install never fetches that) or the profile's pinned files are cached.
474
+ */
475
+ async searchModelDownloadBytes(profileId) {
476
+ const profile = knowledgeEmbeddingProfile(profileId);
477
+ const bytes = knowledgeProfileQueryModelBytes(profileId);
478
+ if (!profile || bytes === null || semanticSearchModeFor(profile) !== 'profile')
479
+ return undefined;
480
+ const cacheDir = process.env[HF_CACHE_DIR_ENV] ?? transformersCacheDir(this.opts.home);
481
+ const missing = await missingPinnedFiles(profile, { cacheDir });
482
+ return missing.length > 0 ? bytes : undefined;
483
+ }
457
484
  /** Resolve a catalogId (URI authority) to its mounted info, or null. */
458
485
  mountedCatalog(catalogId) {
459
486
  for (const info of this.mountedByKey.values()) {
@@ -676,7 +703,13 @@ export class KnowledgeManager {
676
703
  let warning;
677
704
  const mounted = this.mountedByKey.get(key);
678
705
  if (mounted?.semanticSearch === 'profile') {
679
- yield { type: 'progress', phase: 'embedder', bytesDone: 0, bytesTotal: 0 };
706
+ yield {
707
+ type: 'progress',
708
+ phase: 'embedder',
709
+ bytesDone: 0,
710
+ bytesTotal: 0,
711
+ searchModel: searchModelOf(mounted.embedding),
712
+ };
680
713
  warning = yield* this.prewarmProfile(mounted.embedding);
681
714
  }
682
715
  yield { ...event, ...(warning ? { warning } : {}) };
@@ -746,7 +779,12 @@ export class KnowledgeManager {
746
779
  (settled || Date.now() - lastReportAt >= EMBEDDER_PROGRESS_INTERVAL_MS)) {
747
780
  progress.reported = latest;
748
781
  lastReportAt = Date.now();
749
- yield { type: 'progress', phase: 'embedder', ...latest };
782
+ yield {
783
+ type: 'progress',
784
+ phase: 'embedder',
785
+ ...latest,
786
+ searchModel: searchModelOf(profile),
787
+ };
750
788
  continue;
751
789
  }
752
790
  if (settled)
@@ -802,6 +840,9 @@ export class KnowledgeManager {
802
840
  phase,
803
841
  bytesDone: lastEvent?.type === 'progress' ? lastEvent.bytesDone : 0,
804
842
  bytesTotal: lastEvent?.type === 'progress' ? lastEvent.bytesTotal : 0,
843
+ ...(lastEvent?.type === 'progress' && lastEvent.searchModel
844
+ ? { searchModel: lastEvent.searchModel }
845
+ : {}),
805
846
  };
806
847
  });
807
848
  }
@@ -1035,6 +1076,12 @@ export class KnowledgeManager {
1035
1076
  async documentsPage(catalogId, opts) {
1036
1077
  return this.opts.host.documentsPage(this.requireMounted(catalogId).key, opts);
1037
1078
  }
1079
+ async documentInitials(catalogId, opts) {
1080
+ return this.opts.host.documentInitials(this.requireMounted(catalogId).key, opts);
1081
+ }
1082
+ async documentPosition(catalogId, documentId, opts) {
1083
+ return this.opts.host.documentPosition(this.requireMounted(catalogId).key, documentId, opts);
1084
+ }
1038
1085
  async getDocument(catalogId, documentId) {
1039
1086
  return this.opts.host.getDocument(this.requireMounted(catalogId).key, documentId);
1040
1087
  }
@@ -1145,7 +1192,7 @@ export class KnowledgeManager {
1145
1192
  documentId: document.id,
1146
1193
  }),
1147
1194
  };
1148
- const identity = regionalDocumentIdentity(info.ref.publisherId, info.ref.catalogId, document.id);
1195
+ const identity = spatialDocumentIdentity(info.ref.publisherId, info.ref.catalogId, document.id);
1149
1196
  const previous = unique.get(identity);
1150
1197
  if (!previous || result.distanceMeters < previous.distanceMeters)
1151
1198
  unique.set(identity, result);
@@ -1383,7 +1430,7 @@ export class KnowledgeManager {
1383
1430
  const count = perCatalogCount.get(entry.catalogKey) ?? 0;
1384
1431
  if (capPerCatalog && count >= PER_CATALOG_CAP)
1385
1432
  continue;
1386
- const spatialIdentity = regionalDocumentIdentity(info.ref.publisherId, info.ref.catalogId, entry.documentId);
1433
+ const spatialIdentity = spatialDocumentIdentity(info.ref.publisherId, info.ref.catalogId, entry.documentId);
1387
1434
  if (opts.spatial && seenSpatial.has(spatialIdentity))
1388
1435
  continue;
1389
1436
  const relevance = fusedRankRelevance(out.length);
@@ -12,6 +12,6 @@
12
12
  */
13
13
  export interface KnowledgeWorkerRequest {
14
14
  id: number;
15
- op: 'mount' | 'unmount' | 'mounted' | 'prewarm' | 'validate' | 'topics' | 'documentsPage' | 'nearbyDocuments' | 'getDocument' | 'getChunk' | 'assets' | 'readAsset' | 'assetFile' | 'search' | 'dispose';
15
+ op: 'mount' | 'unmount' | 'mounted' | 'prewarm' | 'validate' | 'topics' | 'documentsPage' | 'documentInitials' | 'documentPosition' | 'nearbyDocuments' | 'getDocument' | 'getChunk' | 'assets' | 'readAsset' | 'assetFile' | 'search' | 'dispose';
16
16
  args: unknown[];
17
17
  }
@@ -1,11 +1,10 @@
1
1
  /**
2
2
  * Radius pagination binds offsets to the authorized catalog snapshot and
3
3
  * query. Cursors confer no authority; the manager recalculates scope on
4
- * every request. Regional replicas share identity only under Qualla's
5
- * declared regional catalog and source-document naming convention.
4
+ * every request.
6
5
  */
7
6
  export declare class KnowledgeSpatialCursorError extends Error {
8
7
  }
9
8
  export declare function encodeNearbyCursor(snapshot: string, offset: number): string;
10
9
  export declare function nearbyCursorOffset(cursor: string | undefined, snapshot: string): number;
11
- export declare function regionalDocumentIdentity(publisherId: string, catalogId: string, documentId: string): string;
10
+ export declare function spatialDocumentIdentity(publisherId: string, catalogId: string, documentId: string): string;
@@ -1,8 +1,7 @@
1
1
  /**
2
2
  * Radius pagination binds offsets to the authorized catalog snapshot and
3
3
  * query. Cursors confer no authority; the manager recalculates scope on
4
- * every request. Regional replicas share identity only under Qualla's
5
- * declared regional catalog and source-document naming convention.
4
+ * every request.
6
5
  */
7
6
  export class KnowledgeSpatialCursorError extends Error {
8
7
  }
@@ -24,10 +23,17 @@ export function nearbyCursorOffset(cursor, snapshot) {
24
23
  throw new KnowledgeSpatialCursorError('Invalid or stale nearby cursor; restart the query.');
25
24
  }
26
25
  }
27
- export function regionalDocumentIdentity(publisherId, catalogId, documentId) {
28
- const regional = catalogId.startsWith('qualla-region-') &&
29
- /^qualla-(wikipedia|wikivoyage)-[0-9]+$/.test(documentId);
30
- return regional
26
+ /**
27
+ * Source-article ids (`wikipedia-<page-id>`) name the same article in every
28
+ * catalog of one publisher, and regional catalogs overlap heavily — Pacific
29
+ * Northwest and Canada & Alaska share 683 of the former's 1,106 articles — so
30
+ * those copies are one result. Any other id is unique only within its catalog.
31
+ * Keyed on the id, not the catalog name: an earlier catalog-name match never
32
+ * fired for the published `regional-*` catalogs, so every copy came through.
33
+ */
34
+ const SOURCE_ARTICLE_ID = /^(wikipedia|wikivoyage)-[0-9]+$/;
35
+ export function spatialDocumentIdentity(publisherId, catalogId, documentId) {
36
+ return SOURCE_ARTICLE_ID.test(documentId)
31
37
  ? `${publisherId}\u0000${documentId}`
32
38
  : `${publisherId}\u0000${catalogId}\u0000${documentId}`;
33
39
  }
@@ -141,6 +141,8 @@ export function createWorkerCatalogHost() {
141
141
  documentsPage: async (key, opts) =>
142
142
  // biome-ignore lint/suspicious/noExplicitAny: structured-clone round trip preserves the shape
143
143
  (await call('documentsPage', [key, opts])),
144
+ documentInitials: async (key, opts) => (await call('documentInitials', [key, opts])),
145
+ documentPosition: async (key, documentId, opts) => (await call('documentPosition', [key, documentId, opts])),
144
146
  nearbyDocuments: async (key, radius, opts) => (await call('nearbyDocuments', [key, radius, opts])),
145
147
  getDocument: async (key, documentId) =>
146
148
  // biome-ignore lint/suspicious/noExplicitAny: structured-clone round trip preserves the shape