@tpsdev-ai/flair 0.48.0 → 0.49.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.
- package/README.md +2 -0
- package/dist/build-info.json +3 -3
- package/dist/cli.js +525 -121
- package/dist/component-env.js +52 -4
- package/dist/doctor-client.js +46 -1
- package/dist/hook-install.js +52 -4
- package/dist/install/clients.js +318 -9
- package/dist/lib/auth-resolve.js +34 -3
- package/dist/lib/mcp-enable.js +134 -26
- package/dist/resources/AgentSeed.js +2 -0
- package/dist/resources/Memory.js +24 -5
- package/dist/resources/MemoryFeed.js +3 -0
- package/dist/resources/MemoryMaintenance.js +11 -2
- package/dist/resources/bm25-index-service.js +257 -0
- package/dist/resources/bm25-index.js +631 -0
- package/dist/resources/bm25.js +31 -1
- package/dist/resources/embeddings-boot.js +45 -3
- package/dist/resources/memory-read-scope.js +2 -0
- package/dist/resources/semantic-retrieval-core.js +93 -22
- package/dist/version-check.js +59 -13
- package/docs/claude-code.md +10 -3
- package/docs/deployment.md +11 -1
- package/docs/integrations.md +25 -4
- package/docs/mcp-clients.md +18 -0
- package/docs/notes/mcp-oauth-model2.md +31 -13
- package/docs/quickstart.md +9 -9
- package/docs/standalone-local.md +3 -0
- package/package.json +3 -2
package/dist/lib/mcp-enable.js
CHANGED
|
@@ -555,12 +555,57 @@ function opsBaseUrl(opsPortOrUrl) {
|
|
|
555
555
|
function basicAuthHeader(adminUser, adminPass) {
|
|
556
556
|
return `Basic ${Buffer.from(`${adminUser}:${adminPass}`).toString("base64")}`;
|
|
557
557
|
}
|
|
558
|
+
/** The resolver's own predicate, verbatim (resources/mcp-handler.ts
|
|
559
|
+
* `resolveAgentFromSub`): a credential is resolvable unless it is explicitly
|
|
560
|
+
* revoked. The linking layer MUST use the same test — a credential the linker
|
|
561
|
+
* considers inactive but the resolver would still serve is exactly the
|
|
562
|
+
* invisible-duplicate hole #1317 is about. */
|
|
563
|
+
function isResolvableCredential(cred) {
|
|
564
|
+
return cred?.status !== "revoked";
|
|
565
|
+
}
|
|
558
566
|
/**
|
|
559
567
|
* Map the operator's IdP subject to their principal via `Credential(kind:
|
|
560
568
|
* "idp")` — the SAME credential surface resources/mcp-handler.ts's
|
|
561
|
-
* `resolveAgentFromSub` reads at request time.
|
|
562
|
-
*
|
|
563
|
-
*
|
|
569
|
+
* `resolveAgentFromSub` reads at request time.
|
|
570
|
+
*
|
|
571
|
+
* ## The uniqueness constraint (flair#1317, K&S ruling 2026-08-21)
|
|
572
|
+
*
|
|
573
|
+
* **At most one ACTIVE `Credential(kind:"idp", idpSubject:<sub>)` exists at a
|
|
574
|
+
* time, regardless of `idpProvider`.** This function is where that invariant is
|
|
575
|
+
* enforced, because it is the only supported writer of the mapping.
|
|
576
|
+
*
|
|
577
|
+
* It used to dedup on `(kind, idpProvider, idpSubject)` while the resolver read
|
|
578
|
+
* `(kind, idpSubject)`. A re-link under a different provider name therefore
|
|
579
|
+
* matched nothing, INSERTED a second active credential, and left
|
|
580
|
+
* `resolveAgentFromSub` picking whichever row its search iterator served first
|
|
581
|
+
* — identity resolution by iteration order, on a security-relevant mapping.
|
|
582
|
+
*
|
|
583
|
+
* The resolver's key is the correct one and does not change: an IdP subject is
|
|
584
|
+
* an identity, and "who is this subject?" has exactly one answer. `idpProvider`
|
|
585
|
+
* stays on the row as audit/diagnostic metadata, but it does not participate in
|
|
586
|
+
* uniqueness. So:
|
|
587
|
+
*
|
|
588
|
+
* - same provider, existing active credential → RE-POINT it (`credentialReused`);
|
|
589
|
+
* - any OTHER active credential for the subject → SUPERSEDE it: terminal
|
|
590
|
+
* `status: "revoked"`, never a soft flag a later path could flip back
|
|
591
|
+
* (revoked rows are never reused here — a re-link after a revoke mints a
|
|
592
|
+
* fresh credential);
|
|
593
|
+
* - the re-point/insert and every revocation go out as ONE ops-API `upsert`
|
|
594
|
+
* batch, so there is no observable window with two active credentials or
|
|
595
|
+
* zero. If the batch fails, nothing is claimed and the call throws;
|
|
596
|
+
* - after the write the invariant is RE-READ and asserted. A store that
|
|
597
|
+
* somehow holds ≠1 active credential for the subject is a hard error, not a
|
|
598
|
+
* silent nondeterministic mapping.
|
|
599
|
+
*
|
|
600
|
+
* The principal Agent is created only if missing.
|
|
601
|
+
*
|
|
602
|
+
* RESIDUAL RISK, by design (Sherlock, #1317): whoever can call this for a
|
|
603
|
+
* subject can revoke that subject's prior credential. If two genuinely
|
|
604
|
+
* different people ever shared a subject string across providers, one's link
|
|
605
|
+
* kills the other's mapping. IdP subjects are opaque per-IdP identifiers so the
|
|
606
|
+
* collision is remote, and the alternative — duplicate active credentials with
|
|
607
|
+
* order-dependent resolution — is strictly worse. `credentialSuperseded` exists
|
|
608
|
+
* so the operator is told, not so the event is hidden.
|
|
564
609
|
*/
|
|
565
610
|
export async function provisionIdpIdentityMapping(params, deps = {}) {
|
|
566
611
|
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
@@ -628,26 +673,43 @@ export async function provisionIdpIdentityMapping(params, deps = {}) {
|
|
|
628
673
|
}
|
|
629
674
|
principalCreated = true;
|
|
630
675
|
}
|
|
631
|
-
//
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
676
|
+
// ── flair#1317: look SUBJECT-WIDE, not (provider, subject) ─────────────────
|
|
677
|
+
// The resolver's key is (kind, idpSubject); anything narrower here leaves
|
|
678
|
+
// credentials that dedup cannot see but resolution can.
|
|
679
|
+
const findCredentialsForSubject = async () => {
|
|
680
|
+
const res = await fetchImpl(opsUrl, {
|
|
681
|
+
method: "POST",
|
|
682
|
+
headers: { "Content-Type": "application/json", Authorization: authHeader },
|
|
683
|
+
body: JSON.stringify({
|
|
684
|
+
operation: "search_by_conditions",
|
|
685
|
+
database: "flair",
|
|
686
|
+
table: "Credential",
|
|
687
|
+
operator: "and",
|
|
688
|
+
conditions: [
|
|
689
|
+
{ search_attribute: "kind", search_type: "equals", search_value: "idp" },
|
|
690
|
+
{ search_attribute: "idpSubject", search_type: "equals", search_value: params.idpSubject },
|
|
691
|
+
],
|
|
692
|
+
get_attributes: ["id", "principalId", "idpProvider", "idpSubject", "status", "label", "createdAt"],
|
|
693
|
+
}),
|
|
694
|
+
});
|
|
695
|
+
const body = res.ok ? await res.json().catch(() => []) : [];
|
|
696
|
+
return Array.isArray(body) ? body : [];
|
|
697
|
+
};
|
|
698
|
+
const subjectCreds = await findCredentialsForSubject();
|
|
699
|
+
const activeCreds = subjectCreds.filter(isResolvableCredential);
|
|
700
|
+
// Survivor: an ACTIVE same-provider credential is re-pointed (the idempotent
|
|
701
|
+
// re-run and the documented same-provider link). A revoked one is never
|
|
702
|
+
// resurrected — a re-link after a revoke mints a fresh credential.
|
|
703
|
+
const reused = activeCreds.find((c) => c?.idpProvider === params.idpProvider && c?.id);
|
|
704
|
+
const credentialId = reused?.id ?? `cred_idp_${params.idpProvider}_${randomBytes(6).toString("hex")}`;
|
|
705
|
+
// Everything else active for this subject is superseded. Under the old
|
|
706
|
+
// (provider, subject) key these rows were simply invisible; they are what made
|
|
707
|
+
// resolution order-dependent.
|
|
708
|
+
const superseded = activeCreds.filter((c) => c?.id && c.id !== credentialId);
|
|
709
|
+
// ONE batched write: the survivor first, then the revocations. A single
|
|
710
|
+
// ops-API operation is the strongest atomicity this surface can express, and
|
|
711
|
+
// ordering the survivor first means even a partially-applied batch can never
|
|
712
|
+
// leave the subject with ZERO resolvable credentials (the fail-open denial).
|
|
651
713
|
const upsertRes = await fetchImpl(opsUrl, {
|
|
652
714
|
method: "POST",
|
|
653
715
|
headers: { "Content-Type": "application/json", Authorization: authHeader },
|
|
@@ -664,9 +726,25 @@ export async function provisionIdpIdentityMapping(params, deps = {}) {
|
|
|
664
726
|
status: "active",
|
|
665
727
|
idpProvider: params.idpProvider,
|
|
666
728
|
idpSubject: params.idpSubject,
|
|
667
|
-
createdAt:
|
|
729
|
+
createdAt: reused ? undefined : now,
|
|
668
730
|
lastUsedAt: now,
|
|
669
731
|
},
|
|
732
|
+
// Retained, not deleted: the revocation stays legible in storage and in
|
|
733
|
+
// Harper's table audit log (which records the full record image of
|
|
734
|
+
// every write). Identifying fields are echoed back so the row survives
|
|
735
|
+
// as a well-formed, revoked credential whichever merge semantics the
|
|
736
|
+
// ops API applies.
|
|
737
|
+
...superseded.map((c) => ({
|
|
738
|
+
id: c.id,
|
|
739
|
+
principalId: c.principalId,
|
|
740
|
+
kind: "idp",
|
|
741
|
+
label: c.label,
|
|
742
|
+
status: "revoked",
|
|
743
|
+
idpProvider: c.idpProvider,
|
|
744
|
+
idpSubject: params.idpSubject,
|
|
745
|
+
createdAt: c.createdAt,
|
|
746
|
+
updatedAt: now,
|
|
747
|
+
})),
|
|
670
748
|
],
|
|
671
749
|
}),
|
|
672
750
|
});
|
|
@@ -674,7 +752,26 @@ export async function provisionIdpIdentityMapping(params, deps = {}) {
|
|
|
674
752
|
const text = await upsertRes.text().catch(() => "");
|
|
675
753
|
throw new Error(`Identity mapping: failed to write Credential(kind:idp) mapping (HTTP ${upsertRes.status}): ${text}`);
|
|
676
754
|
}
|
|
677
|
-
|
|
755
|
+
// ── The invariant, RE-READ ────────────────────────────────────────────────
|
|
756
|
+
// Asserting what we intended to write proves nothing. This asks the store.
|
|
757
|
+
// ≠1 active credential means the resolver's answer for this subject is
|
|
758
|
+
// order-dependent, so this fails LOUDLY rather than returning a mapping the
|
|
759
|
+
// operator would reasonably believe is deterministic.
|
|
760
|
+
const afterCreds = (await findCredentialsForSubject()).filter(isResolvableCredential);
|
|
761
|
+
if (afterCreds.length !== 1 || afterCreds[0]?.id !== credentialId) {
|
|
762
|
+
const seen = afterCreds.map((c) => `${c?.id} → ${c?.principalId} (provider '${c?.idpProvider}')`).join("; ") || "none";
|
|
763
|
+
throw new Error(`Identity mapping: the uniqueness invariant does not hold after the write — subject '${params.idpSubject}' ` +
|
|
764
|
+
`has ${afterCreds.length} active Credential(kind:idp) row(s) [${seen}], expected exactly 1 (${credentialId}). ` +
|
|
765
|
+
`Runtime resolution for this subject would be iteration-order-dependent (flair#1317). ` +
|
|
766
|
+
`Inspect the Credential table for kind:"idp" idpSubject:"${params.idpSubject}" and revoke the rows that should not resolve.`);
|
|
767
|
+
}
|
|
768
|
+
return {
|
|
769
|
+
principalCreated,
|
|
770
|
+
credentialId,
|
|
771
|
+
credentialReused: Boolean(reused),
|
|
772
|
+
credentialSuperseded: superseded.length > 0,
|
|
773
|
+
supersededCredentialIds: superseded.map((c) => String(c.id)),
|
|
774
|
+
};
|
|
678
775
|
}
|
|
679
776
|
// ─── Restart only ────────────────────────────────────────────────────────────
|
|
680
777
|
/** `restart` only — used by `disableMcp` (flag off + restart, no config
|
|
@@ -1069,10 +1166,21 @@ export async function enableMcp(params, deps = {}) {
|
|
|
1069
1166
|
// and the one silent failure mode this surface has is discovering that
|
|
1070
1167
|
// via an empty bootstrap. So the step that creates the mapping states it
|
|
1071
1168
|
// plainly, names the link remedy, and points at the runtime diagnostic.
|
|
1169
|
+
// flair#1317 — a cross-provider re-link REVOKES the subject's prior
|
|
1170
|
+
// credential (one active credential per subject is the invariant). That is
|
|
1171
|
+
// a credential dying, so it is stated as such, by id: an operator must
|
|
1172
|
+
// never discover it later from something that stopped working.
|
|
1173
|
+
const supersedeNote = mapping.credentialSuperseded
|
|
1174
|
+
? ` SUPERSEDED: ${mapping.supersededCredentialIds.length} prior Credential(kind:idp) row(s) for this subject ` +
|
|
1175
|
+
`were REVOKED, not de-duplicated — ${mapping.supersededCredentialIds.join(", ")}. ` +
|
|
1176
|
+
`They no longer resolve, and anything relying on them stops working. ` +
|
|
1177
|
+
`Exactly one active credential per (kind, idpSubject) is the invariant that keeps resolution deterministic.`
|
|
1178
|
+
: "";
|
|
1072
1179
|
push(true, `connector identity: sub '${params.idpSubject}' (provider '${idpProvider}') resolves to Agent '${principal}' — ` +
|
|
1073
1180
|
`every /mcp call reads and writes AS '${principal}'. ` +
|
|
1074
1181
|
`principal ${mapping.principalCreated ? "created" : "already existed"}; ` +
|
|
1075
|
-
`Credential(kind:idp) ${mapping.credentialReused ? "re-pointed" : "created"} (${mapping.credentialId})
|
|
1182
|
+
`Credential(kind:idp) ${mapping.credentialReused ? "re-pointed" : "created"} (${mapping.credentialId}).` +
|
|
1183
|
+
`${supersedeNote} ` +
|
|
1076
1184
|
`If your CLI signs as a DIFFERENT agent id, the connector sees that agent's DISTINCT memory scope (by design) — ` +
|
|
1077
1185
|
`re-run with --principal <your-agent-id> to link them. ` +
|
|
1078
1186
|
`Diagnostic: the bootstrap tool's agentId/scope fields always say who the server resolved you to.`);
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
import { Resource, databases } from "harper";
|
|
20
20
|
import { isAdmin, allowAdmin, invalidateAdminCache } from "./agent-auth.js";
|
|
21
21
|
import { reconcileAdminFields } from "./agent-admin.js";
|
|
22
|
+
import { noteMemoryUpsert } from "./bm25-index-service.js";
|
|
22
23
|
const DEFAULT_SOUL_KEYS = (agentId, displayName, role, now) => ({
|
|
23
24
|
name: displayName,
|
|
24
25
|
role,
|
|
@@ -124,6 +125,7 @@ export class AgentSeed extends Resource {
|
|
|
124
125
|
archived: false,
|
|
125
126
|
};
|
|
126
127
|
await databases.flair.Memory.put(record);
|
|
128
|
+
noteMemoryUpsert(record);
|
|
127
129
|
memories.push(record);
|
|
128
130
|
}
|
|
129
131
|
} // end !hasOnboardingMemory
|
package/dist/resources/Memory.js
CHANGED
|
@@ -13,6 +13,7 @@ import { buildProvenance, makeAuthGate, makeReadScope, makeByIdReadGate, makeSco
|
|
|
13
13
|
import { RECORD_TYPES } from "./record-types.js";
|
|
14
14
|
import { attachTrust } from "./trust-block.js";
|
|
15
15
|
import { recordCitations } from "./usage-recording.js";
|
|
16
|
+
import { noteMemoryUpsert, noteMemoryDelete } from "./bm25-index-service.js";
|
|
16
17
|
/**
|
|
17
18
|
* flair#744 slice 1 — read the opt-in `includeTrust` flag for a by-id get.
|
|
18
19
|
* Two entry shapes: an in-process caller (resources/mcp-tools.ts's memory_get)
|
|
@@ -306,7 +307,11 @@ async function closeSupersededRecord(ctx, oldId, patch) {
|
|
|
306
307
|
if (!existing) {
|
|
307
308
|
throw new Error(`supersede-close: record ${oldId} not found`);
|
|
308
309
|
}
|
|
309
|
-
|
|
310
|
+
const closed = { ...existing, ...patch };
|
|
311
|
+
await withDetachedTxn(ctx, () => databases.flair.Memory.put(closed));
|
|
312
|
+
// flair#1357 — a supersede-close sets `validTo`, which the retrieval filters
|
|
313
|
+
// read, so the lexical index has to see it as eagerly as a content write.
|
|
314
|
+
noteMemoryUpsert(closed);
|
|
310
315
|
}
|
|
311
316
|
/** Does an agent hold a "write" grant from `ownerId`? Same MemoryGrant lookup
|
|
312
317
|
* pattern as Memory.search()/SemanticSearch.ts (read/search scopes) — reused
|
|
@@ -766,6 +771,11 @@ export class Memory extends databases.flair.Memory {
|
|
|
766
771
|
await stampOriginatorInstanceId(content);
|
|
767
772
|
// ── Write the new record FIRST ──────────────────────────────────────────
|
|
768
773
|
const result = await super.post(content);
|
|
774
|
+
// flair#1357 — read-your-write for the lexical leg. The table change feed
|
|
775
|
+
// (resources/bm25-index-service.ts) is the CORRECTNESS mechanism; this
|
|
776
|
+
// synchronous hook is what makes a store immediately searchable rather
|
|
777
|
+
// than searchable-after-the-feed-turns.
|
|
778
|
+
noteMemoryUpsert(content);
|
|
769
779
|
// ── THEN close the superseded record ────────────────────────────────────
|
|
770
780
|
// Write-new-BEFORE-close-old: the previous order (close-old via a fire-
|
|
771
781
|
// and-forget `.catch(()=>{})` BEFORE the new write) could tombstone the
|
|
@@ -807,7 +817,9 @@ export class Memory extends databases.flair.Memory {
|
|
|
807
817
|
});
|
|
808
818
|
}
|
|
809
819
|
delete content._reindex;
|
|
810
|
-
|
|
820
|
+
const reindexed = await super.put(content);
|
|
821
|
+
noteMemoryUpsert(content);
|
|
822
|
+
return reindexed;
|
|
811
823
|
}
|
|
812
824
|
// Create/update ownership (same rule as post): a non-admin agent may only
|
|
813
825
|
// write memories it owns, via resolveAgentAuth (gate annotation), not
|
|
@@ -1043,6 +1055,8 @@ export class Memory extends databases.flair.Memory {
|
|
|
1043
1055
|
await stampOriginatorInstanceId(content);
|
|
1044
1056
|
// ── Write the new/updated record FIRST ──────────────────────────────────
|
|
1045
1057
|
const result = await super.put(content);
|
|
1058
|
+
// flair#1357 — read-your-write for the lexical leg (see post()).
|
|
1059
|
+
noteMemoryUpsert(content);
|
|
1046
1060
|
// ── THEN close the superseded record (see post()) ───────────────────────
|
|
1047
1061
|
await closeSupersededIfNeeded(ctx, content, "put");
|
|
1048
1062
|
// flair#744 slice A: citation-on-write — POST-COMMIT, fully
|
|
@@ -1067,8 +1081,11 @@ export class Memory extends databases.flair.Memory {
|
|
|
1067
1081
|
// before the read-gate fix — the read-scoping override must not leak
|
|
1068
1082
|
// into delete()'s internal record lookup.
|
|
1069
1083
|
const record = await super.get(id);
|
|
1070
|
-
if (!record)
|
|
1071
|
-
|
|
1084
|
+
if (!record) {
|
|
1085
|
+
const gone = await super.delete(id);
|
|
1086
|
+
noteMemoryDelete(id);
|
|
1087
|
+
return gone;
|
|
1088
|
+
}
|
|
1072
1089
|
if (record.durability === "permanent") {
|
|
1073
1090
|
// Middleware already guards this for non-admins, but belt-and-suspenders
|
|
1074
1091
|
const ctx = this.getContext?.();
|
|
@@ -1081,6 +1098,8 @@ export class Memory extends databases.flair.Memory {
|
|
|
1081
1098
|
});
|
|
1082
1099
|
}
|
|
1083
1100
|
}
|
|
1084
|
-
|
|
1101
|
+
const deleted = await super.delete(id);
|
|
1102
|
+
noteMemoryDelete(id);
|
|
1103
|
+
return deleted;
|
|
1085
1104
|
}
|
|
1086
1105
|
}
|
|
@@ -4,6 +4,7 @@ import { computeContentHash, findExistingMemoryByContentHash } from "./memory-fe
|
|
|
4
4
|
import { FORBIDDEN, UNAUTH, stampAttribution } from "./record-type-kit.js";
|
|
5
5
|
import { assertValidVisibility, assertVisibilityAllowedForDurability, PRIVATE_VISIBILITY } from "./memory-visibility.js";
|
|
6
6
|
import { assertValidDurability } from "./memory-durability.js";
|
|
7
|
+
import { noteMemoryUpsert } from "./bm25-index-service.js";
|
|
7
8
|
export class FeedMemories extends Resource {
|
|
8
9
|
// Self-authorize via the Ed25519 agent verify (the auth reshape removes the
|
|
9
10
|
// gate's admin elevation).
|
|
@@ -125,6 +126,8 @@ export class FeedMemories extends Resource {
|
|
|
125
126
|
record.visibility = PRIVATE_VISIBILITY;
|
|
126
127
|
}
|
|
127
128
|
await databases.flair.Memory.put(record);
|
|
129
|
+
// flair#1357 — raw-table write: hook it explicitly (see bm25-index-service).
|
|
130
|
+
noteMemoryUpsert(record);
|
|
128
131
|
return record;
|
|
129
132
|
}
|
|
130
133
|
async *connect(target, incomingMessages) {
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import { Resource, databases } from "harper";
|
|
20
20
|
import { isAdmin } from "./agent-auth.js";
|
|
21
|
+
import { noteMemoryUpsert, noteMemoryDelete } from "./bm25-index-service.js";
|
|
21
22
|
export class MemoryMaintenance extends Resource {
|
|
22
23
|
/** POST requires auth — either an agent acting on its own memories, or admin. */
|
|
23
24
|
allowCreate() {
|
|
@@ -65,6 +66,9 @@ export class MemoryMaintenance extends Resource {
|
|
|
65
66
|
if (!dryRun) {
|
|
66
67
|
try {
|
|
67
68
|
await databases.flair.Memory.delete(record.id);
|
|
69
|
+
// flair#1357 — ephemeral expiry removes the row from what the
|
|
70
|
+
// lexical leg may score.
|
|
71
|
+
noteMemoryDelete(record.id);
|
|
68
72
|
stats.expired++;
|
|
69
73
|
}
|
|
70
74
|
catch {
|
|
@@ -88,11 +92,16 @@ export class MemoryMaintenance extends Resource {
|
|
|
88
92
|
if (ageDays > 30) {
|
|
89
93
|
if (!dryRun) {
|
|
90
94
|
try {
|
|
91
|
-
|
|
95
|
+
const archivedRow = {
|
|
92
96
|
...record,
|
|
93
97
|
archived: true,
|
|
94
98
|
archivedAt: now.toISOString(),
|
|
95
|
-
}
|
|
99
|
+
};
|
|
100
|
+
await databases.flair.Memory.update(record.id, archivedRow);
|
|
101
|
+
// flair#1357 — an `archived` flip changes what the retrieval
|
|
102
|
+
// conditions (`archived not_equal true`) admit, so the lexical
|
|
103
|
+
// index has to see it, not just content writes.
|
|
104
|
+
noteMemoryUpsert(archivedRow);
|
|
96
105
|
stats.archived++;
|
|
97
106
|
}
|
|
98
107
|
catch {
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
// ─── Harper wiring for the persistent BM25 index (flair#1357) ───────────────
|
|
2
|
+
//
|
|
3
|
+
// ./bm25-index.ts is the Harper-free data structure. This module owns the one
|
|
4
|
+
// process-wide instance of it and answers the only two questions the retrieval
|
|
5
|
+
// core asks: "can you serve this lexical leg?" and "here is a write you should
|
|
6
|
+
// know about".
|
|
7
|
+
//
|
|
8
|
+
// ── WHERE THE INDEX STATE LIVES, AND WHY ────────────────────────────────────
|
|
9
|
+
// In process memory, per Harper worker, NOT in a Harper table.
|
|
10
|
+
//
|
|
11
|
+
// A Harper-table posting list was considered and rejected on WRITE cost: a
|
|
12
|
+
// memory averages ~26 tokens (the measured live corpus,
|
|
13
|
+
// test/bench/corpus-profiler/profiles), so persisting postings would turn one
|
|
14
|
+
// `Memory.put()` into ~25 additional indexed row writes inside the same
|
|
15
|
+
// transaction — write amplification on the ingestion path in order to speed up
|
|
16
|
+
// the read path. It would also put a Harper round-trip per query TERM back
|
|
17
|
+
// into recall. The in-process structure costs one full corpus scan per worker
|
|
18
|
+
// lifetime, which is exactly ONE instance of what the defect used to charge on
|
|
19
|
+
// EVERY query.
|
|
20
|
+
//
|
|
21
|
+
// Footprint at 250k documents: ~6.5M postings held as paired Int32Arrays
|
|
22
|
+
// (~52MB), the term dictionary (~20MB), and per-document scope metadata with
|
|
23
|
+
// NO content and NO embedding (~50MB) — order 120MB steady state. For scale:
|
|
24
|
+
// the code this replaces allocated a 250k-entry array of per-document term
|
|
25
|
+
// Maps plus the whole projected corpus INCLUDING content, transiently, on
|
|
26
|
+
// every single query.
|
|
27
|
+
//
|
|
28
|
+
// ── COLD BOOT: LAZY ─────────────────────────────────────────────────────────
|
|
29
|
+
// Built on the first hybrid query that carries query text, not at component
|
|
30
|
+
// start. Eager building would add a full corpus scan to every boot including
|
|
31
|
+
// the many processes that never search (CLI verbs, migration boots, health
|
|
32
|
+
// checks), and it would race the embedding engine's own model load. The first
|
|
33
|
+
// query after boot pays what every query used to pay; every one after it pays
|
|
34
|
+
// nothing. Concurrent first queries share a single build promise.
|
|
35
|
+
//
|
|
36
|
+
// ── STAYING CURRENT ─────────────────────────────────────────────────────────
|
|
37
|
+
// Two mechanisms, deliberately overlapping:
|
|
38
|
+
//
|
|
39
|
+
// 1. THE TABLE'S OWN CHANGE FEED (`Memory.subscribe`) is the authority. It
|
|
40
|
+
// is the same audit-log-backed primitive `FeedMemories.connect()` already
|
|
41
|
+
// uses, and it observes the TABLE — so it sees writes that never touch a
|
|
42
|
+
// flair resource at all: operations-API writes, `flair` CLI direct
|
|
43
|
+
// writes, and Harper replication applying federated rows. A scheme built
|
|
44
|
+
// only from hooks in flair's own write paths CANNOT see those, which is
|
|
45
|
+
// why the feed — not the hook list — is the correctness argument.
|
|
46
|
+
// Verified against a stock instance: an operations-API insert and an
|
|
47
|
+
// operations-API delete both arrive (put/delete with the full row).
|
|
48
|
+
//
|
|
49
|
+
// 2. SYNCHRONOUS HOOKS at flair's own write surface (`noteMemoryUpsert` /
|
|
50
|
+
// `noteMemoryDelete`) give READ-YOUR-WRITE. The feed is asynchronous, so
|
|
51
|
+
// without the hooks a store immediately followed by a search would be a
|
|
52
|
+
// race — and the path being replaced had no such race, because it refetched
|
|
53
|
+
// the corpus every query. Both mechanisms are idempotent upserts keyed by
|
|
54
|
+
// id, so seeing a write twice is a no-op.
|
|
55
|
+
//
|
|
56
|
+
// If the feed cannot be established, or delivers an event shape we do not
|
|
57
|
+
// understand (Harper emits a bare `reload` marker when a base copy / resync is
|
|
58
|
+
// applied — precisely when the index CANNOT be patched incrementally), the
|
|
59
|
+
// index marks itself stale and the next query rebuilds it. If subscription
|
|
60
|
+
// fails outright, the index DISABLES itself and every query falls back to the
|
|
61
|
+
// legacy per-query corpus scan. A slow-but-correct recall is acceptable; a
|
|
62
|
+
// silently stale one is not — recall is the product floor.
|
|
63
|
+
//
|
|
64
|
+
// ── MULTI-WORKER ────────────────────────────────────────────────────────────
|
|
65
|
+
// The instance is per worker thread, so in a multi-worker configuration each
|
|
66
|
+
// worker pays its own first-query build and holds its own copy of the index.
|
|
67
|
+
// Both of flair's shipped launch paths pin `THREADS_COUNT=1` (src/cli.ts's
|
|
68
|
+
// launchd plist and its direct-spawn env), as does the integration harness, so
|
|
69
|
+
// the shipped configuration has exactly one worker and "per worker" is "per
|
|
70
|
+
// process". Cross-worker write visibility rides on mechanism (1): the feed is
|
|
71
|
+
// audit-log-backed and the audit store is shared, so a write committed by
|
|
72
|
+
// another worker still arrives. Mechanism (2) is local to the writing worker,
|
|
73
|
+
// which is why it is an immediacy optimisation and never the correctness
|
|
74
|
+
// argument.
|
|
75
|
+
import { databases } from "harper";
|
|
76
|
+
import { withDetachedTxn } from "./table-helpers.js";
|
|
77
|
+
import { Bm25Index, INDEX_SELECT } from "./bm25-index.js";
|
|
78
|
+
/** Kill switch. Default ON; set FLAIR_BM25_INDEX=false/0/off to force every
|
|
79
|
+
* query back onto the legacy per-query corpus scan + buildBM25(). Read
|
|
80
|
+
* per-call so it can be flipped without a rebuild and set per-case in tests. */
|
|
81
|
+
export function bm25IndexEnabled() {
|
|
82
|
+
const v = (process.env.FLAIR_BM25_INDEX ?? "true").toLowerCase();
|
|
83
|
+
return v === "true" || v === "1" || v === "on";
|
|
84
|
+
}
|
|
85
|
+
const index = new Bm25Index();
|
|
86
|
+
let state = "empty";
|
|
87
|
+
let buildPromise = null;
|
|
88
|
+
let pending = null;
|
|
89
|
+
let feedStarted = false;
|
|
90
|
+
let disabledReason = "";
|
|
91
|
+
/** Test seam — resets everything this module owns. */
|
|
92
|
+
export function __resetBm25IndexForTests() {
|
|
93
|
+
index.clear();
|
|
94
|
+
state = "empty";
|
|
95
|
+
buildPromise = null;
|
|
96
|
+
pending = null;
|
|
97
|
+
feedStarted = false;
|
|
98
|
+
disabledReason = "";
|
|
99
|
+
}
|
|
100
|
+
/** Diagnostics, for tests and `flair doctor`-shaped callers. */
|
|
101
|
+
export function bm25IndexStatus() {
|
|
102
|
+
return { state, size: index.size, postings: index.postingCount, terms: index.termCount, reason: disabledReason };
|
|
103
|
+
}
|
|
104
|
+
function project(record) {
|
|
105
|
+
if (!record || typeof record.id !== "string")
|
|
106
|
+
return null;
|
|
107
|
+
const out = { id: record.id };
|
|
108
|
+
for (const k of INDEX_SELECT)
|
|
109
|
+
if (k !== "id" && k in record)
|
|
110
|
+
out[k] = record[k];
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
function apply(ev) {
|
|
114
|
+
if (ev.kind === "delete")
|
|
115
|
+
index.remove(ev.id);
|
|
116
|
+
else
|
|
117
|
+
index.upsert(ev.record);
|
|
118
|
+
}
|
|
119
|
+
function record(ev) {
|
|
120
|
+
if (state === "disabled" || state === "empty")
|
|
121
|
+
return; // a later build will scan it
|
|
122
|
+
if (state === "building") {
|
|
123
|
+
pending.push(ev);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
apply(ev);
|
|
127
|
+
}
|
|
128
|
+
/** Read-your-write hook: call immediately after a committed Memory write that
|
|
129
|
+
* changed content or any scope/temporal attribute. Safe to call for writes
|
|
130
|
+
* that changed neither (it is an idempotent re-index of one row). */
|
|
131
|
+
export function noteMemoryUpsert(row) {
|
|
132
|
+
const r = project(row);
|
|
133
|
+
if (r)
|
|
134
|
+
record({ kind: "upsert", record: r });
|
|
135
|
+
}
|
|
136
|
+
/** Read-your-write hook: call immediately after a committed Memory delete. */
|
|
137
|
+
export function noteMemoryDelete(id) {
|
|
138
|
+
if (typeof id === "string" && id.length > 0)
|
|
139
|
+
record({ kind: "delete", id });
|
|
140
|
+
}
|
|
141
|
+
/** Force the next query to rebuild — used when the feed reports a change we
|
|
142
|
+
* cannot express incrementally (a resync/base-copy `reload` marker). */
|
|
143
|
+
export function markBm25IndexStale(reason) {
|
|
144
|
+
if (state === "disabled")
|
|
145
|
+
return;
|
|
146
|
+
disabledReason = reason;
|
|
147
|
+
state = "empty";
|
|
148
|
+
buildPromise = null;
|
|
149
|
+
}
|
|
150
|
+
function disable(reason) {
|
|
151
|
+
state = "disabled";
|
|
152
|
+
disabledReason = reason;
|
|
153
|
+
buildPromise = null;
|
|
154
|
+
pending = null;
|
|
155
|
+
index.clear();
|
|
156
|
+
}
|
|
157
|
+
async function startFeed(ctx) {
|
|
158
|
+
if (feedStarted)
|
|
159
|
+
return;
|
|
160
|
+
feedStarted = true;
|
|
161
|
+
const subscription = await withDetachedTxn(ctx, () => databases.flair.Memory.subscribe({ omitCurrent: true }));
|
|
162
|
+
// Deliberately not awaited: the consumer runs for the life of the process.
|
|
163
|
+
(async () => {
|
|
164
|
+
try {
|
|
165
|
+
for await (const ev of subscription) {
|
|
166
|
+
const type = ev?.type;
|
|
167
|
+
if (type === "delete") {
|
|
168
|
+
record({ kind: "delete", id: String(ev.id) });
|
|
169
|
+
}
|
|
170
|
+
else if (type === "put" || type === "insert" || type === "update" || type === "upsert") {
|
|
171
|
+
const r = project(ev?.value);
|
|
172
|
+
if (r)
|
|
173
|
+
record({ kind: "upsert", record: r });
|
|
174
|
+
else
|
|
175
|
+
markBm25IndexStale(`feed ${type} event carried no usable record`);
|
|
176
|
+
}
|
|
177
|
+
else if (type !== undefined) {
|
|
178
|
+
// Includes Harper's `reload` base-copy/resync marker: the table's
|
|
179
|
+
// contents may have been replaced wholesale with no per-row events.
|
|
180
|
+
markBm25IndexStale(`unhandled feed event type ${String(type)}`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
disable("change feed ended");
|
|
184
|
+
}
|
|
185
|
+
catch (err) {
|
|
186
|
+
disable("change feed error: " + String(err?.message ?? err));
|
|
187
|
+
}
|
|
188
|
+
})();
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Build (or rebuild) the index from one full corpus scan.
|
|
192
|
+
*
|
|
193
|
+
* ORDER IS LOAD-BEARING: the change feed is started BEFORE the scan, and the
|
|
194
|
+
* events it delivers during the scan are buffered and replayed AFTER it. A
|
|
195
|
+
* delete that lands mid-scan for a row the cursor has not reached yet would
|
|
196
|
+
* otherwise be applied first and then undone by the cursor re-adding the row.
|
|
197
|
+
* Replaying after the scan lets the newer event win, whichever order they
|
|
198
|
+
* physically occurred in.
|
|
199
|
+
*/
|
|
200
|
+
async function build(ctx) {
|
|
201
|
+
state = "building";
|
|
202
|
+
pending = [];
|
|
203
|
+
index.clear();
|
|
204
|
+
try {
|
|
205
|
+
await startFeed(ctx);
|
|
206
|
+
const results = withDetachedTxn(ctx, () => databases.flair.Memory.search({ select: INDEX_SELECT }));
|
|
207
|
+
for await (const row of results) {
|
|
208
|
+
const r = project(row);
|
|
209
|
+
if (r)
|
|
210
|
+
index.upsert(r);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
catch (err) {
|
|
214
|
+
disable("build failed: " + String(err?.message ?? err));
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
const buffered = pending ?? [];
|
|
218
|
+
pending = null;
|
|
219
|
+
// `state` may have been knocked back to "empty" by a stale marker that
|
|
220
|
+
// arrived during the scan; in that case do not claim readiness.
|
|
221
|
+
if (state !== "building")
|
|
222
|
+
return false;
|
|
223
|
+
state = "ready";
|
|
224
|
+
for (const ev of buffered)
|
|
225
|
+
apply(ev);
|
|
226
|
+
return true;
|
|
227
|
+
}
|
|
228
|
+
async function ensureReady(ctx) {
|
|
229
|
+
if (!bm25IndexEnabled())
|
|
230
|
+
return false;
|
|
231
|
+
if (state === "disabled")
|
|
232
|
+
return false;
|
|
233
|
+
if (state === "ready")
|
|
234
|
+
return true;
|
|
235
|
+
if (!buildPromise)
|
|
236
|
+
buildPromise = build(ctx).finally(() => { buildPromise = null; });
|
|
237
|
+
return buildPromise;
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* The lexical leg, served from the index. Returns the BM25 candidate ids
|
|
241
|
+
* (score>0, best-first, sliced to `limit`) — or NULL when the index declines,
|
|
242
|
+
* in which case the caller MUST run the legacy corpus scan + buildBM25(). Null
|
|
243
|
+
* is returned for: the kill switch, a failed/disabled index, and any query
|
|
244
|
+
* whose conditions the index cannot reproduce exactly (see
|
|
245
|
+
* ./bm25-index.ts's `planQuery`).
|
|
246
|
+
*/
|
|
247
|
+
export async function indexedBm25Ids(params) {
|
|
248
|
+
if (!(await ensureReady(params.ctx)))
|
|
249
|
+
return null;
|
|
250
|
+
try {
|
|
251
|
+
return index.rank(params);
|
|
252
|
+
}
|
|
253
|
+
catch (err) {
|
|
254
|
+
disable("rank failed: " + String(err?.message ?? err));
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
}
|