@bendyline/gezel-inference 1.0.6 → 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.
Files changed (152) hide show
  1. package/dist/cache/controller.d.ts +2 -0
  2. package/dist/cache/controller.js +38 -5
  3. package/dist/catalog/chat-models/index.json +1 -1
  4. package/dist/catalog/chat-models/lf/lfm3-1.7b-q4/manifest.json +114 -0
  5. package/dist/catalog/chat-models/lf/lfm3-1.7b-q4/versions/1.0.0/manifest.json +15 -0
  6. package/dist/catalog/chat-models/raw-index.json +1 -1
  7. package/dist/catalog/image-loras/index.json +1 -0
  8. package/dist/catalog/image-loras/kr/krea-2-ink-wash/manifest.json +31 -0
  9. package/dist/catalog/image-loras/kr/krea-2-ink-wash/versions/1.0.0/manifest.json +15 -0
  10. package/dist/catalog/image-loras/kr/krea-2-isometric-3d/manifest.json +32 -0
  11. package/dist/catalog/image-loras/kr/krea-2-isometric-3d/versions/1.0.0/manifest.json +15 -0
  12. package/dist/catalog/image-loras/kr/krea-2-kids-drawing/manifest.json +31 -0
  13. package/dist/catalog/image-loras/kr/krea-2-kids-drawing/versions/1.0.0/manifest.json +15 -0
  14. package/dist/catalog/image-loras/kr/krea-2-pixel-art/manifest.json +31 -0
  15. package/dist/catalog/image-loras/kr/krea-2-pixel-art/versions/1.0.0/manifest.json +15 -0
  16. package/dist/catalog/image-loras/kr/krea-2-soft-watercolor/manifest.json +31 -0
  17. package/dist/catalog/image-loras/kr/krea-2-soft-watercolor/versions/1.0.0/manifest.json +15 -0
  18. package/dist/catalog/image-loras/kr/krea-2-turbo-4step/manifest.json +31 -0
  19. package/dist/catalog/image-loras/kr/krea-2-turbo-4step/versions/1.0.0/manifest.json +15 -0
  20. package/dist/catalog/image-loras/mo/moss-image-gouache/manifest.json +29 -0
  21. package/dist/catalog/image-loras/mo/moss-image-gouache/versions/1.0.0/manifest.json +15 -0
  22. package/dist/catalog/image-loras/mo/moss-image-sft-4step/manifest.json +30 -0
  23. package/dist/catalog/image-loras/mo/moss-image-sft-4step/versions/1.0.0/manifest.json +15 -0
  24. package/dist/catalog/image-loras/qw/qwen-image-edit-2511-lightning/manifest.json +31 -0
  25. package/dist/catalog/image-loras/qw/qwen-image-edit-2511-lightning/versions/1.0.0/manifest.json +15 -0
  26. package/dist/catalog/image-loras/raw-index.json +1 -0
  27. package/dist/catalog/image-loras/sd/sdxl-3d-render/manifest.json +29 -0
  28. package/dist/catalog/image-loras/sd/sdxl-3d-render/versions/1.0.0/manifest.json +15 -0
  29. package/dist/catalog/image-loras/sd/sdxl-coloring-book/manifest.json +30 -0
  30. package/dist/catalog/image-loras/sd/sdxl-coloring-book/versions/1.0.0/manifest.json +15 -0
  31. package/dist/catalog/image-loras/sd/sdxl-papercut/manifest.json +29 -0
  32. package/dist/catalog/image-loras/sd/sdxl-papercut/versions/1.0.0/manifest.json +15 -0
  33. package/dist/catalog/image-loras/sd/sdxl-pixel-art/manifest.json +29 -0
  34. package/dist/catalog/image-loras/sd/sdxl-pixel-art/versions/1.0.0/manifest.json +15 -0
  35. package/dist/catalog/image-models/fl/flux-2-klein-4b-q4/manifest.json +3 -0
  36. package/dist/catalog/image-models/index.json +1 -1
  37. package/dist/catalog/image-models/qw/qwen-image-edit-2511-q4/manifest.json +44 -0
  38. package/dist/catalog/image-models/qw/qwen-image-edit-2511-q4/versions/1.0.0/manifest.json +36 -0
  39. package/dist/catalog/image-models/raw-index.json +1 -1
  40. package/dist/catalog/knowledge-catalogs/index.json +1 -1
  41. package/dist/catalog/knowledge-catalogs/raw-index.json +1 -1
  42. package/dist/catalog/knowledge-catalogs/wi/wikipedia-arts/versions/2026.10.2/manifest.json +181 -0
  43. package/dist/catalog/knowledge-catalogs/wi/wikipedia-astronomy/versions/2026.10.2/manifest.json +181 -0
  44. package/dist/catalog/knowledge-catalogs/wi/wikipedia-biology/versions/2026.10.2/manifest.json +217 -0
  45. package/dist/catalog/knowledge-catalogs/wi/wikipedia-business/versions/2026.10.2/manifest.json +197 -0
  46. package/dist/catalog/knowledge-catalogs/wi/wikipedia-film-tv/versions/2026.10.2/manifest.json +197 -0
  47. package/dist/catalog/knowledge-catalogs/wi/wikipedia-food-drink/versions/2026.10.2/manifest.json +185 -0
  48. package/dist/catalog/knowledge-catalogs/wi/wikipedia-games/versions/2026.10.2/manifest.json +181 -0
  49. package/dist/catalog/knowledge-catalogs/wi/wikipedia-general/manifest.json +55 -0
  50. package/dist/catalog/knowledge-catalogs/wi/wikipedia-general/versions/2026.10.2/manifest.json +165 -0
  51. package/dist/catalog/knowledge-catalogs/wi/wikipedia-geography/versions/2026.10.2/manifest.json +217 -0
  52. package/dist/catalog/knowledge-catalogs/wi/wikipedia-history/versions/2026.10.2/manifest.json +217 -0
  53. package/dist/catalog/knowledge-catalogs/wi/wikipedia-language/versions/2026.10.2/manifest.json +181 -0
  54. package/dist/catalog/knowledge-catalogs/wi/wikipedia-law/versions/2026.10.2/manifest.json +185 -0
  55. package/dist/catalog/knowledge-catalogs/wi/wikipedia-literature/versions/2026.10.2/manifest.json +193 -0
  56. package/dist/catalog/knowledge-catalogs/wi/wikipedia-mathematics/versions/2026.10.2/manifest.json +189 -0
  57. package/dist/catalog/knowledge-catalogs/wi/wikipedia-medicine/versions/2026.10.2/manifest.json +185 -0
  58. package/dist/catalog/knowledge-catalogs/wi/wikipedia-military/versions/2026.10.2/manifest.json +189 -0
  59. package/dist/catalog/knowledge-catalogs/wi/wikipedia-music/versions/2026.10.2/manifest.json +197 -0
  60. package/dist/catalog/knowledge-catalogs/wi/wikipedia-people-arts/versions/2026.10.2/manifest.json +209 -0
  61. package/dist/catalog/knowledge-catalogs/wi/wikipedia-people-business/versions/2026.10.2/manifest.json +189 -0
  62. package/dist/catalog/knowledge-catalogs/wi/wikipedia-people-entertainment/versions/2026.10.2/manifest.json +201 -0
  63. package/dist/catalog/knowledge-catalogs/wi/wikipedia-people-military/versions/2026.10.2/manifest.json +185 -0
  64. package/dist/catalog/knowledge-catalogs/wi/wikipedia-people-other/versions/2026.10.2/manifest.json +165 -0
  65. package/dist/catalog/knowledge-catalogs/wi/wikipedia-people-politics/versions/2026.10.2/manifest.json +197 -0
  66. package/dist/catalog/knowledge-catalogs/wi/wikipedia-people-religion/versions/2026.10.2/manifest.json +201 -0
  67. package/dist/catalog/knowledge-catalogs/wi/wikipedia-people-science/versions/2026.10.2/manifest.json +205 -0
  68. package/dist/catalog/knowledge-catalogs/wi/wikipedia-people-sports/versions/2026.10.2/manifest.json +237 -0
  69. package/dist/catalog/knowledge-catalogs/wi/wikipedia-politics/versions/2026.10.2/manifest.json +197 -0
  70. package/dist/catalog/knowledge-catalogs/wi/wikipedia-religion-philosophy/versions/2026.10.2/manifest.json +189 -0
  71. package/dist/catalog/knowledge-catalogs/wi/wikipedia-science/versions/2026.10.2/manifest.json +185 -0
  72. package/dist/catalog/knowledge-catalogs/wi/wikipedia-sports/versions/2026.10.2/manifest.json +193 -0
  73. package/dist/catalog/knowledge-catalogs/wi/wikipedia-technology/versions/2026.10.2/manifest.json +185 -0
  74. package/dist/catalog/knowledge-catalogs/wi/wikipedia-transportation/versions/2026.10.2/manifest.json +189 -0
  75. package/dist/catalog/provenance.json +2 -1
  76. package/dist/engine-components.js +2 -2
  77. package/dist/engines/electron-native-reuse.d.ts +7 -1
  78. package/dist/engines/electron-native-reuse.js +12 -4
  79. package/dist/engines/native-file-manifest.json +546 -406
  80. package/dist/engines/native-manifest.d.ts +4 -4
  81. package/dist/engines/native-manifest.js +36 -36
  82. package/dist/engines/resolver.js +1 -0
  83. package/dist/http/auth-context.d.ts +12 -0
  84. package/dist/http/routes/image-ensure-routes.d.ts +4 -0
  85. package/dist/http/routes/image-ensure-routes.js +102 -0
  86. package/dist/http/routes/v1-image-styles.d.ts +5 -0
  87. package/dist/http/routes/v1-image-styles.js +91 -0
  88. package/dist/http/routes/v1-images.d.ts +3 -1
  89. package/dist/http/routes/v1-images.js +48 -99
  90. package/dist/inference-service.js +2 -1
  91. package/dist/knowledge/manager.d.ts +3 -27
  92. package/dist/knowledge/manager.js +8 -364
  93. package/dist/knowledge/unified-search.d.ts +61 -0
  94. package/dist/knowledge/unified-search.js +373 -0
  95. package/dist/knowledge/worker-host.js +2 -0
  96. package/dist/machine-engine/inspect-runtime.js +12 -3
  97. package/dist/memory/embed-core.js +3 -0
  98. package/dist/memory/embeddings.js +2 -0
  99. package/dist/models/image-fit.js +2 -2
  100. package/dist/models/weight-facts.d.ts +5 -0
  101. package/dist/models/weight-facts.js +5 -2
  102. package/dist/providers/ds4/build-provider.js +1 -1
  103. package/dist/providers/ds4/residency.js +3 -3
  104. package/dist/providers/image/img2img-support.d.ts +36 -1
  105. package/dist/providers/image/img2img-support.js +30 -1
  106. package/dist/providers/image/local-engine.d.ts +1 -0
  107. package/dist/providers/image/local-engine.js +8 -2
  108. package/dist/providers/image/local-factory.d.ts +10 -1
  109. package/dist/providers/image/local-factory.js +33 -6
  110. package/dist/providers/image/mock.d.ts +8 -1
  111. package/dist/providers/image/mock.js +46 -1
  112. package/dist/providers/image/pull-registry.d.ts +25 -3
  113. package/dist/providers/image/pull-registry.js +96 -34
  114. package/dist/providers/image/sd-cpp.d.ts +63 -4
  115. package/dist/providers/image/sd-cpp.js +368 -38
  116. package/dist/providers/image/types.d.ts +76 -4
  117. package/dist/providers/image/types.js +8 -0
  118. package/dist/providers/llama-cpp/build-provider.js +3 -2
  119. package/dist/providers/llama-cpp/cache-adapter.d.ts +3 -0
  120. package/dist/providers/llama-cpp/cache-adapter.js +18 -4
  121. package/dist/providers/llama-cpp/devices.js +2 -2
  122. package/dist/providers/llama-cpp/engine-flags.d.ts +5 -0
  123. package/dist/providers/llama-cpp/engine-flags.js +5 -2
  124. package/dist/providers/mlx/async-file-handoff.d.ts +9 -1
  125. package/dist/providers/mlx/async-file-handoff.js +32 -4
  126. package/dist/providers/mlx/provider.js +41 -20
  127. package/dist/providers/mlx/python/gezel_mlx_server.py +9 -2
  128. package/dist/providers/mlx/python/spec_decode.py +44 -5
  129. package/dist/providers/mlx/runtime-diagnostics.d.ts +6 -0
  130. package/dist/providers/mlx/runtime-diagnostics.js +18 -1
  131. package/dist/providers/mlx/tool-call-protocol.d.ts +4 -3
  132. package/dist/providers/mlx/tool-call-protocol.js +4 -3
  133. package/dist/providers/native/capacity-broker.d.ts +1 -1
  134. package/dist/providers/native/capacity-broker.js +8 -8
  135. package/dist/providers/native/capacity-budget.js +5 -4
  136. package/dist/providers/native/device-capacity-ledger.d.ts +5 -1
  137. package/dist/providers/native/device-capacity-ledger.js +60 -14
  138. package/dist/providers/native/device-capacity.js +4 -3
  139. package/dist/providers/native/local-engine-runtime.js +4 -4
  140. package/dist/providers/native/process-identity.d.ts +40 -0
  141. package/dist/providers/native/process-identity.js +156 -0
  142. package/dist/relevance/relevance-core.js +2 -0
  143. package/dist/relevance/relevance-model.js +2 -0
  144. package/dist/relevance/settings.js +2 -2
  145. package/dist/runtime-lock.d.ts +19 -5
  146. package/dist/runtime-lock.js +116 -26
  147. package/dist/system/host-memory.d.ts +46 -0
  148. package/dist/system/host-memory.js +89 -0
  149. package/dist/system/memory.js +5 -4
  150. package/dist/transformers-cache.js +2 -0
  151. package/dist/utils/download-common.d.ts +1 -1
  152. package/package.json +8 -8
@@ -8,7 +8,7 @@
8
8
  * class never opens a catalog database itself.
9
9
  */
10
10
  import type { GezelConfig, IncompleteKnowledgeDownload, KnowledgeActiveInstall, KnowledgeAvailableCatalog, KnowledgeCatalogRef, KnowledgeCatalogStatus, KnowledgeEmbeddingProfile, KnowledgeInstallEvent, KnowledgeInstallJob, KnowledgeInstallRequest, KnowledgePassageRead, KnowledgeSemanticSearchMode, KnowledgeUpdateCandidate, KnowledgeUri, ProjectKnowledgeCatalogs, UnifiedSearchResult } from '@bendyline/gezel';
11
- import { type KnowledgeNearbyRequest, type KnowledgeNearbyResponse, type KnowledgeRadius } from '@bendyline/gezel';
11
+ import { type KnowledgeNearbyRequest, type KnowledgeNearbyResponse } from '@bendyline/gezel';
12
12
  import type { HistoryEvent } from '@bendyline/gezel';
13
13
  import type { CatalogService } from '@bendyline/gezel-catalog';
14
14
  import { type ModelDownloadProgress } from '@bendyline/gezel-knowledge';
@@ -22,6 +22,7 @@ import { ChatModelInstallRegistry } from '../models/install-registry.js';
22
22
  import type { KnowledgeCatalogHost, KnowledgeChunk } from './catalog-host.js';
23
23
  import { KnowledgeRegistry } from './registry.js';
24
24
  import type { SharedKnowledgeInstaller } from './shared-install-contract.js';
25
+ import { type UnifiedSearchOptions } from './unified-search.js';
25
26
  import { type KnowledgeVectorFloors } from './vector-floors.js';
26
27
  export type { KnowledgeCatalogStatus, KnowledgeInstallJob, KnowledgeSemanticSearchMode };
27
28
  export type KnowledgeInstallRequestSource = KnowledgeInstallRequest['source'];
@@ -245,31 +246,7 @@ export declare class KnowledgeManager {
245
246
  * Search each catalog in its embedding space and fuse lexical/vector ranks.
246
247
  * Spatial eligibility applies inside workers before candidate selection.
247
248
  */
248
- searchUnified(query: string, opts: {
249
- vector: number[] | null;
250
- maxResults: number;
251
- projectId?: string;
252
- spatial?: KnowledgeRadius;
253
- catalogs?: readonly string[];
254
- /** Search a profile group keyword-only when its query model is not ready by then. */
255
- queryEmbedBudgetMs?: number;
256
- /** Query cached profile models only, falling back to keywords when absent. */
257
- localModelsOnly?: boolean;
258
- /**
259
- * Include media rows (images, audio and video windows) as results of
260
- * their own. Explicit search asks; proactive injection does not.
261
- */
262
- media?: boolean;
263
- /**
264
- * Also return up to this many of a document's other passages that the
265
- * search found, after its best one. Fusion otherwise keeps one passage
266
- * per document, chosen by vector similarity alone, so an article's name-
267
- * dense infobox can stand in for the section a reranker would choose.
268
- */
269
- chunksPerDocument?: number;
270
- }): Promise<UnifiedSearchResult[]>;
271
- /** A document the lexical arms named but no chunk carries: cite it with its lead paragraph. */
272
- private toDocumentResult;
249
+ searchUnified(query: string, opts: UnifiedSearchOptions): Promise<UnifiedSearchResult[]>;
273
250
  /** A catalog's vector floor, or null when its hits are keyword-only or its scale unmeasured. */
274
251
  private vectorFloorFor;
275
252
  /**
@@ -285,7 +262,6 @@ export declare class KnowledgeManager {
285
262
  * next query gets vectors.
286
263
  */
287
264
  private embedForProfile;
288
- private toResult;
289
265
  /** Enabled+mounted catalogs, intersected with the project policy. */
290
266
  private activeCatalogKeys;
291
267
  }
@@ -26,19 +26,9 @@ import { resolveKnowledgeCatalogSource } from './catalog-source.js';
26
26
  import { KNOWLEDGE_DOWNLOAD_KEY_PATTERN, installKnowledgeCatalog, knowledgeDownloadKey, pruneOtherKnowledgeCatalogVersions, } from './install.js';
27
27
  import { KnowledgeRegistry } from './registry.js';
28
28
  import { KnowledgeSpatialCursorError, encodeNearbyCursor, nearbyCursorOffset, spatialDocumentIdentity, } from './spatial-query.js';
29
+ import { searchUnifiedCatalogs } from './unified-search.js';
29
30
  import { resolveKnowledgeVectorFloors } from './vector-floors.js';
30
31
  const log = createLogger('knowledge');
31
- /** Explicit-search shard budget (S) across every active catalog (§3.4). */
32
- const ROUTE_BUDGET_EXPLICIT = 6;
33
- /**
34
- * Shards added beyond that budget for pages the query names by title (the
35
- * catalog host's title-assisted routing): the centroids alone left the ABBA
36
- * article unscanned for "Who were the members of ABBA?". Two, not one per
37
- * catalog, so twenty mounted catalogs cannot add twenty scans.
38
- */
39
- const TITLE_ROUTE_EXPLICIT = 2;
40
- const FINAL_K = 24;
41
- /** Same cadence as the catalog download's own progress (download-with-retry). */
42
32
  const EMBEDDER_PROGRESS_INTERVAL_MS = 250;
43
33
  const DEFAULT_EMBEDDER_PREWARM_TIMEOUT_MS = 5 * 60_000;
44
34
  /** Finished jobs stay pollable for a minute: the CLI and older cards poll `/jobs/:id`. */
@@ -63,59 +53,6 @@ function semanticSearchModeFor(profile) {
63
53
  function searchModelOf(profile) {
64
54
  return { profileId: profile.id, repo: profile.model.repo };
65
55
  }
66
- /**
67
- * Explicit search fuses three arms per catalog by reciprocal rank (k = 60):
68
- * the vector chunks (best chunk per document, by rerank cosine), the
69
- * doc-title FTS hits, and the chunk-body FTS hits. Rank fusion instead of
70
- * score comparison because the arms' scores are not commensurable — e5
71
- * cosines sit in a narrow 0.7–0.9 band for everything, so a cosine-first
72
- * ordering buried an exact title match under any vaguely related passage
73
- * ("Who painted the Mona Lisa?" listed three other Mona Lisa copies above
74
- * the article whose title the question names). Two lexical arms agreeing
75
- * on a document now outrank one vector arm; a document every arm names
76
- * comes first.
77
- */
78
- const RRF_K = 60;
79
- const ARM_WEIGHTS = { vector: 1, docFts: 1, chunkFts: 0.5 };
80
- /**
81
- * Media rows fuse per row, not per document: a document's own text chunk
82
- * (cosine ≈ 0.9) would otherwise always stand in for its image (≈ 0.7). Each
83
- * modality is ranked on its own scale, past its own measured floor.
84
- */
85
- const MEDIA_ARM_WEIGHTS = { vector: 0.5, chunkFts: 0.25 };
86
- /** Media rows asked of each catalog, per modality, on an explicit search. */
87
- const MEDIA_K = 2;
88
- /** Media results one search returns at most, and per document. */
89
- const MEDIA_RESULT_CAP = 4;
90
- const MEDIA_PER_DOCUMENT = 1;
91
- /**
92
- * Fused documents per catalog when several are in scope: keeps one catalog
93
- * from monopolizing the merged list. A search scoped to one catalog has
94
- * nothing to share the list with, and the cap cut the Knowledge browser's
95
- * search for "blob" in an Azure-docs catalog — 348 matching titles — to six.
96
- */
97
- const PER_CATALOG_CAP = 6;
98
- /** Lexical candidates fetched per arm before fusion, at the least. */
99
- const DOC_FTS_MIN = 6;
100
- const CHUNK_FTS_MIN = 8;
101
- /** Rank-anchored relevance for a fused knowledge result: the best hit is a strong 1.0, decaying RRF-style. */
102
- function fusedRankRelevance(rank) {
103
- return 11 / (11 + rank);
104
- }
105
- /**
106
- * The first prose paragraph of a document body, for a hit that no chunk
107
- * carries (a title-only match): the model gets something to read, not just
108
- * a summary line. Skips the title heading and a key-facts list.
109
- */
110
- function leadExcerpt(markdown, max = 600) {
111
- const body = markdown.replace(/^#\s[^\n]*\n+/, '');
112
- const paragraph = body
113
- .split(/\n\s*\n/)
114
- .map((part) => part.trim())
115
- .find((part) => part.length > 0 && !part.startsWith('#') && !part.startsWith('- ')) ??
116
- body.trim();
117
- return paragraph.length > max ? `${paragraph.slice(0, max - 1)}…` : paragraph;
118
- }
119
56
  function errorMessage(err) {
120
57
  return err instanceof Error ? err.message : String(err);
121
58
  }
@@ -1233,282 +1170,13 @@ export class KnowledgeManager {
1233
1170
  */
1234
1171
  async searchUnified(query, opts) {
1235
1172
  const active = (await this.activeCatalogKeys(opts.projectId, Boolean(opts.spatial))).filter((key) => !opts.catalogs || opts.catalogs.includes(this.mountedByKey.get(key).ref.catalogId));
1236
- if (active.length === 0)
1237
- return [];
1238
- const groups = [];
1239
- const shared = [];
1240
- const keyword = [];
1241
- const byProfile = new Map();
1242
- for (const key of active) {
1243
- const info = this.mountedByKey.get(key);
1244
- if (!info)
1245
- continue;
1246
- if (info.semanticSearch === 'shared' && !opts.localModelsOnly)
1247
- shared.push(key);
1248
- else if (info.semanticSearch === 'profile' || info.semanticSearch === 'shared') {
1249
- const group = byProfile.get(info.embedding.id) ?? { profile: info.embedding, keys: [] };
1250
- group.keys.push(key);
1251
- byProfile.set(info.embedding.id, group);
1252
- }
1253
- else
1254
- keyword.push(key);
1255
- }
1256
- if (shared.length > 0) {
1257
- groups.push({
1258
- keys: shared,
1259
- ...(opts.vector ? { vector: Float32Array.from(opts.vector) } : {}),
1260
- });
1261
- }
1262
- for (const group of byProfile.values()) {
1263
- const vector = await this.embedForProfile(query, group.profile, opts.queryEmbedBudgetMs, opts.localModelsOnly);
1264
- groups.push({ keys: group.keys, ...(vector ? { vector } : {}) });
1265
- }
1266
- if (keyword.length > 0)
1267
- groups.push({ keys: keyword });
1268
- const response = {
1269
- chunks: [],
1270
- documents: [],
1271
- };
1272
- for (const group of groups) {
1273
- const request = {
1274
- query,
1275
- shardBudget: ROUTE_BUDGET_EXPLICIT,
1276
- titleRouteShards: TITLE_ROUTE_EXPLICIT,
1277
- finalK: FINAL_K,
1278
- includeChunkFts: true,
1279
- catalogKeys: group.keys,
1280
- // A pool no deeper than the page asked for: six title hits could
1281
- // never fill a twenty-result browse search.
1282
- docFtsLimit: Math.max(DOC_FTS_MIN, opts.maxResults),
1283
- chunkFtsLimit: Math.max(CHUNK_FTS_MIN, opts.maxResults),
1284
- spatial: opts.spatial,
1285
- };
1286
- let part;
1287
- try {
1288
- part = await this.opts.host.search(group.vector
1289
- ? { ...request, vector: group.vector, ...(opts.media ? { mediaK: MEDIA_K } : {}) }
1290
- : request);
1291
- }
1292
- catch (error) {
1293
- // A catalog that refuses the query vector (a width the catalog does
1294
- // not store, say) is an embedding failure, not a search failure: the
1295
- // worker forwards only the message, so any vector-arm error falls
1296
- // back to the keyword arms for this group.
1297
- if (!group.vector)
1298
- throw error;
1299
- log.warn(`[knowledge] semantic search failed for ${group.keys.join(', ')}; keyword-only: ${error instanceof Error ? error.message : String(error)}`);
1300
- part = await this.opts.host.search(request);
1301
- }
1302
- response.chunks.push(...part.chunks);
1303
- response.documents.push(...part.documents);
1304
- }
1305
- // ── fuse the arms per document ──────────────────────────────────────
1306
- const fused = new Map();
1307
- const bump = (catalogKey, documentId, weight, rank, chunk) => {
1308
- const key = `${catalogKey}\u0000${documentId}`;
1309
- const entry = fused.get(key) ?? { catalogKey, documentId, score: 0 };
1310
- entry.score += weight / (RRF_K + rank);
1311
- // The document's representative chunk: its best vector chunk, else
1312
- // whichever chunk-FTS row named it.
1313
- if (chunk && (!entry.chunk || (chunk.cosine ?? -1) > (entry.chunk.cosine ?? -1))) {
1314
- entry.chunk = chunk;
1315
- }
1316
- fused.set(key, entry);
1317
- };
1318
- // Every text passage the arms found, per document, best vector match first,
1319
- // for `chunksPerDocument`. Fusion still ranks documents, not passages.
1320
- const passagesByDocument = new Map();
1321
- if ((opts.chunksPerDocument ?? 1) > 1) {
1322
- for (const hit of response.chunks
1323
- .filter((h) => !h.media)
1324
- .sort((a, b) => (b.cosine ?? -1) - (a.cosine ?? -1))) {
1325
- const key = `${hit.catalogKey}\u0000${hit.documentId}`;
1326
- const passages = passagesByDocument.get(key) ?? [];
1327
- if (!passages.some((known) => known.chunkUid === hit.chunkUid))
1328
- passages.push(hit);
1329
- passagesByDocument.set(key, passages);
1330
- }
1331
- }
1332
- // Vector arm: one rank per document, in rerank-cosine order. A hit under
1333
- // its profile's floor is not evidence at all — KNN always returns its
1334
- // nearest rows, and a rank-0 row fuses to relevance 1.0 however far away
1335
- // it is (see vector-floors.ts).
1336
- const textChunks = response.chunks.filter((h) => !h.media);
1337
- const mediaChunks = response.chunks.filter((h) => h.media);
1338
- const seenVector = new Set();
1339
- let vectorRank = 0;
1340
- for (const hit of textChunks
1341
- .filter((h) => h.cosine !== undefined)
1342
- .sort((a, b) => (b.cosine ?? 0) - (a.cosine ?? 0))) {
1343
- const key = `${hit.catalogKey}\u0000${hit.documentId}`;
1344
- if (seenVector.has(key))
1345
- continue;
1346
- const cosine = hit.cosine ?? 0;
1347
- const floor = this.vectorFloorFor(hit.catalogKey);
1348
- if (floor !== null && cosine < floor)
1349
- continue;
1350
- seenVector.add(key);
1351
- bump(hit.catalogKey, hit.documentId, ARM_WEIGHTS.vector, vectorRank++, hit);
1352
- const entry = fused.get(key);
1353
- if (entry && floor !== null && cosine > (entry.similarity ?? Number.NEGATIVE_INFINITY)) {
1354
- entry.similarity = cosine;
1355
- }
1356
- }
1357
- // Chunk-body FTS arm: BM25 order as the shards returned it, ranked per catalog.
1358
- const chunkFtsRank = new Map();
1359
- const seenChunkFts = new Set();
1360
- for (const hit of textChunks.filter((h) => h.cosine === undefined)) {
1361
- const key = `${hit.catalogKey}\u0000${hit.documentId}`;
1362
- if (seenChunkFts.has(key))
1363
- continue;
1364
- seenChunkFts.add(key);
1365
- const rank = chunkFtsRank.get(hit.catalogKey) ?? 0;
1366
- chunkFtsRank.set(hit.catalogKey, rank + 1);
1367
- bump(hit.catalogKey, hit.documentId, ARM_WEIGHTS.chunkFts, rank, hit);
1368
- }
1369
- // Doc-title FTS arm: exact-name evidence, ranked per catalog by the host.
1370
- for (const doc of response.documents) {
1371
- bump(doc.catalogKey, doc.documentId, ARM_WEIGHTS.docFts, doc.rank);
1372
- }
1373
- // Media rows: their own vector arm per modality (a modality with no
1374
- // measured floor gives no vector evidence — nearest-neighbour search
1375
- // always returns some image, and a media row has no query words to be
1376
- // grounded in) and a caption FTS arm.
1377
- const mediaFused = new Map();
1378
- const bumpMedia = (hit, weight, rank) => {
1379
- const key = `${hit.catalogKey}\u0000${hit.chunkUid}`;
1380
- const entry = mediaFused.get(key) ?? {
1381
- catalogKey: hit.catalogKey,
1382
- documentId: hit.documentId,
1383
- score: 0,
1384
- chunk: hit,
1385
- };
1386
- entry.score += weight / (RRF_K + rank);
1387
- if ((hit.cosine ?? -1) > (entry.chunk?.cosine ?? -1))
1388
- entry.chunk = hit;
1389
- mediaFused.set(key, entry);
1390
- };
1391
- const mediaVectorRank = new Map();
1392
- for (const hit of mediaChunks
1393
- .filter((h) => h.cosine !== undefined)
1394
- .sort((a, b) => (b.cosine ?? 0) - (a.cosine ?? 0))) {
1395
- const modality = hit.media?.modality;
1396
- const floor = this.vectorFloorFor(hit.catalogKey, modality);
1397
- if (floor === null || (hit.cosine ?? 0) < floor)
1398
- continue;
1399
- const rank = mediaVectorRank.get(modality) ?? 0;
1400
- mediaVectorRank.set(modality, rank + 1);
1401
- bumpMedia(hit, MEDIA_ARM_WEIGHTS.vector, rank);
1402
- const entry = mediaFused.get(`${hit.catalogKey}\u0000${hit.chunkUid}`);
1403
- if (entry)
1404
- entry.similarity = Math.max(entry.similarity ?? -1, hit.cosine ?? 0);
1405
- }
1406
- let mediaFtsRank = 0;
1407
- for (const hit of mediaChunks.filter((h) => h.cosine === undefined)) {
1408
- bumpMedia(hit, MEDIA_ARM_WEIGHTS.chunkFts, mediaFtsRank++);
1409
- }
1410
- const ordered = [...fused.values(), ...mediaFused.values()].sort((a, b) => b.score - a.score ||
1411
- (b.chunk?.cosine ?? -1) - (a.chunk?.cosine ?? -1) ||
1412
- a.documentId.localeCompare(b.documentId));
1413
- const matches = new Map([...response.documents, ...response.chunks]
1414
- .filter((hit) => hit.matchedLocation)
1415
- .map((hit) => [
1416
- `${hit.catalogKey}\u0000${hit.documentId}`,
1417
- { distanceMeters: hit.distanceMeters, matchedLocation: hit.matchedLocation },
1418
- ]));
1419
- const seenSpatial = new Set();
1420
- const out = [];
1421
- const perCatalogCount = new Map();
1422
- const capPerCatalog = !opts.spatial && active.length > 1;
1423
- const mediaPerDocument = new Map();
1424
- let mediaResults = 0;
1425
- for (const entry of ordered) {
1426
- const info = this.mountedByKey.get(entry.catalogKey);
1427
- if (!info)
1428
- continue;
1429
- if (entry.chunk?.media) {
1430
- const docKey = `${entry.catalogKey}\u0000${entry.documentId}`;
1431
- const forDocument = mediaPerDocument.get(docKey) ?? 0;
1432
- if (mediaResults >= MEDIA_RESULT_CAP || forDocument >= MEDIA_PER_DOCUMENT)
1433
- continue;
1434
- const evidence = entry.similarity !== undefined
1435
- ? { arm: 'vector', similarity: entry.similarity }
1436
- : { arm: 'fts' };
1437
- mediaPerDocument.set(docKey, forDocument + 1);
1438
- mediaResults++;
1439
- out.push(this.toResult(info, entry.chunk, fusedRankRelevance(out.length), evidence));
1440
- if (out.length >= Math.max(10, opts.maxResults))
1441
- break;
1442
- continue;
1443
- }
1444
- const count = perCatalogCount.get(entry.catalogKey) ?? 0;
1445
- if (capPerCatalog && count >= PER_CATALOG_CAP)
1446
- continue;
1447
- const spatialIdentity = spatialDocumentIdentity(info.ref.publisherId, info.ref.catalogId, entry.documentId);
1448
- if (opts.spatial && seenSpatial.has(spatialIdentity))
1449
- continue;
1450
- const relevance = fusedRankRelevance(out.length);
1451
- // `vector` only when the hit cleared a measured floor; everything else
1452
- // is lexical evidence, which proactive injection asks to be grounded.
1453
- const evidence = entry.similarity !== undefined
1454
- ? { arm: 'vector', similarity: entry.similarity }
1455
- : { arm: 'fts' };
1456
- const result = entry.chunk
1457
- ? this.toResult(info, entry.chunk, relevance, evidence)
1458
- : await this.toDocumentResult(info, entry.documentId, relevance, evidence);
1459
- if (!result)
1460
- continue;
1461
- perCatalogCount.set(entry.catalogKey, count + 1);
1462
- seenSpatial.add(spatialIdentity);
1463
- const docKey = `${entry.catalogKey}\u0000${entry.documentId}`;
1464
- out.push({ ...result, ...matches.get(docKey) });
1465
- if (out.length >= Math.max(10, opts.maxResults))
1466
- break;
1467
- // The document's other passages, each labelled by its own evidence: a
1468
- // sibling is vector evidence only if it clears the floor by itself.
1469
- const siblings = (passagesByDocument.get(docKey) ?? [])
1470
- .filter((hit) => hit.chunkUid !== entry.chunk?.chunkUid)
1471
- .slice(0, Math.max(0, (opts.chunksPerDocument ?? 1) - 1));
1472
- const floor = this.vectorFloorFor(entry.catalogKey);
1473
- for (const sibling of siblings) {
1474
- const own = floor !== null && sibling.cosine !== undefined && sibling.cosine >= floor
1475
- ? { arm: 'vector', similarity: sibling.cosine }
1476
- : { arm: 'fts' };
1477
- out.push(this.toResult(info, sibling, fusedRankRelevance(out.length), own));
1478
- if (out.length >= Math.max(10, opts.maxResults))
1479
- break;
1480
- }
1481
- if (out.length >= Math.max(10, opts.maxResults))
1482
- break;
1483
- }
1484
- return out;
1485
- }
1486
- /** A document the lexical arms named but no chunk carries: cite it with its lead paragraph. */
1487
- async toDocumentResult(info, documentId, relevance, evidence) {
1488
- const doc = await this.opts.host.getDocument(info.key, documentId).catch(() => null);
1489
- if (!doc)
1490
- return null;
1491
- const snippet = leadExcerpt(doc.markdown) || doc.summary || '';
1492
- return {
1493
- kind: 'knowledge',
1494
- id: `knowledge:${info.ref.catalogId}:doc:${documentId}`,
1495
- title: doc.title,
1496
- subtitle: `${info.name} · ${info.topicNames.get(doc.topicId) ?? doc.topicId}`,
1497
- ...(snippet ? { snippet } : {}),
1498
- retrievalSource: 'knowledge',
1499
- catalogId: info.ref.catalogId,
1500
- catalogVersion: info.ref.version,
1501
- documentId,
1502
- topicPath: [info.topicNames.get(doc.topicId) ?? doc.topicId],
1503
- uri: formatKnowledgeUri({
1504
- publisherId: info.ref.publisherId,
1505
- catalogId: info.ref.catalogId,
1506
- documentId,
1507
- }),
1508
- ...(doc.sourceUrl ? { sourceUrl: doc.sourceUrl } : {}),
1509
- ...evidence,
1510
- ...scoreResult('knowledge', relevance),
1511
- };
1173
+ return searchUnifiedCatalogs({
1174
+ catalog: (key) => this.mountedByKey.get(key),
1175
+ host: this.opts.host,
1176
+ embedForProfile: (text, profile, budgetMs, localFilesOnly) => this.embedForProfile(text, profile, budgetMs, localFilesOnly),
1177
+ vectorFloorFor: (key, modality) => this.vectorFloorFor(key, modality),
1178
+ warn: (message) => log.warn(message),
1179
+ }, active, query, opts);
1512
1180
  }
1513
1181
  /** A catalog's vector floor, or null when its hits are keyword-only or its scale unmeasured. */
1514
1182
  vectorFloorFor(catalogKey, modality) {
@@ -1570,30 +1238,6 @@ export class KnowledgeManager {
1570
1238
  clearTimeout(timer);
1571
1239
  }
1572
1240
  }
1573
- toResult(info, hit, relevance, evidence) {
1574
- return {
1575
- kind: 'knowledge',
1576
- id: `knowledge:${info.ref.catalogId}:${hit.chunkUid}`,
1577
- title: hit.headingPath.length > 0 ? `${hit.title} › ${hit.headingPath.join(' › ')}` : hit.title,
1578
- subtitle: info.name,
1579
- snippet: hit.text,
1580
- retrievalSource: 'knowledge',
1581
- catalogId: info.ref.catalogId,
1582
- catalogVersion: info.ref.version,
1583
- documentId: hit.documentId,
1584
- uri: formatKnowledgeUri({
1585
- publisherId: info.ref.publisherId,
1586
- catalogId: info.ref.catalogId,
1587
- documentId: hit.documentId,
1588
- fragment: { chunk: hit.chunkUid },
1589
- }),
1590
- line: hit.lineStart,
1591
- lineEnd: hit.lineEnd,
1592
- ...(hit.media ? { media: hit.media } : {}),
1593
- ...evidence,
1594
- ...scoreResult('knowledge', relevance),
1595
- };
1596
- }
1597
1241
  /** Enabled+mounted catalogs, intersected with the project policy. */
1598
1242
  async activeCatalogKeys(projectId, failClosed = false) {
1599
1243
  const mounted = [...this.mountedByKey.values()];
@@ -0,0 +1,61 @@
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 { type KnowledgeCatalogRef, type KnowledgeEmbeddingProfile, type KnowledgeRadius, type KnowledgeSemanticSearchMode, type UnifiedSearchResult } from '@bendyline/gezel';
8
+ import type { GlobalSearchHit, KnowledgeCatalogHost } from './catalog-host.js';
9
+ /** What fusion and result shaping read about a mounted catalog. */
10
+ export interface UnifiedSearchCatalog {
11
+ ref: KnowledgeCatalogRef;
12
+ key: string;
13
+ name: string;
14
+ semanticSearch: KnowledgeSemanticSearchMode;
15
+ embedding: KnowledgeEmbeddingProfile;
16
+ /** topic id → display name, from the shipped TOC. */
17
+ topicNames: Map<string, string>;
18
+ }
19
+ export interface UnifiedSearchDeps {
20
+ catalog(key: string): UnifiedSearchCatalog | undefined;
21
+ host: Pick<KnowledgeCatalogHost, 'search' | 'getDocument'>;
22
+ /** The profile's query vector, or undefined to search that group by keyword. */
23
+ embedForProfile(query: string, profile: KnowledgeEmbeddingProfile, budgetMs?: number, localFilesOnly?: boolean): Promise<Float32Array | undefined>;
24
+ /** A catalog's measured vector floor, or null when its hits are keyword-only. */
25
+ vectorFloorFor(catalogKey: string, modality?: 'image' | 'video' | 'audio'): number | null;
26
+ warn(message: string): void;
27
+ }
28
+ export type UnifiedSearchOptions = {
29
+ vector: number[] | null;
30
+ maxResults: number;
31
+ projectId?: string;
32
+ spatial?: KnowledgeRadius;
33
+ catalogs?: readonly string[];
34
+ /** Search a profile group keyword-only when its query model is not ready by then. */
35
+ queryEmbedBudgetMs?: number;
36
+ /** Query cached profile models only, falling back to keywords when absent. */
37
+ localModelsOnly?: boolean;
38
+ /**
39
+ * Include media rows (images, audio and video windows) as results of
40
+ * their own. Explicit search asks; proactive injection does not.
41
+ */
42
+ media?: boolean;
43
+ /**
44
+ * Also return up to this many of a document's other passages that the
45
+ * search found, after its best one. Fusion otherwise keeps one passage
46
+ * per document, chosen by vector similarity alone, so an article's name-
47
+ * dense infobox can stand in for the section a reranker would choose.
48
+ */
49
+ chunksPerDocument?: number;
50
+ };
51
+ /** Which arm vouches for a fused knowledge hit — see the vector arm in `searchUnified`. */
52
+ export type KnowledgeEvidence = {
53
+ arm: 'vector';
54
+ similarity: number;
55
+ } | {
56
+ arm: 'fts';
57
+ };
58
+ export declare function searchUnifiedCatalogs(deps: UnifiedSearchDeps, active: readonly string[], query: string, opts: UnifiedSearchOptions): Promise<UnifiedSearchResult[]>;
59
+ /** A document the lexical arms named but no chunk carries: cite it with its lead paragraph. */
60
+ export declare function knowledgeDocumentResult(host: Pick<KnowledgeCatalogHost, 'getDocument'>, info: UnifiedSearchCatalog, documentId: string, relevance: number, evidence: KnowledgeEvidence): Promise<UnifiedSearchResult | null>;
61
+ export declare function knowledgeChunkResult(info: UnifiedSearchCatalog, hit: GlobalSearchHit, relevance: number, evidence: KnowledgeEvidence): UnifiedSearchResult;