@bendyline/gezel-inference 1.0.7 → 1.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/http/routes/v1-images.d.ts +2 -1
- package/dist/http/routes/v1-images.js +36 -9
- package/dist/knowledge/manager.d.ts +3 -27
- package/dist/knowledge/manager.js +8 -364
- package/dist/knowledge/unified-search.d.ts +61 -0
- package/dist/knowledge/unified-search.js +373 -0
- package/dist/providers/image/sd-cpp.js +16 -9
- package/dist/providers/mlx/async-file-handoff.d.ts +9 -1
- package/dist/providers/mlx/async-file-handoff.js +32 -4
- package/dist/providers/mlx/provider.js +28 -16
- package/dist/providers/mlx/runtime-diagnostics.d.ts +6 -0
- package/dist/providers/mlx/runtime-diagnostics.js +18 -1
- package/dist/providers/mlx/tool-call-protocol.d.ts +4 -3
- package/dist/providers/mlx/tool-call-protocol.js +4 -3
- package/dist/providers/native/device-capacity-ledger.d.ts +4 -2
- package/dist/providers/native/device-capacity-ledger.js +35 -20
- package/dist/providers/native/process-identity.d.ts +38 -0
- package/dist/providers/native/process-identity.js +86 -0
- package/dist/runtime-lock.d.ts +3 -27
- package/dist/runtime-lock.js +7 -80
- package/package.json +4 -4
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Explicit knowledge search across mounted catalogs: each catalog searched in
|
|
3
|
+
* its own embedding space, the lexical and vector arms fused by reciprocal
|
|
4
|
+
* rank. Platform-neutral — the daemon's KnowledgeManager and the on-device
|
|
5
|
+
* Capacitor host both run it over their own KnowledgeCatalogHost.
|
|
6
|
+
*/
|
|
7
|
+
import { formatKnowledgeUri, scoreResult, } from '@bendyline/gezel';
|
|
8
|
+
import { spatialDocumentIdentity } from './spatial-query.js';
|
|
9
|
+
/** Explicit-search shard budget (S) across every active catalog (§3.4). */
|
|
10
|
+
const ROUTE_BUDGET_EXPLICIT = 6;
|
|
11
|
+
/**
|
|
12
|
+
* Shards added beyond that budget for pages the query names by title (the
|
|
13
|
+
* catalog host's title-assisted routing): the centroids alone left the ABBA
|
|
14
|
+
* article unscanned for "Who were the members of ABBA?". Two, not one per
|
|
15
|
+
* catalog, so twenty mounted catalogs cannot add twenty scans.
|
|
16
|
+
*/
|
|
17
|
+
const TITLE_ROUTE_EXPLICIT = 2;
|
|
18
|
+
const FINAL_K = 24;
|
|
19
|
+
/**
|
|
20
|
+
* Explicit search fuses three arms per catalog by reciprocal rank (k = 60):
|
|
21
|
+
* the vector chunks (best chunk per document, by rerank cosine), the
|
|
22
|
+
* doc-title FTS hits, and the chunk-body FTS hits. Rank fusion instead of
|
|
23
|
+
* score comparison because the arms' scores are not commensurable — e5
|
|
24
|
+
* cosines sit in a narrow 0.7–0.9 band for everything, so a cosine-first
|
|
25
|
+
* ordering buried an exact title match under any vaguely related passage
|
|
26
|
+
* ("Who painted the Mona Lisa?" listed three other Mona Lisa copies above
|
|
27
|
+
* the article whose title the question names). Two lexical arms agreeing
|
|
28
|
+
* on a document now outrank one vector arm; a document every arm names
|
|
29
|
+
* comes first.
|
|
30
|
+
*/
|
|
31
|
+
const RRF_K = 60;
|
|
32
|
+
const ARM_WEIGHTS = { vector: 1, docFts: 1, chunkFts: 0.5 };
|
|
33
|
+
/**
|
|
34
|
+
* Media rows fuse per row, not per document: a document's own text chunk
|
|
35
|
+
* (cosine ≈ 0.9) would otherwise always stand in for its image (≈ 0.7). Each
|
|
36
|
+
* modality is ranked on its own scale, past its own measured floor.
|
|
37
|
+
*/
|
|
38
|
+
const MEDIA_ARM_WEIGHTS = { vector: 0.5, chunkFts: 0.25 };
|
|
39
|
+
/** Media rows asked of each catalog, per modality, on an explicit search. */
|
|
40
|
+
const MEDIA_K = 2;
|
|
41
|
+
/** Media results one search returns at most, and per document. */
|
|
42
|
+
const MEDIA_RESULT_CAP = 4;
|
|
43
|
+
const MEDIA_PER_DOCUMENT = 1;
|
|
44
|
+
/**
|
|
45
|
+
* Fused documents per catalog when several are in scope: keeps one catalog
|
|
46
|
+
* from monopolizing the merged list. A search scoped to one catalog has
|
|
47
|
+
* nothing to share the list with, and the cap cut the Knowledge browser's
|
|
48
|
+
* search for "blob" in an Azure-docs catalog — 348 matching titles — to six.
|
|
49
|
+
*/
|
|
50
|
+
const PER_CATALOG_CAP = 6;
|
|
51
|
+
/** Lexical candidates fetched per arm before fusion, at the least. */
|
|
52
|
+
const DOC_FTS_MIN = 6;
|
|
53
|
+
const CHUNK_FTS_MIN = 8;
|
|
54
|
+
/** Rank-anchored relevance for a fused knowledge result: the best hit is a strong 1.0, decaying RRF-style. */
|
|
55
|
+
function fusedRankRelevance(rank) {
|
|
56
|
+
return 11 / (11 + rank);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* The first prose paragraph of a document body, for a hit that no chunk
|
|
60
|
+
* carries (a title-only match): the model gets something to read, not just
|
|
61
|
+
* a summary line. Skips the title heading and a key-facts list.
|
|
62
|
+
*/
|
|
63
|
+
function leadExcerpt(markdown, max = 600) {
|
|
64
|
+
const body = markdown.replace(/^#\s[^\n]*\n+/, '');
|
|
65
|
+
const paragraph = body
|
|
66
|
+
.split(/\n\s*\n/)
|
|
67
|
+
.map((part) => part.trim())
|
|
68
|
+
.find((part) => part.length > 0 && !part.startsWith('#') && !part.startsWith('- ')) ??
|
|
69
|
+
body.trim();
|
|
70
|
+
return paragraph.length > max ? `${paragraph.slice(0, max - 1)}…` : paragraph;
|
|
71
|
+
}
|
|
72
|
+
export async function searchUnifiedCatalogs(deps, active, query, opts) {
|
|
73
|
+
if (active.length === 0)
|
|
74
|
+
return [];
|
|
75
|
+
const groups = [];
|
|
76
|
+
const shared = [];
|
|
77
|
+
const keyword = [];
|
|
78
|
+
const byProfile = new Map();
|
|
79
|
+
for (const key of active) {
|
|
80
|
+
const info = deps.catalog(key);
|
|
81
|
+
if (!info)
|
|
82
|
+
continue;
|
|
83
|
+
if (info.semanticSearch === 'shared' && !opts.localModelsOnly)
|
|
84
|
+
shared.push(key);
|
|
85
|
+
else if (info.semanticSearch === 'profile' || info.semanticSearch === 'shared') {
|
|
86
|
+
const group = byProfile.get(info.embedding.id) ?? { profile: info.embedding, keys: [] };
|
|
87
|
+
group.keys.push(key);
|
|
88
|
+
byProfile.set(info.embedding.id, group);
|
|
89
|
+
}
|
|
90
|
+
else
|
|
91
|
+
keyword.push(key);
|
|
92
|
+
}
|
|
93
|
+
if (shared.length > 0) {
|
|
94
|
+
groups.push({
|
|
95
|
+
keys: shared,
|
|
96
|
+
...(opts.vector ? { vector: Float32Array.from(opts.vector) } : {}),
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
for (const group of byProfile.values()) {
|
|
100
|
+
const vector = await deps.embedForProfile(query, group.profile, opts.queryEmbedBudgetMs, opts.localModelsOnly);
|
|
101
|
+
groups.push({ keys: group.keys, ...(vector ? { vector } : {}) });
|
|
102
|
+
}
|
|
103
|
+
if (keyword.length > 0)
|
|
104
|
+
groups.push({ keys: keyword });
|
|
105
|
+
const response = {
|
|
106
|
+
chunks: [],
|
|
107
|
+
documents: [],
|
|
108
|
+
};
|
|
109
|
+
for (const group of groups) {
|
|
110
|
+
const request = {
|
|
111
|
+
query,
|
|
112
|
+
shardBudget: ROUTE_BUDGET_EXPLICIT,
|
|
113
|
+
titleRouteShards: TITLE_ROUTE_EXPLICIT,
|
|
114
|
+
finalK: FINAL_K,
|
|
115
|
+
includeChunkFts: true,
|
|
116
|
+
catalogKeys: group.keys,
|
|
117
|
+
// A pool no deeper than the page asked for: six title hits could
|
|
118
|
+
// never fill a twenty-result browse search.
|
|
119
|
+
docFtsLimit: Math.max(DOC_FTS_MIN, opts.maxResults),
|
|
120
|
+
chunkFtsLimit: Math.max(CHUNK_FTS_MIN, opts.maxResults),
|
|
121
|
+
spatial: opts.spatial,
|
|
122
|
+
};
|
|
123
|
+
let part;
|
|
124
|
+
try {
|
|
125
|
+
part = await deps.host.search(group.vector
|
|
126
|
+
? { ...request, vector: group.vector, ...(opts.media ? { mediaK: MEDIA_K } : {}) }
|
|
127
|
+
: request);
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
// A catalog that refuses the query vector (a width the catalog does
|
|
131
|
+
// not store, say) is an embedding failure, not a search failure: the
|
|
132
|
+
// worker forwards only the message, so any vector-arm error falls
|
|
133
|
+
// back to the keyword arms for this group.
|
|
134
|
+
if (!group.vector)
|
|
135
|
+
throw error;
|
|
136
|
+
deps.warn(`[knowledge] semantic search failed for ${group.keys.join(', ')}; keyword-only: ${error instanceof Error ? error.message : String(error)}`);
|
|
137
|
+
part = await deps.host.search(request);
|
|
138
|
+
}
|
|
139
|
+
response.chunks.push(...part.chunks);
|
|
140
|
+
response.documents.push(...part.documents);
|
|
141
|
+
}
|
|
142
|
+
// ── fuse the arms per document ──────────────────────────────────────
|
|
143
|
+
const fused = new Map();
|
|
144
|
+
const bump = (catalogKey, documentId, weight, rank, chunk) => {
|
|
145
|
+
const key = `${catalogKey}\u0000${documentId}`;
|
|
146
|
+
const entry = fused.get(key) ?? { catalogKey, documentId, score: 0 };
|
|
147
|
+
entry.score += weight / (RRF_K + rank);
|
|
148
|
+
// The document's representative chunk: its best vector chunk, else
|
|
149
|
+
// whichever chunk-FTS row named it.
|
|
150
|
+
if (chunk && (!entry.chunk || (chunk.cosine ?? -1) > (entry.chunk.cosine ?? -1))) {
|
|
151
|
+
entry.chunk = chunk;
|
|
152
|
+
}
|
|
153
|
+
fused.set(key, entry);
|
|
154
|
+
};
|
|
155
|
+
// Every text passage the arms found, per document, best vector match first,
|
|
156
|
+
// for `chunksPerDocument`. Fusion still ranks documents, not passages.
|
|
157
|
+
const passagesByDocument = new Map();
|
|
158
|
+
if ((opts.chunksPerDocument ?? 1) > 1) {
|
|
159
|
+
for (const hit of response.chunks
|
|
160
|
+
.filter((h) => !h.media)
|
|
161
|
+
.sort((a, b) => (b.cosine ?? -1) - (a.cosine ?? -1))) {
|
|
162
|
+
const key = `${hit.catalogKey}\u0000${hit.documentId}`;
|
|
163
|
+
const passages = passagesByDocument.get(key) ?? [];
|
|
164
|
+
if (!passages.some((known) => known.chunkUid === hit.chunkUid))
|
|
165
|
+
passages.push(hit);
|
|
166
|
+
passagesByDocument.set(key, passages);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
// Vector arm: one rank per document, in rerank-cosine order. A hit under
|
|
170
|
+
// its profile's floor is not evidence at all — KNN always returns its
|
|
171
|
+
// nearest rows, and a rank-0 row fuses to relevance 1.0 however far away
|
|
172
|
+
// it is (see vector-floors.ts).
|
|
173
|
+
const textChunks = response.chunks.filter((h) => !h.media);
|
|
174
|
+
const mediaChunks = response.chunks.filter((h) => h.media);
|
|
175
|
+
const seenVector = new Set();
|
|
176
|
+
let vectorRank = 0;
|
|
177
|
+
for (const hit of textChunks
|
|
178
|
+
.filter((h) => h.cosine !== undefined)
|
|
179
|
+
.sort((a, b) => (b.cosine ?? 0) - (a.cosine ?? 0))) {
|
|
180
|
+
const key = `${hit.catalogKey}\u0000${hit.documentId}`;
|
|
181
|
+
if (seenVector.has(key))
|
|
182
|
+
continue;
|
|
183
|
+
const cosine = hit.cosine ?? 0;
|
|
184
|
+
const floor = deps.vectorFloorFor(hit.catalogKey);
|
|
185
|
+
if (floor !== null && cosine < floor)
|
|
186
|
+
continue;
|
|
187
|
+
seenVector.add(key);
|
|
188
|
+
bump(hit.catalogKey, hit.documentId, ARM_WEIGHTS.vector, vectorRank++, hit);
|
|
189
|
+
const entry = fused.get(key);
|
|
190
|
+
if (entry && floor !== null && cosine > (entry.similarity ?? Number.NEGATIVE_INFINITY)) {
|
|
191
|
+
entry.similarity = cosine;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
// Chunk-body FTS arm: BM25 order as the shards returned it, ranked per catalog.
|
|
195
|
+
const chunkFtsRank = new Map();
|
|
196
|
+
const seenChunkFts = new Set();
|
|
197
|
+
for (const hit of textChunks.filter((h) => h.cosine === undefined)) {
|
|
198
|
+
const key = `${hit.catalogKey}\u0000${hit.documentId}`;
|
|
199
|
+
if (seenChunkFts.has(key))
|
|
200
|
+
continue;
|
|
201
|
+
seenChunkFts.add(key);
|
|
202
|
+
const rank = chunkFtsRank.get(hit.catalogKey) ?? 0;
|
|
203
|
+
chunkFtsRank.set(hit.catalogKey, rank + 1);
|
|
204
|
+
bump(hit.catalogKey, hit.documentId, ARM_WEIGHTS.chunkFts, rank, hit);
|
|
205
|
+
}
|
|
206
|
+
// Doc-title FTS arm: exact-name evidence, ranked per catalog by the host.
|
|
207
|
+
for (const doc of response.documents) {
|
|
208
|
+
bump(doc.catalogKey, doc.documentId, ARM_WEIGHTS.docFts, doc.rank);
|
|
209
|
+
}
|
|
210
|
+
// Media rows: their own vector arm per modality (a modality with no
|
|
211
|
+
// measured floor gives no vector evidence — nearest-neighbour search
|
|
212
|
+
// always returns some image, and a media row has no query words to be
|
|
213
|
+
// grounded in) and a caption FTS arm.
|
|
214
|
+
const mediaFused = new Map();
|
|
215
|
+
const bumpMedia = (hit, weight, rank) => {
|
|
216
|
+
const key = `${hit.catalogKey}\u0000${hit.chunkUid}`;
|
|
217
|
+
const entry = mediaFused.get(key) ?? {
|
|
218
|
+
catalogKey: hit.catalogKey,
|
|
219
|
+
documentId: hit.documentId,
|
|
220
|
+
score: 0,
|
|
221
|
+
chunk: hit,
|
|
222
|
+
};
|
|
223
|
+
entry.score += weight / (RRF_K + rank);
|
|
224
|
+
if ((hit.cosine ?? -1) > (entry.chunk?.cosine ?? -1))
|
|
225
|
+
entry.chunk = hit;
|
|
226
|
+
mediaFused.set(key, entry);
|
|
227
|
+
};
|
|
228
|
+
const mediaVectorRank = new Map();
|
|
229
|
+
for (const hit of mediaChunks
|
|
230
|
+
.filter((h) => h.cosine !== undefined)
|
|
231
|
+
.sort((a, b) => (b.cosine ?? 0) - (a.cosine ?? 0))) {
|
|
232
|
+
const modality = hit.media?.modality;
|
|
233
|
+
const floor = deps.vectorFloorFor(hit.catalogKey, modality);
|
|
234
|
+
if (floor === null || (hit.cosine ?? 0) < floor)
|
|
235
|
+
continue;
|
|
236
|
+
const rank = mediaVectorRank.get(modality) ?? 0;
|
|
237
|
+
mediaVectorRank.set(modality, rank + 1);
|
|
238
|
+
bumpMedia(hit, MEDIA_ARM_WEIGHTS.vector, rank);
|
|
239
|
+
const entry = mediaFused.get(`${hit.catalogKey}\u0000${hit.chunkUid}`);
|
|
240
|
+
if (entry)
|
|
241
|
+
entry.similarity = Math.max(entry.similarity ?? -1, hit.cosine ?? 0);
|
|
242
|
+
}
|
|
243
|
+
let mediaFtsRank = 0;
|
|
244
|
+
for (const hit of mediaChunks.filter((h) => h.cosine === undefined)) {
|
|
245
|
+
bumpMedia(hit, MEDIA_ARM_WEIGHTS.chunkFts, mediaFtsRank++);
|
|
246
|
+
}
|
|
247
|
+
const ordered = [...fused.values(), ...mediaFused.values()].sort((a, b) => b.score - a.score ||
|
|
248
|
+
(b.chunk?.cosine ?? -1) - (a.chunk?.cosine ?? -1) ||
|
|
249
|
+
a.documentId.localeCompare(b.documentId));
|
|
250
|
+
const matches = new Map([...response.documents, ...response.chunks]
|
|
251
|
+
.filter((hit) => hit.matchedLocation)
|
|
252
|
+
.map((hit) => [
|
|
253
|
+
`${hit.catalogKey}\u0000${hit.documentId}`,
|
|
254
|
+
{ distanceMeters: hit.distanceMeters, matchedLocation: hit.matchedLocation },
|
|
255
|
+
]));
|
|
256
|
+
const seenSpatial = new Set();
|
|
257
|
+
const out = [];
|
|
258
|
+
const perCatalogCount = new Map();
|
|
259
|
+
const capPerCatalog = !opts.spatial && active.length > 1;
|
|
260
|
+
const mediaPerDocument = new Map();
|
|
261
|
+
let mediaResults = 0;
|
|
262
|
+
for (const entry of ordered) {
|
|
263
|
+
const info = deps.catalog(entry.catalogKey);
|
|
264
|
+
if (!info)
|
|
265
|
+
continue;
|
|
266
|
+
if (entry.chunk?.media) {
|
|
267
|
+
const docKey = `${entry.catalogKey}\u0000${entry.documentId}`;
|
|
268
|
+
const forDocument = mediaPerDocument.get(docKey) ?? 0;
|
|
269
|
+
if (mediaResults >= MEDIA_RESULT_CAP || forDocument >= MEDIA_PER_DOCUMENT)
|
|
270
|
+
continue;
|
|
271
|
+
const evidence = entry.similarity !== undefined
|
|
272
|
+
? { arm: 'vector', similarity: entry.similarity }
|
|
273
|
+
: { arm: 'fts' };
|
|
274
|
+
mediaPerDocument.set(docKey, forDocument + 1);
|
|
275
|
+
mediaResults++;
|
|
276
|
+
out.push(knowledgeChunkResult(info, entry.chunk, fusedRankRelevance(out.length), evidence));
|
|
277
|
+
if (out.length >= Math.max(10, opts.maxResults))
|
|
278
|
+
break;
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
const count = perCatalogCount.get(entry.catalogKey) ?? 0;
|
|
282
|
+
if (capPerCatalog && count >= PER_CATALOG_CAP)
|
|
283
|
+
continue;
|
|
284
|
+
const spatialIdentity = spatialDocumentIdentity(info.ref.publisherId, info.ref.catalogId, entry.documentId);
|
|
285
|
+
if (opts.spatial && seenSpatial.has(spatialIdentity))
|
|
286
|
+
continue;
|
|
287
|
+
const relevance = fusedRankRelevance(out.length);
|
|
288
|
+
// `vector` only when the hit cleared a measured floor; everything else
|
|
289
|
+
// is lexical evidence, which proactive injection asks to be grounded.
|
|
290
|
+
const evidence = entry.similarity !== undefined
|
|
291
|
+
? { arm: 'vector', similarity: entry.similarity }
|
|
292
|
+
: { arm: 'fts' };
|
|
293
|
+
const result = entry.chunk
|
|
294
|
+
? knowledgeChunkResult(info, entry.chunk, relevance, evidence)
|
|
295
|
+
: await knowledgeDocumentResult(deps.host, info, entry.documentId, relevance, evidence);
|
|
296
|
+
if (!result)
|
|
297
|
+
continue;
|
|
298
|
+
perCatalogCount.set(entry.catalogKey, count + 1);
|
|
299
|
+
seenSpatial.add(spatialIdentity);
|
|
300
|
+
const docKey = `${entry.catalogKey}\u0000${entry.documentId}`;
|
|
301
|
+
out.push({ ...result, ...matches.get(docKey) });
|
|
302
|
+
if (out.length >= Math.max(10, opts.maxResults))
|
|
303
|
+
break;
|
|
304
|
+
// The document's other passages, each labelled by its own evidence: a
|
|
305
|
+
// sibling is vector evidence only if it clears the floor by itself.
|
|
306
|
+
const siblings = (passagesByDocument.get(docKey) ?? [])
|
|
307
|
+
.filter((hit) => hit.chunkUid !== entry.chunk?.chunkUid)
|
|
308
|
+
.slice(0, Math.max(0, (opts.chunksPerDocument ?? 1) - 1));
|
|
309
|
+
const floor = deps.vectorFloorFor(entry.catalogKey);
|
|
310
|
+
for (const sibling of siblings) {
|
|
311
|
+
const own = floor !== null && sibling.cosine !== undefined && sibling.cosine >= floor
|
|
312
|
+
? { arm: 'vector', similarity: sibling.cosine }
|
|
313
|
+
: { arm: 'fts' };
|
|
314
|
+
out.push(knowledgeChunkResult(info, sibling, fusedRankRelevance(out.length), own));
|
|
315
|
+
if (out.length >= Math.max(10, opts.maxResults))
|
|
316
|
+
break;
|
|
317
|
+
}
|
|
318
|
+
if (out.length >= Math.max(10, opts.maxResults))
|
|
319
|
+
break;
|
|
320
|
+
}
|
|
321
|
+
return out;
|
|
322
|
+
}
|
|
323
|
+
/** A document the lexical arms named but no chunk carries: cite it with its lead paragraph. */
|
|
324
|
+
export async function knowledgeDocumentResult(host, info, documentId, relevance, evidence) {
|
|
325
|
+
const doc = await host.getDocument(info.key, documentId).catch(() => null);
|
|
326
|
+
if (!doc)
|
|
327
|
+
return null;
|
|
328
|
+
const snippet = leadExcerpt(doc.markdown) || doc.summary || '';
|
|
329
|
+
return {
|
|
330
|
+
kind: 'knowledge',
|
|
331
|
+
id: `knowledge:${info.ref.catalogId}:doc:${documentId}`,
|
|
332
|
+
title: doc.title,
|
|
333
|
+
subtitle: `${info.name} · ${info.topicNames.get(doc.topicId) ?? doc.topicId}`,
|
|
334
|
+
...(snippet ? { snippet } : {}),
|
|
335
|
+
retrievalSource: 'knowledge',
|
|
336
|
+
catalogId: info.ref.catalogId,
|
|
337
|
+
catalogVersion: info.ref.version,
|
|
338
|
+
documentId,
|
|
339
|
+
topicPath: [info.topicNames.get(doc.topicId) ?? doc.topicId],
|
|
340
|
+
uri: formatKnowledgeUri({
|
|
341
|
+
publisherId: info.ref.publisherId,
|
|
342
|
+
catalogId: info.ref.catalogId,
|
|
343
|
+
documentId,
|
|
344
|
+
}),
|
|
345
|
+
...(doc.sourceUrl ? { sourceUrl: doc.sourceUrl } : {}),
|
|
346
|
+
...evidence,
|
|
347
|
+
...scoreResult('knowledge', relevance),
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
export function knowledgeChunkResult(info, hit, relevance, evidence) {
|
|
351
|
+
return {
|
|
352
|
+
kind: 'knowledge',
|
|
353
|
+
id: `knowledge:${info.ref.catalogId}:${hit.chunkUid}`,
|
|
354
|
+
title: hit.headingPath.length > 0 ? `${hit.title} › ${hit.headingPath.join(' › ')}` : hit.title,
|
|
355
|
+
subtitle: info.name,
|
|
356
|
+
snippet: hit.text,
|
|
357
|
+
retrievalSource: 'knowledge',
|
|
358
|
+
catalogId: info.ref.catalogId,
|
|
359
|
+
catalogVersion: info.ref.version,
|
|
360
|
+
documentId: hit.documentId,
|
|
361
|
+
uri: formatKnowledgeUri({
|
|
362
|
+
publisherId: info.ref.publisherId,
|
|
363
|
+
catalogId: info.ref.catalogId,
|
|
364
|
+
documentId: hit.documentId,
|
|
365
|
+
fragment: { chunk: hit.chunkUid },
|
|
366
|
+
}),
|
|
367
|
+
line: hit.lineStart,
|
|
368
|
+
lineEnd: hit.lineEnd,
|
|
369
|
+
...(hit.media ? { media: hit.media } : {}),
|
|
370
|
+
...evidence,
|
|
371
|
+
...scoreResult('knowledge', relevance),
|
|
372
|
+
};
|
|
373
|
+
}
|
|
@@ -273,13 +273,8 @@ export class StableDiffusionCppProvider {
|
|
|
273
273
|
const height = input.height ?? defaultDimension;
|
|
274
274
|
const resolvedLoras = await this.resolveLoras(modelId, input.loras ?? []);
|
|
275
275
|
let loras = resolvedLoras.loras;
|
|
276
|
-
// A speed LoRA's recipe is what makes its few steps valid, so it wins
|
|
277
|
-
// over a style's.
|
|
278
|
-
const render = loras.find((l) => l.purpose === 'speed')?.render ?? loras.find((l) => l.render)?.render;
|
|
279
|
-
const steps = input.steps ?? render?.steps ?? (await this.defaultSampleSteps(modelId));
|
|
280
276
|
const seed = input.seed ?? Math.floor(Math.random() * 2_147_483_647);
|
|
281
277
|
const started = Date.now();
|
|
282
|
-
log.info(`[sd-cpp] generate model=${modelId ?? 'default'} steps=${steps} explicitSteps=${input.steps !== undefined} size=${width}x${height}${loras.length > 0 ? ` loras=${loras.map((l) => l.id).join(',')}` : ''}`);
|
|
283
278
|
// Model selection: sd-server serves one model per process. When a
|
|
284
279
|
// request targets a different installed model than the one currently
|
|
285
280
|
// loaded, point resolveLaunch at it (via the shared launchState) and
|
|
@@ -333,6 +328,15 @@ export class StableDiffusionCppProvider {
|
|
|
333
328
|
}
|
|
334
329
|
if (signal?.aborted)
|
|
335
330
|
throw new ImageGenerationCancelledError();
|
|
331
|
+
const settingsNotes = [...resolvedLoras.notes];
|
|
332
|
+
if (loras.length > 0) {
|
|
333
|
+
loras = await this.lorasTheEngineHas(baseUrl, loras, request.signal, settingsNotes);
|
|
334
|
+
}
|
|
335
|
+
// Read only after the engine's list: a fast mode's 4 steps and CFG 1
|
|
336
|
+
// on the bare model it was dropped from render mush.
|
|
337
|
+
const render = loraRenderOverrides(loras);
|
|
338
|
+
const steps = input.steps ?? render?.steps ?? (await this.defaultSampleSteps(modelId));
|
|
339
|
+
log.info(`[sd-cpp] generate model=${modelId ?? 'default'} steps=${steps} explicitSteps=${input.steps !== undefined} size=${width}x${height}${loras.length > 0 ? ` loras=${loras.map((l) => l.id).join(',')}` : ''}`);
|
|
336
340
|
// Drive sd-server's A1111-compatible endpoints (`/sdapi/v1/txt2img`
|
|
337
341
|
// and `/sdapi/v1/img2img`). Unlike the OpenAI `/v1/images/*` shape
|
|
338
342
|
// — which reads only prompt/n/size and takes steps/seed/cfg from the
|
|
@@ -341,10 +345,6 @@ export class StableDiffusionCppProvider {
|
|
|
341
345
|
// actually take effect. `cfg_scale` is set per model (distilled /
|
|
342
346
|
// Flux-flow models need CFG 1; others inherit the server default).
|
|
343
347
|
const cfg = render?.cfg ?? (modelId ? MODEL_CFG_DEFAULTS.get(modelId) : undefined);
|
|
344
|
-
const settingsNotes = [...resolvedLoras.notes];
|
|
345
|
-
if (loras.length > 0) {
|
|
346
|
-
loras = await this.lorasTheEngineHas(baseUrl, loras, request.signal, settingsNotes);
|
|
347
|
-
}
|
|
348
348
|
let inputImages = input.inputImages ?? [];
|
|
349
349
|
// Capability gate: models whose architecture doesn't honor init
|
|
350
350
|
// latents on the pinned sd-server fall back to txt2img instead of
|
|
@@ -996,6 +996,13 @@ export function withoutEngineDirectives(text) {
|
|
|
996
996
|
} while (out !== previous);
|
|
997
997
|
return out;
|
|
998
998
|
}
|
|
999
|
+
/**
|
|
1000
|
+
* The render settings the applied LoRAs set. A speed LoRA's recipe is what
|
|
1001
|
+
* makes its few steps valid, so it wins over a style's.
|
|
1002
|
+
*/
|
|
1003
|
+
function loraRenderOverrides(applied) {
|
|
1004
|
+
return (applied.find((l) => l.purpose === 'speed')?.render ?? applied.find((l) => l.render)?.render);
|
|
1005
|
+
}
|
|
999
1006
|
/**
|
|
1000
1007
|
* Read an installed LoRA's manifest, or nothing when it is malformed. The
|
|
1001
1008
|
* filename must be a bare `.safetensors` name: the engine resolves it inside
|
|
@@ -7,5 +7,13 @@
|
|
|
7
7
|
* recipients.
|
|
8
8
|
*/
|
|
9
9
|
export declare function isSuccessfulAsyncHandoff(toolName: string, output: string): boolean;
|
|
10
|
-
|
|
10
|
+
/** The gezel a successful handoff result names, when it names one. */
|
|
11
|
+
export declare function asyncHandoffRecipient(output: string): string | undefined;
|
|
12
|
+
/**
|
|
13
|
+
* The reply the person sees when the runtime ends a delegating turn, which the
|
|
14
|
+
* model also reads back as its own last message. The tool result already told
|
|
15
|
+
* the model to end its turn, so this line only says what happened. Each entry
|
|
16
|
+
* is one handoff's recipient, or undefined when its result named nobody.
|
|
17
|
+
*/
|
|
18
|
+
export declare function asyncHandoffClosing(recipients: readonly (string | undefined)[]): string;
|
|
11
19
|
export declare function immediateFileWriteClosing(paths: string[]): string;
|
|
@@ -10,10 +10,38 @@ export function isSuccessfulAsyncHandoff(toolName, output) {
|
|
|
10
10
|
return (!output.startsWith('ERROR:') &&
|
|
11
11
|
(toolName === 'message_gezel' || toolName.startsWith('delegate_')));
|
|
12
12
|
}
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
13
|
+
/**
|
|
14
|
+
* The handoff results (core's `messageGezelText`, the MCP `delegate_*` tools)
|
|
15
|
+
* name the recipient after a fixed lead-in and again as "<name>'s turn".
|
|
16
|
+
* Requiring both means a reworded result, or a name holding a period, gives
|
|
17
|
+
* no name rather than half of one.
|
|
18
|
+
*/
|
|
19
|
+
const HANDOFF_RECIPIENT = /(?:Accepted the (?:message|handoff) for|Dispatched the (?:message|handoff) to|An identical file handoff is already pending with) (.+?)(?= \(|[.;])[\s\S]*?\1's turn/;
|
|
20
|
+
/** The gezel a successful handoff result names, when it names one. */
|
|
21
|
+
export function asyncHandoffRecipient(output) {
|
|
22
|
+
return HANDOFF_RECIPIENT.exec(output.slice(0, 2_000))?.[1]?.trim() || undefined;
|
|
23
|
+
}
|
|
24
|
+
function joinNames(names) {
|
|
25
|
+
if (names.length <= 2)
|
|
26
|
+
return names.join(' and ');
|
|
27
|
+
return `${names.slice(0, -1).join(', ')}, and ${names[names.length - 1]}`;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* The reply the person sees when the runtime ends a delegating turn, which the
|
|
31
|
+
* model also reads back as its own last message. The tool result already told
|
|
32
|
+
* the model to end its turn, so this line only says what happened. Each entry
|
|
33
|
+
* is one handoff's recipient, or undefined when its result named nobody.
|
|
34
|
+
*/
|
|
35
|
+
export function asyncHandoffClosing(recipients) {
|
|
36
|
+
const named = [...new Set(recipients.filter((name) => Boolean(name)))];
|
|
37
|
+
if (named.length > 0 && !recipients.includes(undefined)) {
|
|
38
|
+
return named.length === 1
|
|
39
|
+
? `${named[0]} is on it. Their reply will appear here.`
|
|
40
|
+
: `${joinNames(named)} are on it. Their replies will appear here.`;
|
|
41
|
+
}
|
|
42
|
+
return recipients.length > 1
|
|
43
|
+
? "I've passed this on. The replies will appear here."
|
|
44
|
+
: "I've passed this on. The reply will appear here.";
|
|
17
45
|
}
|
|
18
46
|
export function immediateFileWriteClosing(paths) {
|
|
19
47
|
const unique = [...new Set(paths.filter((path) => path.trim().length > 0))];
|
|
@@ -51,7 +51,7 @@ import { coerceToolCallArgs } from '../tool-arg-schema-coercion.js';
|
|
|
51
51
|
import { createToolExecutor } from '../tool-executor.js';
|
|
52
52
|
import { ToolFailureTracker } from '../tool-failure-tracker.js';
|
|
53
53
|
import { ToolRepeatTracker } from '../tool-repeat-tracker.js';
|
|
54
|
-
import { asyncHandoffClosing, immediateFileWriteClosing, isSuccessfulAsyncHandoff, } from './async-file-handoff.js';
|
|
54
|
+
import { asyncHandoffClosing, asyncHandoffRecipient, immediateFileWriteClosing, isSuccessfulAsyncHandoff, } from './async-file-handoff.js';
|
|
55
55
|
export { isSuccessfulAsyncHandoff };
|
|
56
56
|
import { setChatTemplateKwarg, } from './chat-protocol.js';
|
|
57
57
|
import { MlxEngineGate } from './engine-gate.js';
|
|
@@ -60,7 +60,7 @@ import { EngineLogRouter } from './engine-log-router.js';
|
|
|
60
60
|
import { MlxEngineStatsSampler } from './engine-stats-sampler.js';
|
|
61
61
|
import { REASONING_OPEN_HEADER, StreamingReasoningSplit, engineOpensReasoning, reopenTemplateReasoning, requestOpensReasoning, } from './reasoning-stream.js';
|
|
62
62
|
import { applyMlxRequestShape } from './request-shape.js';
|
|
63
|
-
import { PRE_FIRST_BYTE_BASE_MS, buildMidStreamDropMessage, buildPreFirstByteAbortMessage, computePreFirstByteBudgetMs, estimatePromptTokens, formatTps, isMidStreamConnectionDrop, mlxHttpError, } from './runtime-diagnostics.js';
|
|
63
|
+
import { MLX_IMAGE_RESERVED_CHARS, PRE_FIRST_BYTE_BASE_MS, buildMidStreamDropMessage, buildPreFirstByteAbortMessage, computePreFirstByteBudgetMs, estimatePromptTokens, formatTps, isMidStreamConnectionDrop, mlxHttpError, } from './runtime-diagnostics.js';
|
|
64
64
|
import { classifyMlxFatalErrorLine, classifyMlxStartupLine, } from './stdout-parser.js';
|
|
65
65
|
import { salvageTextualToolCalls } from './textual-tool-call-salvage.js';
|
|
66
66
|
import { APPEND_TO_FILE_CONTINUATION_TOOL, MlxToolCallAccumulator, applyCallableToolGrammar, callableRequestToolNames, chatCompletionToolName, externalToolsAsChatCompletions, hermesRequiredArgGrammarRequested, missingTopLevelRequiredToolArgs, toChatCompletionsTools, validatorReportedMissingRequiredArgs, } from './tool-call-protocol.js';
|
|
@@ -646,7 +646,7 @@ class MlxSession extends StreamingSessionBase {
|
|
|
646
646
|
total += m.content.length;
|
|
647
647
|
// The sidecar bounds images to 1024px. Reserve patch tokens without
|
|
648
648
|
// counting base64 bytes as language tokens or treating pixels as free.
|
|
649
|
-
total += (m.images?.length ?? 0) *
|
|
649
|
+
total += (m.images?.length ?? 0) * MLX_IMAGE_RESERVED_CHARS;
|
|
650
650
|
if (m.tool_calls) {
|
|
651
651
|
for (const tc of m.tool_calls)
|
|
652
652
|
total += tc.function.arguments.length + tc.function.name.length;
|
|
@@ -1427,7 +1427,8 @@ class MlxSession extends StreamingSessionBase {
|
|
|
1427
1427
|
// - STREAMING_IDLE_MS: budget between two consecutive SSE
|
|
1428
1428
|
// content events once tokens are flowing. Generous enough
|
|
1429
1429
|
// to cover a heavy reasoning gap on a 26B model.
|
|
1430
|
-
|
|
1430
|
+
// The request as sent: an image retired from it costs no prefill.
|
|
1431
|
+
const approxPromptTokens = estimatePromptTokens(body.messages, body.tools);
|
|
1431
1432
|
const PRE_FIRST_BYTE_IDLE_MS = computePreFirstByteBudgetMs(approxPromptTokens);
|
|
1432
1433
|
const PRE_FIRST_BYTE_PER_PROGRESS_MS = PRE_FIRST_BYTE_IDLE_MS;
|
|
1433
1434
|
const STREAMING_IDLE_MS = 300_000;
|
|
@@ -2168,6 +2169,11 @@ class MlxSession extends StreamingSessionBase {
|
|
|
2168
2169
|
});
|
|
2169
2170
|
turnContent = salvaged.turnContent;
|
|
2170
2171
|
const { proseRepaired, xmlTagRepaired, claudeInvokeRepaired, glmRepaired, hermesRepaired, shellRepaired, envelopeRepaired, envelopeTruncated, unknownCall, truncatedSalvage, bareInvokeRepaired, } = salvaged;
|
|
2172
|
+
// A prose-shaped call such as lookup_record({}) was ordinary TEXT
|
|
2173
|
+
// to the Hermes grammar, not a constrained tool envelope. Missing
|
|
2174
|
+
// arguments in that salvage cannot establish a decoding violation.
|
|
2175
|
+
// Keep provenance by id through coercion and duplicate collapse.
|
|
2176
|
+
const proseCallIds = new Set(proseRepaired.map((call) => call.id));
|
|
2171
2177
|
const rawTurnContentBeforeReasoning = turnContent;
|
|
2172
2178
|
// Reasoning-tag extraction applies regardless of which (if any)
|
|
2173
2179
|
// salvage path fired. Qwen 3.5/3.6 leak `<think>…</think>`
|
|
@@ -2602,7 +2608,7 @@ class MlxSession extends StreamingSessionBase {
|
|
|
2602
2608
|
tool_calls: toolCalls,
|
|
2603
2609
|
});
|
|
2604
2610
|
let abortDueToFailureLoop = null;
|
|
2605
|
-
|
|
2611
|
+
const asyncHandoffRecipients = [];
|
|
2606
2612
|
let terminalActionClosing = null;
|
|
2607
2613
|
const toolImages = [];
|
|
2608
2614
|
const immediateFileWritePaths = [];
|
|
@@ -2701,16 +2707,21 @@ class MlxSession extends StreamingSessionBase {
|
|
|
2701
2707
|
this.deps.bridges.hasTool(call.function.name)) {
|
|
2702
2708
|
const missingRequired = missingTopLevelRequiredToolArgs(toolArgSchemas.get(call.function.name), args);
|
|
2703
2709
|
const grammarContractViolation = missingRequired.length > 0 &&
|
|
2710
|
+
!proseCallIds.has(call.id) &&
|
|
2704
2711
|
hermesRequiredArgGrammarRequested(body) &&
|
|
2705
2712
|
!this.forceSequentialToolGrammar;
|
|
2706
|
-
if (
|
|
2707
|
-
|
|
2708
|
-
|
|
2713
|
+
if (missingRequired.length > 0) {
|
|
2714
|
+
if (grammarContractViolation) {
|
|
2715
|
+
this.forceSequentialToolGrammar = true;
|
|
2716
|
+
log.warn(`turn#${seq}.${turn} required-argument grammar contract violation tool=${call.function.name} missing=${missingRequired.join(',')} — latching this session to sequential decode`);
|
|
2717
|
+
}
|
|
2709
2718
|
// Do not send a call that the advertised schema proves cannot
|
|
2710
|
-
// pass validation
|
|
2711
|
-
//
|
|
2712
|
-
//
|
|
2713
|
-
output =
|
|
2719
|
+
// pass validation, regardless of how it was parsed. A rejected
|
|
2720
|
+
// prose salvage gets ordinary corrective feedback and retains
|
|
2721
|
+
// speculation; native grammar violations keep the circuit breaker.
|
|
2722
|
+
output = grammarContractViolation
|
|
2723
|
+
? `ERROR: ${call.function.name} was not executed because the constrained decoder omitted required fields: ${missingRequired.join(', ')}. The runtime switched this session to sequential decoding; retry the call once.`
|
|
2724
|
+
: `ERROR: ${call.function.name} was not executed because its arguments are missing required fields: ${missingRequired.join(', ')}. Retry the call with all declared required fields.`;
|
|
2714
2725
|
}
|
|
2715
2726
|
else {
|
|
2716
2727
|
try {
|
|
@@ -2741,6 +2752,7 @@ class MlxSession extends StreamingSessionBase {
|
|
|
2741
2752
|
// the advertised schema. Sequential decode may still recover on
|
|
2742
2753
|
// the next iteration, and the log now makes the skew explicit.
|
|
2743
2754
|
if (validatorReportedMissingRequiredArgs(output) &&
|
|
2755
|
+
!proseCallIds.has(call.id) &&
|
|
2744
2756
|
hermesRequiredArgGrammarRequested(body) &&
|
|
2745
2757
|
!this.forceSequentialToolGrammar) {
|
|
2746
2758
|
this.forceSequentialToolGrammar = true;
|
|
@@ -2755,7 +2767,7 @@ class MlxSession extends StreamingSessionBase {
|
|
|
2755
2767
|
askedQuestionThisTurn = true;
|
|
2756
2768
|
}
|
|
2757
2769
|
if (isSuccessfulAsyncHandoff(call.function.name, output)) {
|
|
2758
|
-
|
|
2770
|
+
asyncHandoffRecipients.push(asyncHandoffRecipient(output));
|
|
2759
2771
|
}
|
|
2760
2772
|
if ((immediateFileWriteTurn || writeContinuationActive) &&
|
|
2761
2773
|
(call.function.name === 'write_file' || call.function.name === 'append_to_file') &&
|
|
@@ -2930,12 +2942,12 @@ class MlxSession extends StreamingSessionBase {
|
|
|
2930
2942
|
`${writeContinuations > 0 ? ` continuations=${writeContinuations}` : ''}`);
|
|
2931
2943
|
return fullText;
|
|
2932
2944
|
}
|
|
2933
|
-
if (
|
|
2934
|
-
const closingText = asyncHandoffClosing(
|
|
2945
|
+
if (asyncHandoffRecipients.length > 0) {
|
|
2946
|
+
const closingText = asyncHandoffClosing(asyncHandoffRecipients);
|
|
2935
2947
|
this.messages.push({ role: 'assistant', content: closingText });
|
|
2936
2948
|
fullText = closingText;
|
|
2937
2949
|
log.info(`turn#${seq} END async-handoff-bail afterMs=${Date.now() - start} ` +
|
|
2938
|
-
`handoffs=${
|
|
2950
|
+
`handoffs=${asyncHandoffRecipients.length} loopTurns=${turn + 1}`);
|
|
2939
2951
|
return fullText;
|
|
2940
2952
|
}
|
|
2941
2953
|
if (forceProjectMacroBail) {
|
|
@@ -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
|
-
|
|
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 */
|