@malloy-publisher/server 0.0.231 → 0.0.232
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/README.docker.md +4 -0
- package/dist/app/api-doc.yaml +74 -3
- package/dist/app/assets/{EnvironmentPage-wa_EPkwK.js → EnvironmentPage-DXEaZIPx.js} +1 -1
- package/dist/app/assets/{HomePage-jnCrupQp.js → HomePage-kofsqpZt.js} +1 -1
- package/dist/app/assets/{LightMode-DYbwNULZ.js → LightMode-CNhIlIlJ.js} +1 -1
- package/dist/app/assets/{MainPage-CuJLrPNI.js → MainPage-Bgqo8jCy.js} +1 -1
- package/dist/app/assets/{MaterializationsPage-D_67x2ee.js → MaterializationsPage-CgBlgGz2.js} +1 -1
- package/dist/app/assets/{ModelPage-D5JtAWqR.js → ModelPage-B0TjoDtf.js} +1 -1
- package/dist/app/assets/{PackagePage-BRwtqUSG.js → PackagePage-BL8vnFj1.js} +1 -1
- package/dist/app/assets/{RouteError-CBNNrnSD.js → RouteError-BzPby0X2.js} +1 -1
- package/dist/app/assets/{ThemeEditorPage-CTCeBneA.js → ThemeEditorPage-CTEP_9r3.js} +1 -1
- package/dist/app/assets/{WorkbookPage-SN6f1RBm.js → WorkbookPage-BwM3BmKw.js} +1 -1
- package/dist/app/assets/{core-Dp3q5Ieu.es-CD5FvM2s.js → core-CK68iv6w.es-CpRxXBt7.js} +1 -1
- package/dist/app/assets/{index-C_tJstcx.js → index-B33zGctF.js} +15 -15
- package/dist/app/assets/{index-DU4r7GdU.js → index-BabP-V-S.js} +346 -321
- package/dist/app/assets/{index-B3Nn8Vm2.js → index-BkiWKaAF.js} +266 -265
- package/dist/app/assets/{index-BLCx1EdC.js → index-CmkW1MiE.js} +1 -1
- package/dist/app/assets/{index-CfmBVB6M.js → index-tXJXwdyj.js} +1 -1
- package/dist/app/index.html +1 -1
- package/dist/instrumentation.mjs +2 -0
- package/dist/package_load_worker.mjs +11 -1
- package/dist/server.mjs +3186 -433
- package/package.json +12 -12
- package/scripts/bake-duckdb-extensions.js +4 -1
- package/src/config.spec.ts +39 -0
- package/src/config.ts +135 -0
- package/src/controller/materialization.controller.spec.ts +62 -0
- package/src/controller/materialization.controller.ts +15 -0
- package/src/controller/package.controller.spec.ts +6 -0
- package/src/controller/package.controller.ts +7 -2
- package/src/errors.ts +19 -0
- package/src/logger.spec.ts +18 -1
- package/src/logger.ts +3 -1
- package/src/materialization_metrics.spec.ts +89 -4
- package/src/materialization_metrics.ts +155 -5
- package/src/mcp/skills/skills_bundle.json +1 -1
- package/src/mcp/tools/embedding_index.spec.ts +1236 -0
- package/src/mcp/tools/embedding_index.ts +808 -0
- package/src/mcp/tools/get_context_eval.ts +194 -45
- package/src/mcp/tools/get_context_tool.spec.ts +295 -2
- package/src/mcp/tools/get_context_tool.ts +159 -10
- package/src/pg_helpers.spec.ts +201 -0
- package/src/pg_helpers.ts +44 -5
- package/src/server.ts +24 -0
- package/src/service/build_plan.spec.ts +128 -2
- package/src/service/build_plan.ts +239 -17
- package/src/service/connection.ts +263 -7
- package/src/service/connection_config.spec.ts +48 -0
- package/src/service/connection_config.ts +21 -8
- package/src/service/connection_federation.spec.ts +184 -0
- package/src/service/embedding_provider.spec.ts +329 -0
- package/src/service/embedding_provider.ts +236 -0
- package/src/service/environment.ts +274 -12
- package/src/service/environment_store.spec.ts +678 -3
- package/src/service/environment_store.ts +449 -33
- package/src/service/environment_store_clone.spec.ts +350 -0
- package/src/service/manifest_loader.spec.ts +68 -13
- package/src/service/manifest_loader.ts +67 -19
- package/src/service/materialization_build_session.spec.ts +435 -0
- package/src/service/materialization_build_session.ts +681 -0
- package/src/service/materialization_eligibility.spec.ts +158 -0
- package/src/service/materialization_eligibility.ts +305 -0
- package/src/service/materialization_serve_transform.spec.ts +1003 -0
- package/src/service/materialization_serve_transform.ts +779 -0
- package/src/service/materialization_service.spec.ts +774 -7
- package/src/service/materialization_service.ts +1107 -42
- package/src/service/materialization_test_fixtures.ts +7 -0
- package/src/service/model.spec.ts +207 -0
- package/src/service/model.ts +540 -52
- package/src/service/model_storage_serve.spec.ts +193 -0
- package/src/service/model_storage_serve_joins.spec.ts +193 -0
- package/src/service/package.spec.ts +196 -0
- package/src/service/package.ts +385 -17
- package/src/service/persistence_policy.spec.ts +109 -0
- package/src/storage/duckdb/schema.ts +37 -0
- package/tests/fixtures/xlsx/database.xlsx +0 -0
- package/tests/integration/first_boot/readiness_line.integration.spec.ts +177 -0
- package/tests/integration/materialization/manifest_binding.integration.spec.ts +104 -0
- package/tests/integration/mcp/mcp_get_context_semantic.integration.spec.ts +235 -0
|
@@ -0,0 +1,808 @@
|
|
|
1
|
+
import { createHash } from "crypto";
|
|
2
|
+
import { E_ALREADY_LOCKED, Mutex, tryAcquire } from "async-mutex";
|
|
3
|
+
import { logger } from "../../logger";
|
|
4
|
+
import { DuckDBConnection } from "../../storage/duckdb/DuckDBConnection";
|
|
5
|
+
import type { Package } from "../../service/package";
|
|
6
|
+
import {
|
|
7
|
+
EmbeddingProvider,
|
|
8
|
+
EMBEDDING_BATCH_TIMEOUT_MS,
|
|
9
|
+
EMBEDDING_QUERY_TIMEOUT_MS,
|
|
10
|
+
prepareEmbeddingInput,
|
|
11
|
+
} from "../../service/embedding_provider";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Minimum cosine similarity for a semantic hit. Below this the entity is
|
|
15
|
+
* dropped, so a query about something the package does not model returns
|
|
16
|
+
* an empty result rather than the k least-unrelated entities. Agents are
|
|
17
|
+
* taught to treat an empty result as "not in this package"; unfiltered
|
|
18
|
+
* top-k would destroy that signal. 0.20 matches the hosted retrieval
|
|
19
|
+
* pipeline's min_score; tune against get_context_eval.ts.
|
|
20
|
+
*/
|
|
21
|
+
export const MIN_SIMILARITY = 0.2;
|
|
22
|
+
/**
|
|
23
|
+
* Packages with more entities than this stay lexical: the first embed of
|
|
24
|
+
* such a package would take minutes of provider calls and rate limits.
|
|
25
|
+
* The bundled examples sit around a few hundred entities.
|
|
26
|
+
*/
|
|
27
|
+
export const MAX_EMBEDDED_ENTITIES = 5_000;
|
|
28
|
+
/**
|
|
29
|
+
* After a provider failure the semantic path short-circuits to lexical
|
|
30
|
+
* for this long, so a down or misconfigured endpoint costs one timeout
|
|
31
|
+
* per window, not one per call.
|
|
32
|
+
*/
|
|
33
|
+
export const PROVIDER_FAILURE_COOLDOWN_MS = 60_000;
|
|
34
|
+
/**
|
|
35
|
+
* After a dims-mismatch purge, suppress further purges for this long. It
|
|
36
|
+
* MUST exceed PROVIDER_FAILURE_COOLDOWN_MS. A dims-mismatch backoff arms
|
|
37
|
+
* the cooldown, which then blocks every heal-reaching call for one
|
|
38
|
+
* cooldown window; if the two windows were equal, the first call past the
|
|
39
|
+
* cooldown would always find the purge guard expired too and re-purge,
|
|
40
|
+
* re-embedding the whole package once per cooldown window forever. With a
|
|
41
|
+
* longer window a durably dims-inconsistent provider (e.g. mid-migration
|
|
42
|
+
* replicas serving different dims under one model name) is throttled to
|
|
43
|
+
* at most one re-embed per this interval, staying lexical between, while
|
|
44
|
+
* still re-adopting a genuinely-new stable dimensionality within the
|
|
45
|
+
* window. Deliberately not "never re-purge": that would strand the cache
|
|
46
|
+
* on the old dims if the provider later settles on a new one.
|
|
47
|
+
*/
|
|
48
|
+
export const HEAL_PURGE_SUPPRESSION_MS = 10 * PROVIDER_FAILURE_COOLDOWN_MS;
|
|
49
|
+
|
|
50
|
+
/** The subset of the tool's Entity shape the index needs. */
|
|
51
|
+
export interface EmbeddableEntity {
|
|
52
|
+
kind: string;
|
|
53
|
+
name: string;
|
|
54
|
+
source: string | undefined;
|
|
55
|
+
modelPath: string;
|
|
56
|
+
// #(doc)-only text (see get_context_tool docOnlyText). Deliberately NOT
|
|
57
|
+
// the response `doc`, which can fall back to raw annotation lines and
|
|
58
|
+
// would leak #(authorize) predicates to the embedding provider.
|
|
59
|
+
embedDoc: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface SemanticHit {
|
|
63
|
+
kind: string;
|
|
64
|
+
name: string;
|
|
65
|
+
source: string | undefined;
|
|
66
|
+
score: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export type SemanticUnavailableReason =
|
|
70
|
+
| "cooldown"
|
|
71
|
+
| "too-many-entities"
|
|
72
|
+
| "indexing"
|
|
73
|
+
| "error";
|
|
74
|
+
|
|
75
|
+
export type SemanticSearchResult =
|
|
76
|
+
| { hits: SemanticHit[] }
|
|
77
|
+
| { unavailable: SemanticUnavailableReason };
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Turn an identifier into the words a person would search with:
|
|
81
|
+
* `dep_delay` -> "dep delay", `totalSales` -> "total sales". This is the
|
|
82
|
+
* heart of closing the lexical token gap; the embedding model then maps
|
|
83
|
+
* "departure delay" near "dep delay" where BM25 could not.
|
|
84
|
+
*/
|
|
85
|
+
export function humanizeName(name: string): string {
|
|
86
|
+
return name
|
|
87
|
+
.replace(/[_\-./]+/g, " ")
|
|
88
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
89
|
+
.toLowerCase()
|
|
90
|
+
.replace(/\s+/g, " ")
|
|
91
|
+
.trim();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The text embedded per entity: humanized name plus its `#(doc)` text.
|
|
96
|
+
* Deliberately minimal (no kind, no parent source); the recipe is
|
|
97
|
+
* eval-tunable via get_context_eval.ts. A punctuation-only identifier
|
|
98
|
+
* (`_` is legal Malloy) humanizes to nothing; fall back to the raw name
|
|
99
|
+
* so the embedded text stays meaningful. prepareEmbeddingInput (which
|
|
100
|
+
* both the hash and the request path apply) separately guarantees that
|
|
101
|
+
* even a whitespace-only name never reaches the provider as an empty
|
|
102
|
+
* input.
|
|
103
|
+
*/
|
|
104
|
+
export function embeddingText(entity: EmbeddableEntity): string {
|
|
105
|
+
const name = humanizeName(entity.name) || entity.name;
|
|
106
|
+
return entity.embedDoc ? `${name}: ${entity.embedDoc}` : name;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function contentHash(text: string): string {
|
|
110
|
+
return createHash("sha256").update(text).digest("hex");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** '' in the entity_source column encodes "no parent source". */
|
|
114
|
+
function sourceColumn(source: string | undefined): string {
|
|
115
|
+
return source ?? "";
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The stable key for one entity: `kind|source|name`. Shared by the index
|
|
120
|
+
* dedup, the WeakMap-free row identity, and the tool's result mapping so
|
|
121
|
+
* the format lives in exactly one place. Pass "" for a sourceless entity.
|
|
122
|
+
*/
|
|
123
|
+
export function entityRowKey(
|
|
124
|
+
kind: string,
|
|
125
|
+
source: string,
|
|
126
|
+
name: string,
|
|
127
|
+
): string {
|
|
128
|
+
return `${kind}|${source}|${name}`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Sync state, two layers.
|
|
132
|
+
//
|
|
133
|
+
// Per package NAME (`syncMeta`): a mutex serializing every read-diff-write
|
|
134
|
+
// section (sync AND the heal's purge) so a reload racing an in-flight sync
|
|
135
|
+
// cannot tear rows; a `generation` counter bumped by every purge, so a
|
|
136
|
+
// purge invalidates the memo of EVERY Package instance, not just the
|
|
137
|
+
// caller's (a reloaded instance's `done` memo must not survive a purge
|
|
138
|
+
// over a now-empty table); `lastPurgeAtMs`, which bounds how often the
|
|
139
|
+
// heal may purge (a backend serving inconsistent dimensionalities
|
|
140
|
+
// otherwise causes an unbounded purge / full-re-embed loop); and
|
|
141
|
+
// `failureAtMs`, the per-package provider cool-down. The cool-down is
|
|
142
|
+
// scoped per package, NOT global: a query timeout or dims-mismatch on
|
|
143
|
+
// one package must not force every other healthy, correctly-cached
|
|
144
|
+
// package to lexical for the window. If the endpoint is genuinely down,
|
|
145
|
+
// each package cools itself on its own first failed probe (one wasted
|
|
146
|
+
// probe per package per window, negligible at the entity counts a single
|
|
147
|
+
// Publisher serves).
|
|
148
|
+
//
|
|
149
|
+
// Per package INSTANCE (`syncState`, WeakMap): memoizes "this instance is
|
|
150
|
+
// synced" (reload swaps the instance, so entity-set staleness clears
|
|
151
|
+
// itself, same contract as the tool's lunr cache). A rejected sync
|
|
152
|
+
// promise is evicted so one transient failure is not permanent.
|
|
153
|
+
// `providerKey` records which model/dims request-config the sync used, so
|
|
154
|
+
// switching EMBEDDING_MODEL or EMBEDDING_DIMENSIONS re-syncs promptly.
|
|
155
|
+
interface PackageSyncMeta {
|
|
156
|
+
mutex: Mutex;
|
|
157
|
+
generation: number;
|
|
158
|
+
lastPurgeAtMs: number;
|
|
159
|
+
failureAtMs: number;
|
|
160
|
+
}
|
|
161
|
+
interface SyncState {
|
|
162
|
+
done: boolean;
|
|
163
|
+
providerKey: string;
|
|
164
|
+
generation: number;
|
|
165
|
+
}
|
|
166
|
+
const syncState = new WeakMap<Package, SyncState>();
|
|
167
|
+
const syncMeta = new Map<string, PackageSyncMeta>();
|
|
168
|
+
// Every generation value ever issued is globally unique (drawn from this
|
|
169
|
+
// counter, never incremented locally). That makes deleting a syncMeta
|
|
170
|
+
// entry safe: a re-minted meta for the same name can never coincide with
|
|
171
|
+
// a generation some live memo recorded under the old meta, which would
|
|
172
|
+
// let that memo be trusted over a table the deletion just emptied.
|
|
173
|
+
let generationCounter = 0;
|
|
174
|
+
const oversizeWarned = new Set<string>();
|
|
175
|
+
|
|
176
|
+
// Effective timing windows. Mutable only so tests can drive real expiry
|
|
177
|
+
// deterministically with small sleeps (the cooldown-vs-suppression
|
|
178
|
+
// relationship is real-time and not otherwise unit-observable); production
|
|
179
|
+
// always uses the exported constants.
|
|
180
|
+
let cooldownMs = PROVIDER_FAILURE_COOLDOWN_MS;
|
|
181
|
+
let purgeSuppressionMs = HEAL_PURGE_SUPPRESSION_MS;
|
|
182
|
+
|
|
183
|
+
function metaKey(environmentName: string, packageName: string): string {
|
|
184
|
+
return `${environmentName}\x00${packageName}`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function metaFor(
|
|
188
|
+
environmentName: string,
|
|
189
|
+
packageName: string,
|
|
190
|
+
): PackageSyncMeta {
|
|
191
|
+
const key = metaKey(environmentName, packageName);
|
|
192
|
+
let meta = syncMeta.get(key);
|
|
193
|
+
if (!meta) {
|
|
194
|
+
meta = {
|
|
195
|
+
mutex: new Mutex(),
|
|
196
|
+
generation: ++generationCounter,
|
|
197
|
+
lastPurgeAtMs: 0,
|
|
198
|
+
failureAtMs: 0,
|
|
199
|
+
};
|
|
200
|
+
syncMeta.set(key, meta);
|
|
201
|
+
}
|
|
202
|
+
return meta;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function markProviderFailure(meta: PackageSyncMeta): void {
|
|
206
|
+
meta.failureAtMs = Date.now();
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function inCooldown(meta: PackageSyncMeta): boolean {
|
|
210
|
+
return Date.now() - meta.failureAtMs < cooldownMs;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Test seam: forget cool-down, purge, timing, and oversize state. */
|
|
214
|
+
export function _resetEmbeddingIndexStateForTests(): void {
|
|
215
|
+
oversizeWarned.clear();
|
|
216
|
+
syncMeta.clear();
|
|
217
|
+
cooldownMs = PROVIDER_FAILURE_COOLDOWN_MS;
|
|
218
|
+
purgeSuppressionMs = HEAL_PURGE_SUPPRESSION_MS;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Test seam: shrink the timing windows to drive real expiry in tests. */
|
|
222
|
+
export function _setTimingForTests(t: {
|
|
223
|
+
cooldownMs?: number;
|
|
224
|
+
purgeSuppressionMs?: number;
|
|
225
|
+
}): void {
|
|
226
|
+
if (t.cooldownMs !== undefined) cooldownMs = t.cooldownMs;
|
|
227
|
+
if (t.purgeSuppressionMs !== undefined)
|
|
228
|
+
purgeSuppressionMs = t.purgeSuppressionMs;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Test seam: clear the per-package cool-downs, keeping sync metas. */
|
|
232
|
+
export function _clearProviderCooldownForTests(): void {
|
|
233
|
+
for (const meta of syncMeta.values()) {
|
|
234
|
+
meta.failureAtMs = 0;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Test seam: observe syncMeta growth (the churn-leak pin). */
|
|
239
|
+
export function _syncMetaSizeForTests(): number {
|
|
240
|
+
return syncMeta.size;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Test seam: read a package's lastPurgeAtMs (the heal-terminal pin). */
|
|
244
|
+
export function _lastPurgeAtMsForTests(
|
|
245
|
+
environmentName: string,
|
|
246
|
+
packageName: string,
|
|
247
|
+
): number | undefined {
|
|
248
|
+
return syncMeta.get(metaKey(environmentName, packageName))?.lastPurgeAtMs;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
interface ExistingRow {
|
|
252
|
+
entity_kind: string;
|
|
253
|
+
entity_source: string;
|
|
254
|
+
entity_name: string;
|
|
255
|
+
content_hash: string;
|
|
256
|
+
embedding_model: string;
|
|
257
|
+
dims: number;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Remove a deleted package's cached embeddings so package churn does not
|
|
262
|
+
* grow publisher.db forever. Called best-effort from the deletion paths;
|
|
263
|
+
* a missed cleanup is inert (every read is scoped by environment and
|
|
264
|
+
* package) and `--init` reclaims it. Runs under the package mutex so it
|
|
265
|
+
* cannot tear an in-flight sync, and bumps the generation so any live
|
|
266
|
+
* instance memo stops trusting its rows.
|
|
267
|
+
*/
|
|
268
|
+
export async function deletePackageEmbeddings(
|
|
269
|
+
db: DuckDBConnection,
|
|
270
|
+
environmentName: string,
|
|
271
|
+
packageName: string,
|
|
272
|
+
): Promise<void> {
|
|
273
|
+
const meta = metaFor(environmentName, packageName);
|
|
274
|
+
await meta.mutex.runExclusive(async () => {
|
|
275
|
+
// Same orphan guard as every other mutexed writer: a second delete
|
|
276
|
+
// queued on the old meta must not run again (its map removal would
|
|
277
|
+
// hit a re-minted meta's entry and orphan a live sync).
|
|
278
|
+
if (syncMeta.get(metaKey(environmentName, packageName)) !== meta) {
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
await db.run(
|
|
282
|
+
`DELETE FROM entity_embeddings
|
|
283
|
+
WHERE environment_name = ? AND package_name = ?`,
|
|
284
|
+
[environmentName, packageName],
|
|
285
|
+
);
|
|
286
|
+
meta.generation = ++generationCounter;
|
|
287
|
+
// Removing the entry keeps package churn from growing the map for
|
|
288
|
+
// the process lifetime; it is safe because generations are
|
|
289
|
+
// globally unique (a re-minted meta can never match a memo issued
|
|
290
|
+
// under this one). It MUST happen inside the mutexed section: the
|
|
291
|
+
// mutex hands off to the next queued waiter the moment this
|
|
292
|
+
// callback returns, before any outer continuation runs, and a
|
|
293
|
+
// queued sync's orphan guard has to see the entry already gone or
|
|
294
|
+
// it will re-embed rows for the deleted package.
|
|
295
|
+
syncMeta.delete(metaKey(environmentName, packageName));
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Remove a deleted environment's cached embeddings. Packages with live
|
|
301
|
+
* sync state are cleaned under their mutex; a final environment-wide
|
|
302
|
+
* sweep covers packages never queried in this process. The mutex gives
|
|
303
|
+
* mutual exclusion, not ordering: a getContext call already in flight at
|
|
304
|
+
* deletion time can cold-kick a sync that lands after the cleanup and
|
|
305
|
+
* rewrites its package's rows. Accepted: such rows are inert (the
|
|
306
|
+
* package is gone from every serving path), are reconciled if the name
|
|
307
|
+
* returns, and are reclaimed by --init.
|
|
308
|
+
*/
|
|
309
|
+
export async function deleteEnvironmentEmbeddings(
|
|
310
|
+
db: DuckDBConnection,
|
|
311
|
+
environmentName: string,
|
|
312
|
+
): Promise<void> {
|
|
313
|
+
const prefix = `${environmentName}\x00`;
|
|
314
|
+
for (const [key] of syncMeta) {
|
|
315
|
+
if (key.startsWith(prefix)) {
|
|
316
|
+
await deletePackageEmbeddings(
|
|
317
|
+
db,
|
|
318
|
+
environmentName,
|
|
319
|
+
key.slice(prefix.length),
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
await db.run(`DELETE FROM entity_embeddings WHERE environment_name = ?`, [
|
|
324
|
+
environmentName,
|
|
325
|
+
]);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Bring the entity_embeddings rows for one package in line with the
|
|
330
|
+
* current entity set: embed new/changed entities (content-hash diff, so
|
|
331
|
+
* unchanged entities never re-embed, across restarts too), upsert them,
|
|
332
|
+
* and delete rows for entities that no longer exist. Runs under the
|
|
333
|
+
* package-name mutex. Returns the package generation the sync ran under.
|
|
334
|
+
* Throws on provider or storage failure; partial writes are safe because
|
|
335
|
+
* the hash diff self-heals on the next sync.
|
|
336
|
+
*/
|
|
337
|
+
async function syncPackageEmbeddings(
|
|
338
|
+
db: DuckDBConnection,
|
|
339
|
+
provider: EmbeddingProvider,
|
|
340
|
+
environmentName: string,
|
|
341
|
+
packageName: string,
|
|
342
|
+
entities: EmbeddableEntity[],
|
|
343
|
+
): Promise<number> {
|
|
344
|
+
const meta = metaFor(environmentName, packageName);
|
|
345
|
+
return meta.mutex.runExclusive(async () => {
|
|
346
|
+
// The meta may have been orphaned while this sync waited on the
|
|
347
|
+
// mutex (deletePackageEmbeddings removes the map entry, e.g. the
|
|
348
|
+
// package was deleted with this call already in flight). A sync
|
|
349
|
+
// under an orphaned meta is no longer serialized against syncs
|
|
350
|
+
// under a re-minted meta for the same name, so it must not write.
|
|
351
|
+
// Aborting is safe: the caller's memo records this orphaned
|
|
352
|
+
// generation, which can never match a fresh meta's (generations
|
|
353
|
+
// are globally unique), so the next call re-syncs under the fresh
|
|
354
|
+
// meta.
|
|
355
|
+
if (syncMeta.get(metaKey(environmentName, packageName)) !== meta) {
|
|
356
|
+
logger.debug(
|
|
357
|
+
"[MCP Tool getContext] Skipping embedding sync for a deleted package",
|
|
358
|
+
{ environmentName, packageName },
|
|
359
|
+
);
|
|
360
|
+
return meta.generation;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Everything here runs under the package mutex: a purge (same
|
|
364
|
+
// mutex) either finished before this sync started or starts after
|
|
365
|
+
// it ends, so the generation moves mid-sync only via the bump at
|
|
366
|
+
// the bottom of this function.
|
|
367
|
+
const existingRows = await db.all<ExistingRow>(
|
|
368
|
+
`SELECT entity_kind, entity_source, entity_name, content_hash,
|
|
369
|
+
embedding_model, CAST(dims AS INTEGER) AS dims
|
|
370
|
+
FROM entity_embeddings
|
|
371
|
+
WHERE environment_name = ? AND package_name = ?`,
|
|
372
|
+
[environmentName, packageName],
|
|
373
|
+
);
|
|
374
|
+
const existing = new Map(
|
|
375
|
+
existingRows.map((r) => [
|
|
376
|
+
entityRowKey(r.entity_kind, r.entity_source, r.entity_name),
|
|
377
|
+
r,
|
|
378
|
+
]),
|
|
379
|
+
);
|
|
380
|
+
|
|
381
|
+
const desired = entities.map((entity) => {
|
|
382
|
+
const text = prepareEmbeddingInput(embeddingText(entity));
|
|
383
|
+
return { entity, text, hash: contentHash(text) };
|
|
384
|
+
});
|
|
385
|
+
const desiredKeys = new Set(
|
|
386
|
+
desired.map((d) =>
|
|
387
|
+
entityRowKey(
|
|
388
|
+
d.entity.kind,
|
|
389
|
+
sourceColumn(d.entity.source),
|
|
390
|
+
d.entity.name,
|
|
391
|
+
),
|
|
392
|
+
),
|
|
393
|
+
);
|
|
394
|
+
|
|
395
|
+
// A row is current when its text hash and model match; a model
|
|
396
|
+
// switch re-embeds in place (upsert) rather than colliding on the
|
|
397
|
+
// primary key. Dimensionality is deliberately NOT part of this
|
|
398
|
+
// check: `dims` stores the ACTUAL response vector length, and a
|
|
399
|
+
// provider that ignores the `dimensions` request parameter (e.g.
|
|
400
|
+
// Ollama) would otherwise mismatch the configured value forever
|
|
401
|
+
// and re-embed the whole package on every instance swap. A real
|
|
402
|
+
// dims change is caught at query time by the stale-row heal.
|
|
403
|
+
const toEmbed = desired.filter((d) => {
|
|
404
|
+
const row = existing.get(
|
|
405
|
+
entityRowKey(
|
|
406
|
+
d.entity.kind,
|
|
407
|
+
sourceColumn(d.entity.source),
|
|
408
|
+
d.entity.name,
|
|
409
|
+
),
|
|
410
|
+
);
|
|
411
|
+
if (!row) return true;
|
|
412
|
+
if (row.content_hash !== d.hash) return true;
|
|
413
|
+
if (row.embedding_model !== provider.model) return true;
|
|
414
|
+
return false;
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
// A sync that CHANGED rows invalidates every other instance's
|
|
418
|
+
// snapshot the same way a purge does: without the bump below, a
|
|
419
|
+
// call blocked behind this sync on the mutex could return its
|
|
420
|
+
// pre-sync cosine snapshot (possibly empty) marked semantic. The
|
|
421
|
+
// bump runs in a finally: a write failure mid-loop (disk full)
|
|
422
|
+
// has already changed rows, and those torn writes must invalidate
|
|
423
|
+
// snapshots too, even though this sync rejects.
|
|
424
|
+
let rowsChanged = false;
|
|
425
|
+
let deleted = 0;
|
|
426
|
+
try {
|
|
427
|
+
if (toEmbed.length > 0) {
|
|
428
|
+
const vectors = await provider.embedBatch(
|
|
429
|
+
toEmbed.map((d) => d.text),
|
|
430
|
+
EMBEDDING_BATCH_TIMEOUT_MS,
|
|
431
|
+
);
|
|
432
|
+
const now = new Date().toISOString();
|
|
433
|
+
for (let i = 0; i < toEmbed.length; i++) {
|
|
434
|
+
const d = toEmbed[i];
|
|
435
|
+
const vector = vectors[i];
|
|
436
|
+
await db.run(
|
|
437
|
+
`INSERT INTO entity_embeddings (
|
|
438
|
+
environment_name, package_name, entity_kind, entity_source,
|
|
439
|
+
entity_name, model_path, content_hash, embedding_model,
|
|
440
|
+
dims, embedding, updated_at
|
|
441
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CAST(? AS FLOAT[]), ?)
|
|
442
|
+
ON CONFLICT (environment_name, package_name, entity_kind, entity_source, entity_name)
|
|
443
|
+
DO UPDATE SET
|
|
444
|
+
model_path = EXCLUDED.model_path,
|
|
445
|
+
content_hash = EXCLUDED.content_hash,
|
|
446
|
+
embedding_model = EXCLUDED.embedding_model,
|
|
447
|
+
dims = EXCLUDED.dims,
|
|
448
|
+
embedding = EXCLUDED.embedding,
|
|
449
|
+
updated_at = EXCLUDED.updated_at`,
|
|
450
|
+
[
|
|
451
|
+
environmentName,
|
|
452
|
+
packageName,
|
|
453
|
+
d.entity.kind,
|
|
454
|
+
sourceColumn(d.entity.source),
|
|
455
|
+
d.entity.name,
|
|
456
|
+
d.entity.modelPath,
|
|
457
|
+
d.hash,
|
|
458
|
+
provider.model,
|
|
459
|
+
vector.length,
|
|
460
|
+
JSON.stringify(vector),
|
|
461
|
+
now,
|
|
462
|
+
],
|
|
463
|
+
);
|
|
464
|
+
rowsChanged = true;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
for (const [rowKey, row] of existing) {
|
|
469
|
+
if (!desiredKeys.has(rowKey)) {
|
|
470
|
+
await db.run(
|
|
471
|
+
`DELETE FROM entity_embeddings
|
|
472
|
+
WHERE environment_name = ? AND package_name = ?
|
|
473
|
+
AND entity_kind = ? AND entity_source = ? AND entity_name = ?`,
|
|
474
|
+
[
|
|
475
|
+
environmentName,
|
|
476
|
+
packageName,
|
|
477
|
+
row.entity_kind,
|
|
478
|
+
row.entity_source,
|
|
479
|
+
row.entity_name,
|
|
480
|
+
],
|
|
481
|
+
);
|
|
482
|
+
rowsChanged = true;
|
|
483
|
+
deleted++;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
} finally {
|
|
487
|
+
if (rowsChanged) {
|
|
488
|
+
meta.generation = ++generationCounter;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
logger.debug("[MCP Tool getContext] Synced entity embeddings", {
|
|
493
|
+
environmentName,
|
|
494
|
+
packageName,
|
|
495
|
+
entityCount: entities.length,
|
|
496
|
+
embedded: toEmbed.length,
|
|
497
|
+
deleted,
|
|
498
|
+
});
|
|
499
|
+
return meta.generation;
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Semantic retrieval for tier 4 of malloy_getContext. Returns ranked
|
|
505
|
+
* hits, or a reason the semantic path is unavailable so the caller can
|
|
506
|
+
* fall back to lexical. Never throws.
|
|
507
|
+
*
|
|
508
|
+
* Cold-start contract: the first call for a Package instance kicks off
|
|
509
|
+
* the embedding sync in the background and reports `indexing`, so no
|
|
510
|
+
* call ever waits on a bulk embed; subsequent calls are semantic once the
|
|
511
|
+
* sync lands.
|
|
512
|
+
*/
|
|
513
|
+
export async function trySemanticSearch(args: {
|
|
514
|
+
db: DuckDBConnection;
|
|
515
|
+
provider: EmbeddingProvider;
|
|
516
|
+
pkg: Package;
|
|
517
|
+
environmentName: string;
|
|
518
|
+
packageName: string;
|
|
519
|
+
entities: EmbeddableEntity[];
|
|
520
|
+
query: string;
|
|
521
|
+
limit: number;
|
|
522
|
+
sourceName?: string;
|
|
523
|
+
}): Promise<SemanticSearchResult> {
|
|
524
|
+
const {
|
|
525
|
+
db,
|
|
526
|
+
provider,
|
|
527
|
+
pkg,
|
|
528
|
+
environmentName,
|
|
529
|
+
packageName,
|
|
530
|
+
entities,
|
|
531
|
+
query,
|
|
532
|
+
limit,
|
|
533
|
+
sourceName,
|
|
534
|
+
} = args;
|
|
535
|
+
|
|
536
|
+
if (entities.length > MAX_EMBEDDED_ENTITIES) {
|
|
537
|
+
const key = `${environmentName}\x00${packageName}`;
|
|
538
|
+
if (!oversizeWarned.has(key)) {
|
|
539
|
+
oversizeWarned.add(key);
|
|
540
|
+
logger.warn(
|
|
541
|
+
"[MCP Tool getContext] Package exceeds the semantic index entity cap; using lexical ranking",
|
|
542
|
+
{
|
|
543
|
+
environmentName,
|
|
544
|
+
packageName,
|
|
545
|
+
entityCount: entities.length,
|
|
546
|
+
cap: MAX_EMBEDDED_ENTITIES,
|
|
547
|
+
},
|
|
548
|
+
);
|
|
549
|
+
}
|
|
550
|
+
return { unavailable: "too-many-entities" };
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
const providerKey = `${provider.model}\x00${provider.dimensions ?? ""}`;
|
|
554
|
+
const meta = metaFor(environmentName, packageName);
|
|
555
|
+
// Per-package cool-down: a recent provider failure for THIS package
|
|
556
|
+
// (sync, query embed, or a dims-mismatch backoff) keeps it lexical for
|
|
557
|
+
// the window without touching any other package.
|
|
558
|
+
if (inCooldown(meta)) {
|
|
559
|
+
return { unavailable: "cooldown" };
|
|
560
|
+
}
|
|
561
|
+
// Captured at entry: if a concurrent heal purges rows while this call
|
|
562
|
+
// is searching, the generation moves and this call must not assert
|
|
563
|
+
// anything about the (now-changed) table; see the re-check below.
|
|
564
|
+
const entryGeneration = meta.generation;
|
|
565
|
+
let state = syncState.get(pkg);
|
|
566
|
+
if (
|
|
567
|
+
!state ||
|
|
568
|
+
state.providerKey !== providerKey ||
|
|
569
|
+
// A purge bumped the generation after this instance synced: its
|
|
570
|
+
// rows are gone, so a `done` memo must not be trusted.
|
|
571
|
+
(state.done && state.generation !== meta.generation)
|
|
572
|
+
) {
|
|
573
|
+
// No await between the get above and the set below: single-threaded
|
|
574
|
+
// JS therefore guarantees concurrent calls cannot both kick a sync
|
|
575
|
+
// for the same instance. (The per-name mutex still guards the
|
|
576
|
+
// cross-instance reload race.)
|
|
577
|
+
const tracked: SyncState = {
|
|
578
|
+
done: false,
|
|
579
|
+
providerKey,
|
|
580
|
+
generation: meta.generation,
|
|
581
|
+
};
|
|
582
|
+
// The sync promise is deliberately not stored: nothing may ever
|
|
583
|
+
// await it (cold starts answer lexically); completion is observed
|
|
584
|
+
// through `done` and failure through the handler below.
|
|
585
|
+
syncPackageEmbeddings(
|
|
586
|
+
db,
|
|
587
|
+
provider,
|
|
588
|
+
environmentName,
|
|
589
|
+
packageName,
|
|
590
|
+
entities,
|
|
591
|
+
).then(
|
|
592
|
+
(generation) => {
|
|
593
|
+
tracked.generation = generation;
|
|
594
|
+
tracked.done = true;
|
|
595
|
+
},
|
|
596
|
+
(error: unknown) => {
|
|
597
|
+
if (syncState.get(pkg) === tracked) {
|
|
598
|
+
syncState.delete(pkg);
|
|
599
|
+
}
|
|
600
|
+
markProviderFailure(meta);
|
|
601
|
+
logger.warn(
|
|
602
|
+
"[MCP Tool getContext] Embedding sync failed; semantic ranking cooling down",
|
|
603
|
+
{
|
|
604
|
+
environmentName,
|
|
605
|
+
packageName,
|
|
606
|
+
error: error instanceof Error ? error.message : String(error),
|
|
607
|
+
},
|
|
608
|
+
);
|
|
609
|
+
},
|
|
610
|
+
);
|
|
611
|
+
syncState.set(pkg, tracked);
|
|
612
|
+
state = tracked;
|
|
613
|
+
}
|
|
614
|
+
if (!state.done) {
|
|
615
|
+
return { unavailable: "indexing" };
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
let queryVector: number[];
|
|
619
|
+
try {
|
|
620
|
+
[queryVector] = await provider.embedBatch(
|
|
621
|
+
[query],
|
|
622
|
+
EMBEDDING_QUERY_TIMEOUT_MS,
|
|
623
|
+
);
|
|
624
|
+
} catch (error) {
|
|
625
|
+
markProviderFailure(meta);
|
|
626
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
627
|
+
logger.warn(
|
|
628
|
+
"[MCP Tool getContext] Query embedding failed; falling back to lexical ranking",
|
|
629
|
+
{ environmentName, packageName, error: message },
|
|
630
|
+
);
|
|
631
|
+
return { unavailable: "error" };
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
try {
|
|
635
|
+
const rows = await db.all<{
|
|
636
|
+
entity_kind: string;
|
|
637
|
+
entity_source: string;
|
|
638
|
+
entity_name: string;
|
|
639
|
+
score: number;
|
|
640
|
+
}>(
|
|
641
|
+
`SELECT * FROM (
|
|
642
|
+
SELECT entity_kind, entity_source, entity_name,
|
|
643
|
+
list_cosine_similarity(embedding, CAST(? AS FLOAT[])) AS score
|
|
644
|
+
FROM entity_embeddings
|
|
645
|
+
WHERE environment_name = ? AND package_name = ?
|
|
646
|
+
AND embedding_model = ? AND dims = ?
|
|
647
|
+
${sourceName !== undefined ? "AND entity_source = ?" : ""}
|
|
648
|
+
)
|
|
649
|
+
WHERE score >= ?
|
|
650
|
+
ORDER BY score DESC, entity_name
|
|
651
|
+
LIMIT ?`,
|
|
652
|
+
[
|
|
653
|
+
JSON.stringify(queryVector),
|
|
654
|
+
environmentName,
|
|
655
|
+
packageName,
|
|
656
|
+
provider.model,
|
|
657
|
+
queryVector.length,
|
|
658
|
+
...(sourceName !== undefined ? [sourceName] : []),
|
|
659
|
+
MIN_SIMILARITY,
|
|
660
|
+
limit,
|
|
661
|
+
],
|
|
662
|
+
);
|
|
663
|
+
// A sync or heal holding the package mutex right now may be
|
|
664
|
+
// rewriting rows underneath the query that just ran: that snapshot
|
|
665
|
+
// is unreliable (it can be partial or empty mid-write) and must
|
|
666
|
+
// not be served as semantic. Completed writers are caught by the
|
|
667
|
+
// generation re-check below; this catches the in-flight ones.
|
|
668
|
+
if (meta.mutex.isLocked()) {
|
|
669
|
+
return { unavailable: "indexing" };
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// Invariant check, every call: no row may exist that does not
|
|
673
|
+
// match the provider's current (model, dims). The sync diff cannot
|
|
674
|
+
// see a dims change (row hashes still match), so stale-dims rows
|
|
675
|
+
// are detected here. Detecting STALE rows directly, rather than
|
|
676
|
+
// inferring from an empty result, also heals the mixed state where
|
|
677
|
+
// a partial sync wrote some rows at the new dimensionality and the
|
|
678
|
+
// rest are stranded at the old one (an empty-result trigger would
|
|
679
|
+
// never fire there, silently hiding the stranded entities forever).
|
|
680
|
+
const staleRows = await db.get<{ n: number }>(
|
|
681
|
+
`SELECT CAST(COUNT(*) AS INTEGER) AS n FROM entity_embeddings
|
|
682
|
+
WHERE environment_name = ? AND package_name = ?
|
|
683
|
+
AND NOT (embedding_model = ? AND dims = ?)`,
|
|
684
|
+
[environmentName, packageName, provider.model, queryVector.length],
|
|
685
|
+
);
|
|
686
|
+
if ((staleRows?.n ?? 0) > 0) {
|
|
687
|
+
// The check-and-purge runs under the package-name mutex so it
|
|
688
|
+
// cannot interleave with a sync, and the generation bump
|
|
689
|
+
// invalidates every instance's memo, not just this caller's.
|
|
690
|
+
// Acquired WITHOUT waiting: a held mutex means a bulk embed or
|
|
691
|
+
// another heal is mid-flight, and this call must answer as
|
|
692
|
+
// indexing rather than queue minutes behind it (the docstring's
|
|
693
|
+
// no-call-waits-on-a-bulk-embed contract).
|
|
694
|
+
let outcome: "none" | "purged" | "backoff" | "busy";
|
|
695
|
+
try {
|
|
696
|
+
outcome = await tryAcquire(meta.mutex).runExclusive(async () => {
|
|
697
|
+
// Same orphaned-meta guard as the sync: never write under
|
|
698
|
+
// a meta that deletePackageEmbeddings removed.
|
|
699
|
+
if (
|
|
700
|
+
syncMeta.get(metaKey(environmentName, packageName)) !== meta
|
|
701
|
+
) {
|
|
702
|
+
return "none";
|
|
703
|
+
}
|
|
704
|
+
const again = await db.get<{ n: number }>(
|
|
705
|
+
`SELECT CAST(COUNT(*) AS INTEGER) AS n FROM entity_embeddings
|
|
706
|
+
WHERE environment_name = ? AND package_name = ?
|
|
707
|
+
AND NOT (embedding_model = ? AND dims = ?)`,
|
|
708
|
+
[
|
|
709
|
+
environmentName,
|
|
710
|
+
packageName,
|
|
711
|
+
provider.model,
|
|
712
|
+
queryVector.length,
|
|
713
|
+
],
|
|
714
|
+
);
|
|
715
|
+
if ((again?.n ?? 0) === 0) return "none";
|
|
716
|
+
// Backoff: at most one purge per suppression window. Within
|
|
717
|
+
// it, a fresh mismatch means the endpoint is serving
|
|
718
|
+
// inconsistent dimensionalities; re-purging would re-embed
|
|
719
|
+
// the whole package, so cool down and stay lexical instead.
|
|
720
|
+
// lastPurgeAtMs is NOT advanced here: the window is measured
|
|
721
|
+
// from the last real PURGE, and because the suppression
|
|
722
|
+
// window is longer than the cooldown (see
|
|
723
|
+
// HEAL_PURGE_SUPPRESSION_MS), a call arriving after the
|
|
724
|
+
// cooldown clears still lands inside the suppression window
|
|
725
|
+
// and backs off, so a durably-inconsistent provider does
|
|
726
|
+
// NOT re-purge once per cooldown window.
|
|
727
|
+
const now = Date.now();
|
|
728
|
+
if (now - meta.lastPurgeAtMs < purgeSuppressionMs) {
|
|
729
|
+
markProviderFailure(meta);
|
|
730
|
+
logger.warn(
|
|
731
|
+
"[MCP Tool getContext] Repeated embedding dimensionality mismatch; the endpoint looks inconsistent, cooling down",
|
|
732
|
+
{
|
|
733
|
+
environmentName,
|
|
734
|
+
packageName,
|
|
735
|
+
model: provider.model,
|
|
736
|
+
queryDims: queryVector.length,
|
|
737
|
+
},
|
|
738
|
+
);
|
|
739
|
+
return "backoff";
|
|
740
|
+
}
|
|
741
|
+
logger.warn(
|
|
742
|
+
"[MCP Tool getContext] Cached embeddings do not match the provider's current model/dimensions; purging stale rows and re-syncing",
|
|
743
|
+
{
|
|
744
|
+
environmentName,
|
|
745
|
+
packageName,
|
|
746
|
+
model: provider.model,
|
|
747
|
+
queryDims: queryVector.length,
|
|
748
|
+
staleRows: again?.n,
|
|
749
|
+
},
|
|
750
|
+
);
|
|
751
|
+
// Only the stale rows: current-dims rows stay, so the
|
|
752
|
+
// follow-up sync re-embeds just what was stranded.
|
|
753
|
+
await db.run(
|
|
754
|
+
`DELETE FROM entity_embeddings
|
|
755
|
+
WHERE environment_name = ? AND package_name = ?
|
|
756
|
+
AND NOT (embedding_model = ? AND dims = ?)`,
|
|
757
|
+
[
|
|
758
|
+
environmentName,
|
|
759
|
+
packageName,
|
|
760
|
+
provider.model,
|
|
761
|
+
queryVector.length,
|
|
762
|
+
],
|
|
763
|
+
);
|
|
764
|
+
meta.lastPurgeAtMs = now;
|
|
765
|
+
meta.generation = ++generationCounter;
|
|
766
|
+
return "purged";
|
|
767
|
+
});
|
|
768
|
+
} catch (error) {
|
|
769
|
+
if (error !== E_ALREADY_LOCKED) throw error;
|
|
770
|
+
outcome = "busy";
|
|
771
|
+
}
|
|
772
|
+
if (outcome === "purged" || outcome === "busy") {
|
|
773
|
+
if (outcome === "purged") {
|
|
774
|
+
syncState.delete(pkg);
|
|
775
|
+
}
|
|
776
|
+
return { unavailable: "indexing" };
|
|
777
|
+
}
|
|
778
|
+
if (outcome === "backoff") {
|
|
779
|
+
return { unavailable: "cooldown" };
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// A purge moved the generation while this call was searching: the
|
|
784
|
+
// rows snapshot above is unreliable (possibly empty because a
|
|
785
|
+
// concurrent heal deleted mid-search), and an unreliable empty
|
|
786
|
+
// result must never be served as semantic "nothing relevant here".
|
|
787
|
+
// Answer as indexing (marked lexical); the next call is consistent.
|
|
788
|
+
if (meta.generation !== entryGeneration) {
|
|
789
|
+
return { unavailable: "indexing" };
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
return {
|
|
793
|
+
hits: rows.map((row) => ({
|
|
794
|
+
kind: row.entity_kind,
|
|
795
|
+
source: row.entity_source === "" ? undefined : row.entity_source,
|
|
796
|
+
name: row.entity_name,
|
|
797
|
+
score: row.score,
|
|
798
|
+
})),
|
|
799
|
+
};
|
|
800
|
+
} catch (error) {
|
|
801
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
802
|
+
logger.warn(
|
|
803
|
+
"[MCP Tool getContext] Semantic search failed; falling back to lexical ranking",
|
|
804
|
+
{ environmentName, packageName, error: message },
|
|
805
|
+
);
|
|
806
|
+
return { unavailable: "error" };
|
|
807
|
+
}
|
|
808
|
+
}
|