@tpsdev-ai/flair 0.30.0 → 0.31.0

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 (49) hide show
  1. package/README.md +194 -377
  2. package/dist/cli.js +1355 -281
  3. package/dist/deploy.js +212 -24
  4. package/dist/fabric-upgrade.js +16 -1
  5. package/dist/federation/scheduler.js +500 -0
  6. package/dist/install/clients.js +111 -53
  7. package/dist/lib/mcp-spec.js +128 -0
  8. package/dist/lib/safe-snapshot-extract.js +231 -0
  9. package/dist/lib/scheduler-platform.js +128 -0
  10. package/dist/lib/xml-escape.js +54 -0
  11. package/dist/rem/scheduler.js +35 -87
  12. package/dist/rem/snapshot.js +13 -0
  13. package/dist/replication-convergence.js +505 -0
  14. package/dist/resources/MemoryBootstrap.js +7 -8
  15. package/dist/resources/SemanticSearch.js +17 -45
  16. package/dist/resources/abstention.js +1 -1
  17. package/dist/resources/embeddings-boot.js +10 -12
  18. package/dist/resources/embeddings-provider.js +10 -7
  19. package/dist/resources/health.js +24 -19
  20. package/dist/resources/in-process.js +225 -0
  21. package/dist/resources/mcp-tools.js +23 -17
  22. package/dist/resources/migration-boot.js +80 -10
  23. package/dist/resources/migrations/data-dir.js +205 -0
  24. package/dist/resources/migrations/progress.js +33 -0
  25. package/dist/resources/migrations/runner.js +29 -2
  26. package/dist/resources/migrations/state.js +13 -2
  27. package/dist/resources/models-dir.js +18 -9
  28. package/dist/resources/semantic-retrieval-core.js +5 -4
  29. package/dist/src/lib/scheduler-platform.js +128 -0
  30. package/dist/src/lib/xml-escape.js +54 -0
  31. package/dist/src/rem/scheduler.js +35 -87
  32. package/docs/deploying-on-fabric.md +267 -0
  33. package/docs/deployment.md +5 -0
  34. package/docs/embedding-in-a-harper-app.md +299 -0
  35. package/docs/federation.md +61 -4
  36. package/docs/integrations.md +3 -0
  37. package/docs/mcp-clients.md +16 -7
  38. package/docs/quickstart.md +80 -54
  39. package/docs/releasing.md +72 -38
  40. package/docs/supply-chain-policy.md +36 -0
  41. package/docs/troubleshooting.md +24 -0
  42. package/docs/upgrade.md +98 -3
  43. package/package.json +1 -11
  44. package/templates/bin/flair-federation-sync.sh.tmpl +28 -0
  45. package/templates/launchd/dev.flair.federation.sync.plist.tmpl +47 -0
  46. package/templates/systemd/flair-federation-sync.service.tmpl +21 -0
  47. package/templates/systemd/flair-federation-sync.timer.tmpl +16 -0
  48. package/dist/resources/rerank-provider.js +0 -569
  49. package/docs/rerank-provisioning.md +0 -101
@@ -0,0 +1,505 @@
1
+ /**
2
+ * replication-convergence.ts — flair#878
3
+ *
4
+ * Harper Fabric's component replication to peers converges ASYNCHRONOUSLY. A
5
+ * peer-replication error raised by `harper deploy` is therefore a snapshot of
6
+ * one instant, not a terminal verdict: the origin has the component, one peer
7
+ * did not have it *at the moment the deploy call returned*, and Harper keeps
8
+ * healing after the CLI has stopped watching.
9
+ *
10
+ * The incident this module closes (flair#878): a two-node cluster upgrade
11
+ * reported
12
+ *
13
+ * Component 'flair' was deployed on the origin node but failed to
14
+ * replicate to 1 of 1 peer node(s): <peer> (Error: Connection closed 1006)
15
+ *
16
+ * and the CLI treated it as failure. Afterwards both nodes carried
17
+ * byte-identical component files and all data was intact — the upgrade had
18
+ * succeeded. Worse, the CLI's retry re-ran the full deploy while Harper was
19
+ * still healing, and *that* attempt died with an npm `ENOTEMPTY` on a native
20
+ * module, turning a transient warning into a hard, misleading failure.
21
+ *
22
+ * ── The signal this module uses ─────────────────────────────────────────────
23
+ * `cluster_status` — the operation that would answer "are my cluster peers in
24
+ * sync" — is harper-pro-only and unavailable in the OSS build this CLI ships
25
+ * (see src/fleet-verify.ts's header for the same constraint). What IS
26
+ * available in the OSS build is `get_components`, an `admin_read` operation
27
+ * that walks the components root and returns, per file, `{ name, size, mtime }`
28
+ * (directories recurse through `entries`; `node_modules` is excluded
29
+ * server-side). Addressing it at each node's OWN hostname yields a per-node
30
+ * fingerprint of the deployed component tree, and comparing peer-to-origin
31
+ * answers exactly the question the deploy error left open.
32
+ *
33
+ * `mtime` is the load-bearing half of that fingerprint, not decoration: a peer
34
+ * still holding the PREVIOUS version can easily match on size (a same-length
35
+ * version string, an unchanged asset) but cannot match on the extraction
36
+ * timestamp the origin just stamped. Size alone would be a much weaker check.
37
+ *
38
+ * ── Why a node-identity guard exists (the false-success this would otherwise
39
+ * have) ────────────────────────────────────────────────────────────────────
40
+ * A Fabric cluster endpoint is GTM-steered: `https://<cluster>.<org>.
41
+ * harperfabric.com` resolves to ONE of the member nodes, and which one is not
42
+ * guaranteed stable between the deploy call and this poll. If the cluster
43
+ * endpoint happened to steer to the very peer that failed, a naive
44
+ * origin-vs-peer comparison would be comparing a node against ITSELF and would
45
+ * report `converged` for a cluster that never converged — precisely the
46
+ * failure mode that is worse than the bug being fixed, because it turns a
47
+ * false alarm into a false all-clear.
48
+ *
49
+ * The guard is a DNS resolution of both hostnames: the peer is only compared
50
+ * against the deploy target when the two resolve to disjoint address sets.
51
+ * Overlapping (or unresolvable) addresses yield `unknown`, never `converged`.
52
+ * This is the reporter's own observation encoded as a check rather than as
53
+ * prose a future reader has to remember — the two nodes "resolve to different
54
+ * IPs" is the property that makes the comparison meaningful at all.
55
+ *
56
+ * ── The invariant every caller may rely on ──────────────────────────────────
57
+ * `converged: true` is returned ONLY when every named peer was positively
58
+ * observed, over a distinct network identity, to hold a component tree
59
+ * identical to the origin's. Every other outcome — unparseable error, node
60
+ * name that is not an addressable host, DNS failure, shared address, HTTP
61
+ * failure, absent component, differing files — is `converged: false`. There is
62
+ * no path that infers convergence from the absence of evidence.
63
+ */
64
+ import { lookup } from "node:dns/promises";
65
+ // ─── Tunables ────────────────────────────────────────────────────────────────
66
+ /**
67
+ * How long to wait for asynchronous peer replication to converge before giving
68
+ * up on confirming it. Generous on purpose: the whole point is that Harper is
69
+ * still working after the deploy call returned, and the cost of waiting is a
70
+ * slower failure report, while the cost of NOT waiting is the flair#878
71
+ * false-failure plus a destructive retry.
72
+ */
73
+ export const DEFAULT_CONVERGENCE_TIMEOUT_MS = 180_000;
74
+ /** How often to re-fingerprint every node while waiting. */
75
+ export const CONVERGENCE_POLL_INTERVAL_MS = 10_000;
76
+ /**
77
+ * Quiescence (see awaitOriginQuiescent): how many consecutive identical
78
+ * fingerprints of the ORIGIN's component tree count as "nothing is writing to
79
+ * it any more". Two is the minimum that can distinguish "stable" from "read
80
+ * once"; the interval between them is what gives it meaning.
81
+ */
82
+ export const QUIESCENT_STABLE_READS = 2;
83
+ export const DEFAULT_QUIESCENT_TIMEOUT_MS = 120_000;
84
+ export const QUIESCENT_POLL_INTERVAL_MS = 5_000;
85
+ /**
86
+ * Harper builds the message as (components/operations.js):
87
+ *
88
+ * `Component '<name>' was deployed on the origin node but failed to
89
+ * replicate to <failed> of <total> peer node(s): <node> (<error>)[, ...].
90
+ * See deployment <id> (get_deployment) for details, or pass
91
+ * ignore_replication_errors: true ...`
92
+ *
93
+ * Both counts and the deployment-id tail are optional here — the aim is to
94
+ * recover the peer NAMES, since those are the only handle the CLI has on the
95
+ * cluster's actual topology (`cluster_status` being unavailable). Anything
96
+ * unrecognised yields an empty peer list, which callers must treat as "cannot
97
+ * check", never as "nothing failed".
98
+ *
99
+ * Literal regexes only (no interpolation) — satisfies semgrep
100
+ * detect-non-literal-regexp.
101
+ */
102
+ const REPLICATION_DETAIL_RE = /failed to replicate to (\d+)(?: of (\d+))? peer node\(s\):\s*([\s\S]*?)(?:\.\s*See deployment\s+(\S+?)\s*\(get_deployment\)|\.\s*$|$)/i;
103
+ /** One `name (error)` entry out of the comma-separated detail list. */
104
+ const PEER_ENTRY_RE = /([^,()]+?)\s*\(([^)]*)\)/g;
105
+ export function parseReplicationFailure(output) {
106
+ const empty = {
107
+ peers: [],
108
+ failedCount: null,
109
+ totalCount: null,
110
+ deploymentId: null,
111
+ };
112
+ if (!output)
113
+ return empty;
114
+ const m = REPLICATION_DETAIL_RE.exec(output);
115
+ if (!m)
116
+ return empty;
117
+ const failedCount = Number.isFinite(Number(m[1])) ? Number(m[1]) : null;
118
+ const totalCount = m[2] != null && Number.isFinite(Number(m[2])) ? Number(m[2]) : null;
119
+ const deploymentId = m[4] ?? null;
120
+ const peers = [];
121
+ const detail = (m[3] ?? "").split("\n")[0];
122
+ for (const entry of detail.matchAll(PEER_ENTRY_RE)) {
123
+ const node = entry[1].trim();
124
+ if (!node)
125
+ continue;
126
+ peers.push({ node, error: entry[2].trim() });
127
+ }
128
+ return { peers, failedCount, totalCount, deploymentId };
129
+ }
130
+ /**
131
+ * Harper serialises `mtime` as a Date, which crosses JSON as an ISO string.
132
+ * Normalising through Date makes the comparison immune to a peer reporting a
133
+ * different-but-equivalent encoding; an unparseable value is compared verbatim
134
+ * rather than silently dropped (dropping it would weaken the check).
135
+ */
136
+ function normalizeMtime(value) {
137
+ if (value == null)
138
+ return "-";
139
+ const asDate = new Date(value);
140
+ const ms = asDate.getTime();
141
+ return Number.isNaN(ms) ? `raw:${String(value)}` : asDate.toISOString();
142
+ }
143
+ function collectFiles(entry, prefix, out) {
144
+ if (!entry || typeof entry.name !== "string")
145
+ return;
146
+ const path = prefix ? `${prefix}/${entry.name}` : entry.name;
147
+ if (Array.isArray(entry.entries)) {
148
+ for (const child of entry.entries)
149
+ collectFiles(child, path, out);
150
+ return;
151
+ }
152
+ const size = typeof entry.size === "number" ? String(entry.size) : "?";
153
+ out.push(`${path} ${size} ${normalizeMtime(entry.mtime)}`);
154
+ }
155
+ /**
156
+ * Reduce a `get_components` response to a stable, comparable fingerprint of ONE
157
+ * component's file tree.
158
+ *
159
+ * Returns null when the response doesn't contain that component, or contains it
160
+ * with no files at all. Null is deliberately NOT a fingerprint: two nodes that
161
+ * both lack the component would otherwise "match" and be reported converged.
162
+ */
163
+ export function fingerprintComponent(body, project) {
164
+ const roots = body?.entries;
165
+ if (!Array.isArray(roots))
166
+ return null;
167
+ const component = roots.find((e) => e && typeof e === "object" && e.name === project);
168
+ if (!component)
169
+ return null;
170
+ const children = Array.isArray(component.entries) ? component.entries : [];
171
+ const lines = [];
172
+ for (const child of children)
173
+ collectFiles(child, "", lines);
174
+ if (lines.length === 0)
175
+ return null;
176
+ lines.sort();
177
+ return lines.join("\n");
178
+ }
179
+ /** How many file entries differ between two fingerprints (for operator-facing detail). */
180
+ export function fingerprintDiffCount(a, b) {
181
+ const left = new Set(a.split("\n"));
182
+ const right = new Set(b.split("\n"));
183
+ let diff = 0;
184
+ for (const line of left)
185
+ if (!right.has(line))
186
+ diff++;
187
+ for (const line of right)
188
+ if (!left.has(line))
189
+ diff++;
190
+ return diff;
191
+ }
192
+ // ─── Addressing a node ──────────────────────────────────────────────────────
193
+ /**
194
+ * A Harper node name is a hostname (server/nodeName.ts derives it from
195
+ * `node.hostname`, the replication URL, or the TLS certificate CN), so it can
196
+ * normally be addressed directly with the deploy target's scheme and port.
197
+ *
198
+ * But harper's error detail is FREE TEXT — the per-peer entry falls back to the
199
+ * literal `'unknown'`, and a malformed cluster can put an arbitrary message
200
+ * there. Anything that isn't a plain `host[:port]` is rejected rather than
201
+ * guessed at: an unaddressable node yields "cannot check", which is safe, while
202
+ * a guessed address could reach the wrong machine, which is not.
203
+ */
204
+ const HOSTPORT_RE = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?(?::\d{1,5})?$/;
205
+ export function resolvePeerUrl(node, referenceUrl) {
206
+ const trimmed = (node ?? "").trim();
207
+ if (!trimmed || trimmed.toLowerCase() === "unknown")
208
+ return null;
209
+ if (/^https?:\/\//i.test(trimmed)) {
210
+ try {
211
+ return new URL(trimmed).origin;
212
+ }
213
+ catch {
214
+ return null;
215
+ }
216
+ }
217
+ if (!HOSTPORT_RE.test(trimmed))
218
+ return null;
219
+ let reference;
220
+ try {
221
+ reference = new URL(referenceUrl);
222
+ }
223
+ catch {
224
+ return null;
225
+ }
226
+ const lastColon = trimmed.lastIndexOf(":");
227
+ const host = lastColon > 0 ? trimmed.slice(0, lastColon) : trimmed;
228
+ const port = lastColon > 0 ? trimmed.slice(lastColon + 1) : "";
229
+ const url = new URL(reference.origin);
230
+ url.hostname = host;
231
+ if (port)
232
+ url.port = port;
233
+ // Setting an invalid hostname on a URL is a silent no-op in WHATWG URL, which
234
+ // would leave us pointed at the ORIGIN while believing we addressed the peer.
235
+ if (url.hostname !== host.toLowerCase())
236
+ return null;
237
+ return url.origin;
238
+ }
239
+ function hostnameOf(url) {
240
+ try {
241
+ return new URL(url).hostname;
242
+ }
243
+ catch {
244
+ return null;
245
+ }
246
+ }
247
+ /**
248
+ * Real `get_components` caller. Fabric serves the operations API on the same
249
+ * origin the deploy targets, with the same admin Basic auth the deploy used.
250
+ *
251
+ * NEVER logs or returns the credential, and never folds a response BODY into a
252
+ * thrown error — only the status code. An operations-API error body can echo
253
+ * request context, and this runs with cluster-admin credentials.
254
+ */
255
+ export function buildOpsGetComponents(fabricUser, fabricPassword, timeoutMs = 15_000) {
256
+ const headers = { "Content-Type": "application/json" };
257
+ if (fabricUser && fabricPassword) {
258
+ headers.Authorization = `Basic ${Buffer.from(`${fabricUser}:${fabricPassword}`).toString("base64")}`;
259
+ }
260
+ return async (baseUrl) => {
261
+ const res = await fetch(baseUrl.replace(/\/+$/, "") + "/", {
262
+ method: "POST",
263
+ headers,
264
+ body: JSON.stringify({ operation: "get_components" }),
265
+ signal: AbortSignal.timeout(timeoutMs),
266
+ });
267
+ if (!res.ok)
268
+ throw new Error(`get_components returned HTTP ${res.status}`);
269
+ return (await res.json());
270
+ };
271
+ }
272
+ async function defaultResolveHostAddresses(hostname) {
273
+ const records = await lookup(hostname, { all: true });
274
+ return records.map((r) => r.address);
275
+ }
276
+ export function defaultConvergenceDeps(fabricUser, fabricPassword, onProgress) {
277
+ return {
278
+ getComponents: buildOpsGetComponents(fabricUser, fabricPassword),
279
+ resolveHostAddresses: defaultResolveHostAddresses,
280
+ sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
281
+ now: () => Date.now(),
282
+ onProgress,
283
+ };
284
+ }
285
+ function summarize(peers, converged, conclusive) {
286
+ if (peers.length === 0) {
287
+ return "harper's replication error named no peer nodes — convergence could not be checked";
288
+ }
289
+ if (converged) {
290
+ const names = peers.map((p) => p.node).join(", ");
291
+ return `all ${peers.length} peer node(s) hold the same component tree as the origin (${names})`;
292
+ }
293
+ const parts = peers.map((p) => `${p.node}: ${p.state} — ${p.detail}`);
294
+ return conclusive
295
+ ? `peer replication did NOT converge — ${parts.join("; ")}`
296
+ : `convergence could NOT be determined — ${parts.join("; ")}`;
297
+ }
298
+ /**
299
+ * Poll every named peer until its component tree matches the origin's, or the
300
+ * deadline passes.
301
+ *
302
+ * Never throws for cluster-side reasons: a peer that cannot be reached, named,
303
+ * or resolved is reported as `unknown` and the caller decides. The only
304
+ * unhandled throw would be a bug in an injected dep.
305
+ */
306
+ export async function awaitReplicationConvergence(opts, deps) {
307
+ const started = deps.now();
308
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_CONVERGENCE_TIMEOUT_MS;
309
+ const pollIntervalMs = opts.pollIntervalMs ?? CONVERGENCE_POLL_INTERVAL_MS;
310
+ const deadline = started + timeoutMs;
311
+ const results = opts.peers.map((p) => {
312
+ const url = resolvePeerUrl(p.node, opts.targetUrl);
313
+ return {
314
+ node: p.node,
315
+ url,
316
+ state: "unknown",
317
+ detail: url
318
+ ? "not checked yet"
319
+ : `harper reported "${p.node}", which is not an addressable host — this CLI will not guess an address for it, so convergence cannot be checked from here`,
320
+ };
321
+ });
322
+ const finish = () => {
323
+ const converged = results.length > 0 && results.every((r) => r.state === "converged");
324
+ const conclusive = results.length > 0 && results.every((r) => r.state !== "unknown");
325
+ return {
326
+ converged,
327
+ conclusive,
328
+ peers: results,
329
+ elapsedMs: deps.now() - started,
330
+ detail: summarize(results, converged, conclusive),
331
+ };
332
+ };
333
+ const addressable = results.filter((r) => r.url !== null);
334
+ if (addressable.length === 0)
335
+ return finish();
336
+ const targetHost = hostnameOf(opts.targetUrl);
337
+ let targetAddresses = [];
338
+ if (targetHost) {
339
+ try {
340
+ targetAddresses = await deps.resolveHostAddresses(targetHost);
341
+ }
342
+ catch {
343
+ targetAddresses = [];
344
+ }
345
+ }
346
+ for (;;) {
347
+ let originFingerprint = null;
348
+ let originError = null;
349
+ try {
350
+ originFingerprint = fingerprintComponent(await deps.getComponents(opts.targetUrl), opts.project);
351
+ if (!originFingerprint) {
352
+ originError = `the deploy target did not report a '${opts.project}' component tree to compare peers against`;
353
+ }
354
+ }
355
+ catch (err) {
356
+ originError = `could not read the deploy target's component tree: ${err?.message ?? String(err)}`;
357
+ }
358
+ for (const peer of results) {
359
+ if (peer.state === "converged" || peer.url === null)
360
+ continue;
361
+ if (!originFingerprint) {
362
+ peer.state = "unknown";
363
+ peer.detail = originError ?? "no origin fingerprint to compare against";
364
+ continue;
365
+ }
366
+ const peerHost = hostnameOf(peer.url);
367
+ let peerAddresses = [];
368
+ if (peerHost) {
369
+ try {
370
+ peerAddresses = await deps.resolveHostAddresses(peerHost);
371
+ }
372
+ catch {
373
+ peerAddresses = [];
374
+ }
375
+ }
376
+ if (peerAddresses.length === 0) {
377
+ peer.state = "unknown";
378
+ peer.detail = `${peerHost ?? peer.url} did not resolve — cannot reach this node to check whether it converged`;
379
+ continue;
380
+ }
381
+ if (targetAddresses.length === 0) {
382
+ peer.state = "unknown";
383
+ peer.detail =
384
+ `the deploy target's own hostname did not resolve, so this peer cannot be proven to be a different ` +
385
+ `node from it — refusing to compare a node against itself`;
386
+ continue;
387
+ }
388
+ if (peerAddresses.some((a) => targetAddresses.includes(a))) {
389
+ peer.state = "unknown";
390
+ peer.detail =
391
+ `${peerHost} currently resolves to the same address as the deploy target — a Fabric cluster endpoint ` +
392
+ `is steered to one member node, so this comparison would be the peer against itself. Refusing to ` +
393
+ `report convergence from it`;
394
+ continue;
395
+ }
396
+ let peerFingerprint = null;
397
+ try {
398
+ peerFingerprint = fingerprintComponent(await deps.getComponents(peer.url), opts.project);
399
+ }
400
+ catch (err) {
401
+ peer.state = "unknown";
402
+ peer.detail = `could not read this node's component tree: ${err?.message ?? String(err)}`;
403
+ continue;
404
+ }
405
+ if (!peerFingerprint) {
406
+ peer.state = "diverged";
407
+ peer.detail = `this node reports no '${opts.project}' component at all`;
408
+ continue;
409
+ }
410
+ if (peerFingerprint === originFingerprint) {
411
+ peer.state = "converged";
412
+ peer.detail = "component files match the origin byte-for-byte (name, size and mtime)";
413
+ continue;
414
+ }
415
+ peer.state = "diverged";
416
+ peer.detail = `${fingerprintDiffCount(originFingerprint, peerFingerprint)} component file entr(ies) differ from the origin`;
417
+ }
418
+ if (results.every((r) => r.state === "converged"))
419
+ break;
420
+ if (deps.now() >= deadline)
421
+ break;
422
+ deps.onProgress?.(`waiting for peer replication to converge (${results.filter((r) => r.state === "converged").length}/${results.length} peer(s) match the origin so far)...`);
423
+ await deps.sleep(pollIntervalMs);
424
+ }
425
+ return finish();
426
+ }
427
+ /**
428
+ * Wait until the ORIGIN's component tree stops changing.
429
+ *
430
+ * This is the retry's clean-first precondition. Retrying on a fixed 5s/10s
431
+ * backoff re-issues a full deploy while the previous attempt's SERVER-SIDE work
432
+ * — tarball extraction and `npm install` into `<components-root>/<project>` —
433
+ * may still be running, and two writers in one component directory is the
434
+ * best available explanation for the reported
435
+ * `ENOTEMPTY: directory not empty, rmdir '.../node_modules/<pkg>/dist'`, the
436
+ * error that took that upgrade from "transient warning" to "failed".
437
+ *
438
+ * Stated honestly: the CLI cannot observe WHY the remote install failed — that
439
+ * happened inside Harper on another host. What it can do is remove the overlap,
440
+ * which is a necessary condition for any concurrent-writer explanation and
441
+ * costs nothing when the explanation is something else.
442
+ *
443
+ * What the CLI CANNOT do is make Harper's remote install clean-first directly.
444
+ * Harper consults a component's `install_command` only when `node_modules` is
445
+ * ABSENT — `components/Application.ts` returns early ("already has
446
+ * node_modules; skipping install") before the custom command is ever read — so
447
+ * it cannot be used to clear an existing tree, and `deploy_component` exposes no
448
+ * clean/force-reinstall option. Not starting attempt N+1 on top of attempt N is
449
+ * therefore the only clean-first guarantee available from this side.
450
+ *
451
+ * Returns `quiescent: false` when it could not establish stability. Callers
452
+ * must treat that as "do not retry", never as "safe to retry".
453
+ */
454
+ export async function awaitOriginQuiescent(opts, deps) {
455
+ const started = deps.now();
456
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_QUIESCENT_TIMEOUT_MS;
457
+ const pollIntervalMs = opts.pollIntervalMs ?? QUIESCENT_POLL_INTERVAL_MS;
458
+ const stableReads = opts.stableReads ?? QUIESCENT_STABLE_READS;
459
+ const deadline = started + timeoutMs;
460
+ let previous = null;
461
+ let streak = 0;
462
+ let lastError = null;
463
+ for (;;) {
464
+ let fingerprint = null;
465
+ try {
466
+ fingerprint = fingerprintComponent(await deps.getComponents(opts.targetUrl), opts.project);
467
+ if (!fingerprint)
468
+ lastError = `the deploy target reports no '${opts.project}' component tree`;
469
+ }
470
+ catch (err) {
471
+ lastError = err?.message ?? String(err);
472
+ }
473
+ if (fingerprint) {
474
+ if (previous !== null && fingerprint === previous) {
475
+ streak++;
476
+ }
477
+ else {
478
+ streak = 1;
479
+ }
480
+ previous = fingerprint;
481
+ lastError = null;
482
+ if (streak >= stableReads) {
483
+ return {
484
+ quiescent: true,
485
+ detail: `origin component tree unchanged across ${streak} consecutive reads`,
486
+ elapsedMs: deps.now() - started,
487
+ };
488
+ }
489
+ }
490
+ else {
491
+ streak = 0;
492
+ previous = null;
493
+ }
494
+ if (deps.now() >= deadline) {
495
+ return {
496
+ quiescent: false,
497
+ detail: `origin component tree did not settle within ${timeoutMs}ms` +
498
+ (lastError ? ` (last read: ${lastError})` : " — it is still being written to"),
499
+ elapsedMs: deps.now() - started,
500
+ };
501
+ }
502
+ deps.onProgress?.("waiting for the origin's component tree to settle before retrying...");
503
+ await deps.sleep(pollIntervalMs);
504
+ }
505
+ }
@@ -11,7 +11,7 @@ import { buildCollisionEntries, buildEntityMatchCondition, freshPresenceByAgent,
11
11
  // The bounded HNSW candidate-pool retrieval for the task-relevant/teammate/
12
12
  // collision surfaces (flair-bootstrap-scale-fix) — the SAME pure core
13
13
  // SemanticSearch.ts's post() wraps, called bare here so an internal
14
- // bootstrap call never trips SemanticSearch's rate-limit/reranker/
14
+ // bootstrap call never trips SemanticSearch's rate-limit or
15
15
  // retrievalCount hit-tracking side effects (see resources/
16
16
  // semantic-retrieval-core.ts's module doc for the full boundary).
17
17
  import { retrieveCandidates, DEFAULT_SELECT } from "./semantic-retrieval-core.js";
@@ -619,12 +619,11 @@ export class BootstrapMemories extends Resource {
619
619
  queryEmbedding,
620
620
  conditions: [scope.condition],
621
621
  limit: candidatePoolK,
622
- // HNSW-leg pushdown ONLY (K&S verdict): no BM25 fusion, no
623
- // reranker for bootstrap — a different cost profile (BM25 over the
624
- // org corpus for a one-shot session-load could be MORE expensive
625
- // than HNSW-only unless cached across sessions; the reranker is a
626
- // generative call per candidate). Both are explicit opt-in
627
- // follow-ons, gated on their own harness runs.
622
+ // HNSW-leg pushdown ONLY (K&S verdict): no BM25 fusion for
623
+ // bootstrap — a different cost profile, since BM25 over the org
624
+ // corpus for a one-shot session-load could be MORE expensive than
625
+ // HNSW-only unless cached across sessions. Turning it on is an
626
+ // explicit opt-in follow-on, gated on its own harness run.
628
627
  hybrid: false,
629
628
  // Per-set (this K-bounded pool only, never cross-applied to the
630
629
  // permanent/recent/predicted sets above) — see this function's
@@ -679,7 +678,7 @@ export class BootstrapMemories extends Resource {
679
678
  // `candidates` are already `_score`-sorted best-first, so filtering
680
679
  // preserves that order). `retrieveCandidates()`'s cosine similarity
681
680
  // replaces the raw JS dot product as the ranking signal (HNSW-only,
682
- // no BM25/rerank) — the K&S-ratified, closest-to-a-wash choice; the
681
+ // no BM25) — the K&S-ratified, closest-to-a-wash choice; the
683
682
  // recall harness gates any regression from this ranking-signal
684
683
  // change (magnitude-sensitive dot product → normalized cosine).
685
684
  const scored = candidates
@@ -4,7 +4,6 @@ import { getEmbedding, getMode } from "./embeddings-provider.js";
4
4
  import { patchRecord } from "./table-helpers.js";
5
5
  import { checkRateLimit, rateLimitResponse } from "./rate-limiter.js";
6
6
  import { resolveReadScope } from "./memory-read-scope.js";
7
- import { isRerankEnabled, getRerankTopN, getRerankBudgetMs, getRerankMinCandidates, rerankCandidates, } from "./rerank-provider.js";
8
7
  // The BM25 + union-RRF hybrid path is feature-flagged via hybridEnabled()
9
8
  // (imported from ./bm25 — Harper-free so it's unit-testable). Default is ON as
10
9
  // of 2026-07-08 (see ./bm25.ts's hybridEnabled() doc); set
@@ -15,7 +14,7 @@ import { hybridEnabled } from "./bm25.js";
15
14
  // supersede/isAllowed) now lives in the pure, side-effect-free
16
15
  // retrieveCandidates() core (flair-bootstrap-scale-fix, Kern-approved
17
16
  // extraction) — MemoryBootstrap.ts calls the SAME core bare, without
18
- // tripping this file's rate-limit/reranker/hit-tracking side effects. See
17
+ // tripping this file's rate-limit/hit-tracking side effects. See
19
18
  // resources/semantic-retrieval-core.ts's module doc for the full boundary.
20
19
  import { retrieveCandidates, DEFAULT_SELECT } from "./semantic-retrieval-core.js";
21
20
  import { attachTrust } from "./trust-block.js";
@@ -181,29 +180,18 @@ export class SemanticSearch extends Resource {
181
180
  }
182
181
  }
183
182
  const hybrid = hybridEnabled();
184
- // When the reranker is on, widen the legacy HNSW fetch so it has a deeper
185
- // pool to re-score (retrieve topN → rerank → slice to limit). Decoupled
186
- // from CANDIDATE_MULTIPLIER so composite re-ranking keeps its existing
187
- // headroom. Scoped to the legacy (non-hybrid) vector path below — the
188
- // hybrid path's candidate pool is already governed by CANDIDATE_MULTIPLIER
189
- // (semantic leg) + SEM_LIMIT (BM25 leg) via RRF union; the
190
- // reranker still applies to its output further down regardless of which
191
- // path produced `filteredResults`.
192
- const rerankOn = isRerankEnabled();
193
- const rerankTopN = getRerankTopN();
194
183
  // The overfetch policy (how many raw candidates to pull from the
195
184
  // HNSW/BM25 legs relative to what the caller ultimately wants) is THIS
196
185
  // wrapper's decision — retrieveCandidates() never multiplies its `limit`
197
186
  // param internally (see resources/semantic-retrieval-core.ts's doc), so
198
187
  // every caller (this one, and MemoryBootstrap's own K formula) computes
199
- // its own fetch depth. Hybrid's semantic leg is never rerank-widened
200
- // (matches the pre-extraction behavior: only the legacy path widened for
201
- // rerank).
202
- const candidateLimit = hybrid
203
- ? limit * CANDIDATE_MULTIPLIER
204
- : (rerankOn ? Math.max(limit * CANDIDATE_MULTIPLIER, rerankTopN) : limit * CANDIDATE_MULTIPLIER);
188
+ // its own fetch depth. Both the hybrid path (CANDIDATE_MULTIPLIER on the
189
+ // semantic leg + SEM_LIMIT on the BM25 leg, fused by RRF) and the legacy
190
+ // vector-only path overfetch by the same multiplier, which is what gives
191
+ // composite re-scoring headroom to reorder before the final slice.
192
+ const candidateLimit = limit * CANDIDATE_MULTIPLIER;
205
193
  const ctx = this.getContext?.();
206
- let filteredResults = await retrieveCandidates({
194
+ const filteredResults = await retrieveCandidates({
207
195
  queryEmbedding: qEmb,
208
196
  q,
209
197
  conditions,
@@ -233,10 +221,10 @@ export class SemanticSearch extends Resource {
233
221
  withSemSimilarity: abstain || includeTrust,
234
222
  });
235
223
  // ─── flair#744 slice 2 — first-class abstention ("no memory covers this")
236
- // Opt-in only. Evaluated on the RETRIEVED candidate pool, BEFORE the
237
- // reranker / final slice / hit-tracking, so an abstaining recall does no
238
- // rerank work and never bumps retrievalCount for memories it declines to
239
- // surface. The decision reads ONLY the best absolute semantic similarity
224
+ // Opt-in only. Evaluated on the RETRIEVED candidate pool, BEFORE the final
225
+ // slice / hit-tracking, so an abstaining recall never bumps retrievalCount
226
+ // for memories it declines to surface. The decision reads ONLY the best
227
+ // absolute semantic similarity
240
228
  // (never any principal/authority signal — abstention.ts is pure and
241
229
  // authority-free), against the single GLOBAL threshold. Default OFF ⇒ this
242
230
  // whole block is skipped and the response is byte-identical to pre-slice-2.
@@ -253,28 +241,12 @@ export class SemanticSearch extends Resource {
253
241
  };
254
242
  }
255
243
  }
256
- // ─── Cross-encoder rerank (best-effort, fail-open to vector order) ───────
257
- // Re-scores query+candidate TOGETHER (cross-attention the pooled embedding
258
- // can't do) and reorders before the final slice. Reorders whatever
259
- // `filteredResults` retrieveCandidates() produced legacy HNSW-only OR
260
- // the BM25+union-RRF hybrid path since both converge into the same
261
- // shape (retrieveCandidates never exposes which leg produced a result).
262
- // Still gated on `qEmb` (an embedding was actually generated); the pure
263
- // keyword-only fallback (no qEmb at all) is untouched either way. The
264
- // reranker overwrites `_score` with the rerank score (so margin
265
- // measurement reads it) and preserves the semantic score as `_semScore`;
266
- // `_rawScore` is left as-is so recall-bench's scoring:"raw" path stays
267
- // reproducible. On init failure, timeout, or any throw, rerankCandidates
268
- // returns the input UNCHANGED and we fall through to retrieveCandidates'
269
- // own vector-order sort — recall is never blocked. retrieveCandidates
270
- // already returns its output sorted best-first, so the non-rerank branch
271
- // needs no additional sort here.
272
- if (rerankOn && qEmb && q && filteredResults.length >= getRerankMinCandidates()) {
273
- filteredResults = await rerankCandidates(String(q), filteredResults, {
274
- topN: rerankTopN,
275
- budgetMs: getRerankBudgetMs(),
276
- });
277
- }
244
+ // retrieveCandidates() already returns its output sorted best-first
245
+ // (whichever leg produced it — legacy HNSW-only, the BM25+union-RRF hybrid
246
+ // path, or the keyword-only fallback all converge into the same shape), so
247
+ // the final slice needs no additional sort. A cross-encoder rerank stage
248
+ // used to sit here and reorder the pool before this slice; it was removed
249
+ // in flair#893 after measuring Δp@3 = 0.000 at 4.1× query latency.
278
250
  const topResults = filteredResults.slice(0, limit);
279
251
  // Async hit tracking — don't block the response
280
252
  const now = new Date().toISOString();
@@ -100,7 +100,7 @@ export const STRONG_BAND = 0.55;
100
100
  * Reads ONLY the `_semSimilarity` number the retrieval core
101
101
  * (resources/semantic-retrieval-core.ts) attaches to each semantic-leg result
102
102
  * WHEN abstention is requested — an absolute cosine similarity in [0,1],
103
- * independent of the RRF normalization / rerank that make the ranking `_score`
103
+ * independent of the RRF normalization that makes the ranking `_score`
104
104
  * a *relative* signal (the top RRF-fused result is normalized to 1.0 regardless
105
105
  * of how weak the actual match is, so `_score` is unusable as a confidence
106
106
  * floor — this is why abstention reads the absolute similarity instead).