@tpsdev-ai/flair 0.14.0 → 0.15.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.
@@ -0,0 +1,367 @@
1
+ /**
2
+ * fabric-upgrade.ts — `flair upgrade --target <fabric-url>`
3
+ *
4
+ * One-command upgrade of a Flair instance already DEPLOYED to a Harper Fabric
5
+ * cluster (as opposed to a local `flair upgrade`, which upgrades the globally
6
+ * installed npm packages on the current host).
7
+ *
8
+ * Why this exists — the manual dance it replaces:
9
+ * Upgrading a deployed Fabric Flair used to require, by hand:
10
+ * 1. mkdir a fresh temp dir
11
+ * 2. write a package.json that depends on @tpsdev-ai/flair@<version> AND
12
+ * carries an `overrides` block pinning @harperfast/harper to a fixed
13
+ * version — because the PUBLISHED flair declares an OLD Harper
14
+ * (@harperfast/harper@5.0.21 as of flair@0.14.0) whose component
15
+ * packager (`packageComponent`) emits an EMPTY tarball when the package
16
+ * root lives under node_modules (the real npm-install scenario).
17
+ * See flair#513 — fixed in Harper >= 5.1.13.
18
+ * 3. npm install
19
+ * 4. run `flair deploy --target <url>` from that temp dir
20
+ * This module bakes the Harper pin in so the whole thing is one command.
21
+ *
22
+ * `deploy()` (src/deploy.ts) is REUSED verbatim for the packaging + spawn of
23
+ * the bundled Harper's `harper deploy` — this module never reimplements deploy.
24
+ * It only: resolves the target version, prepares a clean temp deployable with
25
+ * the Harper override baked in, confirms the resolved Harper bin is the fix
26
+ * version, then hands the temp package root to deploy() via `packageRoot`.
27
+ */
28
+ import { spawnSync } from "node:child_process";
29
+ import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync } from "node:fs";
30
+ import { join } from "node:path";
31
+ import { tmpdir } from "node:os";
32
+ import { createRequire } from "node:module";
33
+ /**
34
+ * Minimum @harperfast/harper version whose component packager works when the
35
+ * package root is under node_modules (flair#513 — the empty-tarball fix landed
36
+ * in 5.1.13). Anything below this MUST be overridden before a Fabric deploy.
37
+ */
38
+ export const MIN_HARPER_VERSION = "5.1.13";
39
+ /**
40
+ * Version we pin Harper to when an override is needed and `--harper-version`
41
+ * isn't given and we can't resolve the registry's latest. Known-good, ships the
42
+ * packageComponent fix. 5.1.14 is the latest published as of this writing.
43
+ */
44
+ export const DEFAULT_HARPER_PIN = "5.1.14";
45
+ const FLAIR_PKG = "@tpsdev-ai/flair";
46
+ const HARPER_PKG = "@harperfast/harper";
47
+ /**
48
+ * Parse "5.1.14" / "5.1.14-rc.1" / "v0.14.0" into [major, minor, patch],
49
+ * ignoring any pre-release / build suffix for comparison. Returns null when the
50
+ * core can't be parsed as three numeric segments.
51
+ */
52
+ export function parseSemverCore(v) {
53
+ if (!v)
54
+ return null;
55
+ const core = v.trim().replace(/^v/, "").split("-")[0].split("+")[0];
56
+ const parts = core.split(".");
57
+ if (parts.length < 3)
58
+ return null;
59
+ const nums = parts.slice(0, 3).map((p) => Number(p));
60
+ if (nums.some((n) => !Number.isFinite(n)))
61
+ return null;
62
+ return [nums[0], nums[1], nums[2]];
63
+ }
64
+ /** True when `a` >= `b` by numeric major.minor.patch (pre-release ignored). */
65
+ export function semverGte(a, b) {
66
+ const pa = parseSemverCore(a);
67
+ const pb = parseSemverCore(b);
68
+ if (!pa || !pb)
69
+ return false;
70
+ for (let i = 0; i < 3; i++) {
71
+ if (pa[i] > pb[i])
72
+ return true;
73
+ if (pa[i] < pb[i])
74
+ return false;
75
+ }
76
+ return true; // equal
77
+ }
78
+ /**
79
+ * Decide what (if any) @harperfast/harper override to bake into the temp
80
+ * deployable's package.json.
81
+ *
82
+ * - If flair already declares Harper >= MIN_HARPER_VERSION → no override
83
+ * needed; keep what it ships (pin = declared).
84
+ * - Otherwise (declared is older, absent, or unparseable) → override to
85
+ * `preferredPin` (caller passes --harper-version, else registry latest,
86
+ * else DEFAULT_HARPER_PIN). We still require the chosen pin to satisfy
87
+ * MIN_HARPER_VERSION, falling back to DEFAULT_HARPER_PIN if the caller
88
+ * passed something too old.
89
+ *
90
+ * Pure — no I/O. The LOAD-BEARING bit is `overridden`: when true, the temp
91
+ * package.json gets `overrides: { "@harperfast/harper": pin }`.
92
+ */
93
+ export function resolveHarperPin(declared, preferredPin) {
94
+ if (declared && semverGte(declared, MIN_HARPER_VERSION)) {
95
+ return {
96
+ pin: declared,
97
+ overridden: false,
98
+ declared,
99
+ reason: `flair declares ${HARPER_PKG}@${declared} (>= ${MIN_HARPER_VERSION}); no override needed`,
100
+ };
101
+ }
102
+ // Need an override. Choose the pin: prefer the caller's, but never below the
103
+ // fix floor — a stale --harper-version would silently reintroduce flair#513.
104
+ let pin = preferredPin && semverGte(preferredPin, MIN_HARPER_VERSION)
105
+ ? preferredPin
106
+ : DEFAULT_HARPER_PIN;
107
+ const declaredLabel = declared ? `${declared}` : "none";
108
+ const tooOldHint = preferredPin && !semverGte(preferredPin, MIN_HARPER_VERSION)
109
+ ? ` (requested ${preferredPin} is below the ${MIN_HARPER_VERSION} fix floor — using ${pin} instead)`
110
+ : "";
111
+ return {
112
+ pin,
113
+ overridden: true,
114
+ declared,
115
+ reason: `flair declares ${HARPER_PKG}@${declaredLabel} (< ${MIN_HARPER_VERSION}, flair#513 empty-tarball) — pinning to ${pin}${tooOldHint}`,
116
+ };
117
+ }
118
+ /**
119
+ * Build the package.json contents for the temp deployable. Depends on
120
+ * @tpsdev-ai/flair@<version> and, when the Harper pin is an override, carries
121
+ * the `overrides` block that forces npm to install the fix Harper under the
122
+ * flair package's node_modules.
123
+ */
124
+ export function buildDeployablePackageJson(flairVersion, pin) {
125
+ const pkg = {
126
+ name: "flair-fabric-upgrade-stage",
127
+ version: "0.0.0",
128
+ private: true,
129
+ dependencies: {
130
+ [FLAIR_PKG]: flairVersion,
131
+ },
132
+ };
133
+ if (pin.overridden) {
134
+ pkg.overrides = { [HARPER_PKG]: pin.pin };
135
+ }
136
+ return pkg;
137
+ }
138
+ // ─── Default (real) dependency implementations ──────────────────────────────
139
+ const REGISTRY = "https://registry.npmjs.org";
140
+ async function defaultFetchLatestFlairVersion() {
141
+ const res = await fetch(`${REGISTRY}/${FLAIR_PKG}/latest`, {
142
+ signal: AbortSignal.timeout(10_000),
143
+ });
144
+ if (!res.ok) {
145
+ throw new Error(`npm registry returned ${res.status} for ${FLAIR_PKG}/latest`);
146
+ }
147
+ const data = (await res.json());
148
+ if (!data.version)
149
+ throw new Error(`No version in registry response for ${FLAIR_PKG}`);
150
+ return data.version;
151
+ }
152
+ async function defaultFetchDeclaredHarperVersion(flairVersion) {
153
+ const res = await fetch(`${REGISTRY}/${FLAIR_PKG}/${flairVersion}`, {
154
+ signal: AbortSignal.timeout(10_000),
155
+ });
156
+ if (!res.ok)
157
+ return null;
158
+ const data = (await res.json());
159
+ return data.dependencies?.[HARPER_PKG] ?? null;
160
+ }
161
+ function defaultNpmInstall(dir) {
162
+ // No package spec on argv — npm reads dependencies + overrides from the
163
+ // package.json we wrote into `dir`. --omit=dev keeps the stage lean;
164
+ // --no-audit/--no-fund cut noise. Output streamed for operator visibility.
165
+ const r = spawnSync("npm", ["install", "--omit=dev", "--no-audit", "--no-fund"], { cwd: dir, stdio: "inherit" });
166
+ if (r.error)
167
+ throw r.error;
168
+ if (r.status !== 0) {
169
+ throw new Error(`npm install failed in staging dir (exit ${r.status})`);
170
+ }
171
+ }
172
+ /**
173
+ * Query the Fabric for the deployed Flair component version. Best-effort: the
174
+ * public REST surface exposes `/Health`, which echoes the running Flair version
175
+ * in newer builds; if the shape isn't recognized we return null (verification
176
+ * downgrades to a soft notice, never a hard failure).
177
+ */
178
+ async function defaultFetchDeployedVersion(opts) {
179
+ const base = opts.url.replace(/\/$/, "");
180
+ const headers = {};
181
+ if (opts.fabricUser && opts.fabricPassword) {
182
+ const auth = Buffer.from(`${opts.fabricUser}:${opts.fabricPassword}`).toString("base64");
183
+ headers.Authorization = `Basic ${auth}`;
184
+ }
185
+ try {
186
+ const res = await fetch(`${base}/Health`, {
187
+ headers,
188
+ signal: AbortSignal.timeout(10_000),
189
+ });
190
+ if (!res.ok)
191
+ return null;
192
+ const text = await res.text();
193
+ let body;
194
+ try {
195
+ body = JSON.parse(text);
196
+ }
197
+ catch {
198
+ return null;
199
+ }
200
+ // Tolerate a few shapes: { version }, { flair: { version } }, { flairVersion }.
201
+ const v = body?.version ?? body?.flairVersion ?? body?.flair?.version ?? null;
202
+ return typeof v === "string" ? v : null;
203
+ }
204
+ catch {
205
+ return null;
206
+ }
207
+ }
208
+ /** Resolve the @harperfast/harper bin that npm installed under the staged flair. */
209
+ export function resolveStagedHarperVersion(stagingDir) {
210
+ const harperPkgJson = join(stagingDir, "node_modules", FLAIR_PKG, "node_modules", HARPER_PKG, "package.json");
211
+ // npm dedupes: with an override the fixed Harper may hoist to the staging
212
+ // root rather than nest under flair. Check both.
213
+ const hoisted = join(stagingDir, "node_modules", HARPER_PKG, "package.json");
214
+ for (const candidate of [harperPkgJson, hoisted]) {
215
+ if (existsSync(candidate)) {
216
+ try {
217
+ const pkg = JSON.parse(readFileSync(candidate, "utf8"));
218
+ if (typeof pkg.version === "string")
219
+ return pkg.version;
220
+ }
221
+ catch {
222
+ /* try next */
223
+ }
224
+ }
225
+ }
226
+ // Last resort: resolve through the staged flair's module graph.
227
+ try {
228
+ const flairPkg = join(stagingDir, "node_modules", FLAIR_PKG, "package.json");
229
+ const req = createRequire(flairPkg);
230
+ const resolved = req.resolve(`${HARPER_PKG}/package.json`);
231
+ const pkg = JSON.parse(readFileSync(resolved, "utf8"));
232
+ return typeof pkg.version === "string" ? pkg.version : null;
233
+ }
234
+ catch {
235
+ return null;
236
+ }
237
+ }
238
+ /** Locate the staged @tpsdev-ai/flair package root (the deploy() packageRoot). */
239
+ export function resolveStagedFlairRoot(stagingDir) {
240
+ const root = join(stagingDir, "node_modules", FLAIR_PKG);
241
+ if (!existsSync(join(root, "package.json"))) {
242
+ throw new Error(`Staged flair not found at ${root} — npm install may have failed`);
243
+ }
244
+ return root;
245
+ }
246
+ function defaultDeps() {
247
+ // deploy is imported lazily so unit tests can fully mock without pulling the
248
+ // real Harper-spawning module into their graph.
249
+ return {
250
+ fetchLatestFlairVersion: defaultFetchLatestFlairVersion,
251
+ fetchDeclaredHarperVersion: defaultFetchDeclaredHarperVersion,
252
+ npmInstall: defaultNpmInstall,
253
+ fetchDeployedVersion: defaultFetchDeployedVersion,
254
+ deploy: async (opts) => {
255
+ const { deploy } = await import("./deploy.js");
256
+ return deploy(opts);
257
+ },
258
+ log: (m) => console.log(m),
259
+ };
260
+ }
261
+ // ─── Orchestrator ───────────────────────────────────────────────────────────
262
+ /**
263
+ * Build the upgrade plan (steps 1 + version diff). Pure-ish: only the two
264
+ * read-only registry lookups, no install, no deploy. `--check` stops here.
265
+ */
266
+ export async function planFabricUpgrade(opts, deps) {
267
+ const project = opts.project ?? "flair";
268
+ const targetVersion = opts.version ?? (await deps.fetchLatestFlairVersion());
269
+ const declaredHarper = await deps.fetchDeclaredHarperVersion(targetVersion);
270
+ const harper = resolveHarperPin(declaredHarper, opts.harperVersion);
271
+ const currentVersion = await deps.fetchDeployedVersion({
272
+ url: opts.target,
273
+ project,
274
+ fabricUser: opts.fabricUser,
275
+ fabricPassword: opts.fabricPassword,
276
+ });
277
+ const upToDate = currentVersion != null &&
278
+ parseSemverCore(currentVersion) != null &&
279
+ parseSemverCore(targetVersion) != null &&
280
+ semverGte(currentVersion, targetVersion) &&
281
+ semverGte(targetVersion, currentVersion);
282
+ return { target: opts.target, project, targetVersion, currentVersion, harper, upToDate };
283
+ }
284
+ /**
285
+ * Full Fabric upgrade. Reuses deploy() for the packaging/deploy — this function
286
+ * only prepares the clean temp deployable (with the Harper override baked in)
287
+ * and verifies afterward.
288
+ *
289
+ * SAFETY: never logs creds; always cleans up the staging dir (finally); never
290
+ * touches the running local Flair — all work happens in an isolated temp dir
291
+ * and the deploy targets the remote Fabric URL only.
292
+ */
293
+ export async function fabricUpgrade(opts, injected) {
294
+ const deps = { ...defaultDeps(), ...injected };
295
+ const log = deps.log ?? (() => { });
296
+ // Step 1 + diff.
297
+ const plan = await planFabricUpgrade(opts, deps);
298
+ log(`Fabric: ${plan.target}`);
299
+ log(`Project: ${plan.project}`);
300
+ log(`Version: ${plan.currentVersion ?? "(unknown)"} → ${plan.targetVersion}` +
301
+ (plan.upToDate ? " (already up to date)" : ""));
302
+ log(`Harper: ${plan.harper.reason}`);
303
+ if (opts.check) {
304
+ log("");
305
+ log("--check: plan only — not deploying.");
306
+ return { plan, deployed: false, verifiedVersion: null, stagingDir: "" };
307
+ }
308
+ // Step 2: prepare a clean deployable in an isolated temp dir.
309
+ const stagingDir = mkdtempSync(join(tmpdir(), "flair-fabric-upgrade-"));
310
+ try {
311
+ const pkgJson = buildDeployablePackageJson(plan.targetVersion, plan.harper);
312
+ writeFileSync(join(stagingDir, "package.json"), JSON.stringify(pkgJson, null, 2));
313
+ log(`\nStaging ${FLAIR_PKG}@${plan.targetVersion} in ${stagingDir} ...`);
314
+ deps.npmInstall(stagingDir);
315
+ // CONFIRM the resolved Harper bin is the fix version before deploying.
316
+ const stagedHarper = resolveStagedHarperVersion(stagingDir);
317
+ if (!stagedHarper) {
318
+ throw new Error(`Could not resolve the staged ${HARPER_PKG} version — refusing to deploy ` +
319
+ `(packageComponent may emit an empty tarball, flair#513).`);
320
+ }
321
+ if (!semverGte(stagedHarper, MIN_HARPER_VERSION)) {
322
+ throw new Error(`Staged ${HARPER_PKG}@${stagedHarper} is below the ${MIN_HARPER_VERSION} ` +
323
+ `fix floor — the override did not take. Refusing to deploy (flair#513).`);
324
+ }
325
+ log(`✓ Staged ${HARPER_PKG}@${stagedHarper} (>= ${MIN_HARPER_VERSION})`);
326
+ const packageRoot = resolveStagedFlairRoot(stagingDir);
327
+ // Step 3: REUSE deploy() — do not reimplement packaging/spawn.
328
+ log(`Deploying ${plan.project} ${plan.targetVersion} to ${plan.target} ...`);
329
+ const result = await deps.deploy({
330
+ target: opts.target,
331
+ project: plan.project,
332
+ version: plan.targetVersion,
333
+ fabricUser: opts.fabricUser,
334
+ fabricPassword: opts.fabricPassword,
335
+ restart: opts.restart,
336
+ replicated: opts.replicated,
337
+ packageRoot,
338
+ });
339
+ // Step 4: verify the deployed version (best-effort).
340
+ let verifiedVersion = null;
341
+ try {
342
+ verifiedVersion = await deps.fetchDeployedVersion({
343
+ url: opts.target,
344
+ project: plan.project,
345
+ fabricUser: opts.fabricUser,
346
+ fabricPassword: opts.fabricPassword,
347
+ });
348
+ }
349
+ catch {
350
+ verifiedVersion = null;
351
+ }
352
+ if (verifiedVersion) {
353
+ const ok = semverGte(verifiedVersion, plan.targetVersion);
354
+ log(ok
355
+ ? `✓ Fabric now reports Flair ${verifiedVersion}`
356
+ : `⚠ Fabric reports ${verifiedVersion} (expected ${plan.targetVersion}) — may still be restarting`);
357
+ }
358
+ else {
359
+ log(`✓ Deployed ${result.version} (Fabric did not report a version to verify against)`);
360
+ }
361
+ return { plan, deployed: true, verifiedVersion, stagingDir };
362
+ }
363
+ finally {
364
+ // SAFETY: always clean up the temp dir.
365
+ rmSync(stagingDir, { recursive: true, force: true });
366
+ }
367
+ }
@@ -274,7 +274,10 @@ export class A2AAdapter extends Resource {
274
274
  // (GET /a2a passes through; POST /a2a must carry TPS-Ed25519 or admin
275
275
  // Basic). The post() handler below additionally enforces sender ==
276
276
  // params.agentId for message/send so an authenticated caller can only
277
- // act AS themselves, not impersonate another agent.
277
+ // act AS themselves, not impersonate another agent. message/send routes
278
+ // a DIRECTED handoff to params.toAgentId (the recipient) by setting the
279
+ // OrgEvent's targetIds = [toAgentId]; OrgEventCatchup filters on that, so
280
+ // the recipient — not the sender — receives the message.
278
281
  allowRead() { return true; }
279
282
  allowCreate() { return true; }
280
283
  async get() {
@@ -439,22 +442,45 @@ export class A2AAdapter extends Resource {
439
442
  // Without this check, an authenticated agent could forge OrgEvents
440
443
  // attributed to any other agent — defeats the whole signed-envelopes
441
444
  // model that delegationChain enforces for TPS mail.
445
+ // NOTE: agentId is the SENDER. The recipient is params.toAgentId.
442
446
  if (!callerIsAdmin && callerAgent !== agentId) {
443
447
  return rpcError(id, -32001, "Forbidden", {
444
448
  detail: `caller ${callerAgent ?? "(anon)"} cannot send as ${agentId}`,
445
449
  });
446
450
  }
447
- const agent = await databases.flair.Agent.get(agentId).catch(() => null);
448
- if (!agent) {
451
+ const sender = await databases.flair.Agent.get(agentId).catch(() => null);
452
+ if (!sender) {
449
453
  return rpcError(id, -32004, "Agent not found", { agentId });
450
454
  }
455
+ // Directed handoff: params.toAgentId (the recipient) controls
456
+ // OrgEvent.targetIds, which is what OrgEventCatchup filters on
457
+ // (`targets.includes(participantId)`). The recipient must exist.
458
+ // Back-compat: if toAgentId is omitted, fall back to the legacy
459
+ // self-scoped broadcast (targetIds = [sender]) so existing callers
460
+ // that only set agentId keep working. The no-spoof guard above is
461
+ // unchanged — toAgentId only affects WHO receives the message, never
462
+ // who it is sent AS, so a sender can hand off to a peer without being
463
+ // able to impersonate anyone.
464
+ const toAgentId = cleanText(params.toAgentId);
465
+ let targetIds;
466
+ if (toAgentId) {
467
+ const recipient = await databases.flair.Agent.get(toAgentId).catch(() => null);
468
+ if (!recipient) {
469
+ return rpcError(id, -32004, "Recipient agent not found", { toAgentId });
470
+ }
471
+ targetIds = [toAgentId];
472
+ }
473
+ else {
474
+ targetIds = [agentId];
475
+ }
451
476
  const summary = truncate(firstTextPart(message) || "A2A message received", 200);
452
477
  await publishOrgEvent({
478
+ authorId: agentId,
453
479
  kind: "a2a.message",
454
480
  scope: agentId,
455
481
  summary,
456
- detail: JSON.stringify({ message }),
457
- targetIds: [agentId],
482
+ detail: JSON.stringify({ message, toAgentId: toAgentId || null }),
483
+ targetIds,
458
484
  });
459
485
  return rpcResult(id, {
460
486
  type: "message",
@@ -23,8 +23,18 @@ export class OrgEvent extends databases.flair.OrgEvent {
23
23
  const auth = await this._auth();
24
24
  if (auth.kind === "anonymous")
25
25
  return UNAUTH();
26
- if (auth.kind === "agent" && !auth.isAdmin && content.authorId !== auth.agentId) {
27
- return FORBIDDEN("forbidden: authorId must match authenticated agent");
26
+ // No-forge attribution: a non-admin agent's events are ALWAYS attributed to
27
+ // its authenticated identity (from the Ed25519 signature), never the body —
28
+ // an agent can only publish AS itself. We overwrite `authorId` rather than
29
+ // 403'ing a mismatch so a CLI client never has to echo its own id into the
30
+ // body (mirrors A2A message/send's "sender must match params.agentId" guard
31
+ // and Presence's "agentId from signature, NOT from body"). Admin agents may
32
+ // publish on behalf of another agent (body authorId honored, else their own).
33
+ if (auth.kind === "agent" && !auth.isAdmin) {
34
+ content.authorId = auth.agentId;
35
+ }
36
+ else if (auth.kind === "agent" && auth.isAdmin) {
37
+ content.authorId ||= auth.agentId;
28
38
  }
29
39
  if (!content.id)
30
40
  content.id = `${content.authorId}-${new Date().toISOString()}`;
@@ -18,6 +18,7 @@
18
18
  */
19
19
  import { databases } from "@harperfast/harper";
20
20
  import { resolveAgentAuth } from "./agent-auth.js";
21
+ import { b64ToArrayBuffer } from "./b64.js";
21
22
  // ─── Constants ────────────────────────────────────────────────────────────────
22
23
  const WINDOW_MS = 30_000;
23
24
  const CURRENT_TASK_MAX_LENGTH = 200;
@@ -40,15 +41,8 @@ function pruneNonces() {
40
41
  }
41
42
  }
42
43
  // ─── Crypto helpers ───────────────────────────────────────────────────────────
43
- function b64ToArrayBuffer(b64) {
44
- const std = b64.replace(/-/g, "+").replace(/_/g, "/");
45
- const bin = atob(std);
46
- const buf = new ArrayBuffer(bin.length);
47
- const view = new Uint8Array(buf);
48
- for (let i = 0; i < bin.length; i++)
49
- view[i] = bin.charCodeAt(i);
50
- return buf;
51
- }
44
+ // b64ToArrayBuffer lives in ./b64.ts (shared with auth-middleware.ts + agent-auth.ts
45
+ // so the base64/base64url decoder can't drift across the three auth call sites).
52
46
  const keyCache = new Map();
53
47
  async function importEd25519Key(publicKeyStr) {
54
48
  if (keyCache.has(publicKeyStr))
@@ -8,6 +8,11 @@ import { wrapUntrusted } from "./content-safety.js";
8
8
  // relevance floor) lives in ./scoring.ts — a Harper-free module so it can be
9
9
  // unit-tested directly (see test/unit/temporal-scoring.test.ts).
10
10
  import { compositeScore } from "./scoring.js";
11
+ // BM25 + union-RRF hybrid retrieval (ops-i39b / FLAIR-BM25-HYBRID-RETRIEVAL).
12
+ // Harper-free modules so the BM25 scoring, the candidate-union RRF, and the
13
+ // SECURITY conditions-filter are unit-tested against the shipped code.
14
+ import { buildBM25, fuseRrfNormalized, hybridEnabled, SEM_LIMIT } from "./bm25.js";
15
+ import { isAllowedBm25Candidate } from "./bm25-filter.js";
11
16
  // Convert HNSW cosine distance (1 - similarity) to similarity score
12
17
  function distanceToSimilarity(distance) {
13
18
  return 1 - distance;
@@ -15,6 +20,9 @@ function distanceToSimilarity(distance) {
15
20
  // Candidate multiplier: fetch more candidates than needed from the HNSW index
16
21
  // so composite re-ranking has enough headroom to reorder results.
17
22
  const CANDIDATE_MULTIPLIER = 5;
23
+ // The BM25 + union-RRF hybrid path is feature-flagged via hybridEnabled()
24
+ // (imported from ./bm25 — Harper-free so it's unit-testable). Flag OFF (default)
25
+ // → byte-identical to the pre-hybrid HNSW + +0.05 keyword-bump behavior.
18
26
  export class SemanticSearch extends Resource {
19
27
  // Self-authorize via the Ed25519 agent verify instead of relying on the auth
20
28
  // gate's admin super_user elevation (removed in the auth reshape). Any
@@ -161,8 +169,122 @@ export class SemanticSearch extends Resource {
161
169
  }
162
170
  }
163
171
  const results = [];
164
- // ─── HNSW vector search path ───────────────────────────────────────────
165
- if (qEmb) {
172
+ const hybrid = hybridEnabled();
173
+ if (hybrid) {
174
+ // ─── BM25 + union-RRF hybrid path (ops-i39b) ─────────────────────────
175
+ // 1. Semantic candidates via HNSW (unchanged fetch). 2. BM25 lexical pass
176
+ // over the SCOPED corpus. 3. SECURITY: the BM25 candidate set is filtered
177
+ // by the SAME conditions[] + temporal filters BEFORE fusion (the corpus
178
+ // is fetched with those conditions, AND re-checked in-process as
179
+ // defense-in-depth) so no other agent's memory is ever scored or fused.
180
+ // 4. Candidate-union RRF → normalize → feed as rawScore to compositeScore.
181
+ const ctx = this.getContext?.();
182
+ // ── (a) Semantic candidate records (best-first) ──────────────────────
183
+ // Same HNSW query as the legacy path; we keep the raw records so the BM25
184
+ // pass + fusion can re-derive rawScore. No embedding → empty semantic list
185
+ // (RRF degrades naturally to BM25-only).
186
+ const semRecords = [];
187
+ const semIds = [];
188
+ if (qEmb) {
189
+ const candidateLimit = limit * CANDIDATE_MULTIPLIER;
190
+ const semQuery = {
191
+ sort: { attribute: "embedding", target: qEmb, distance: "cosine" },
192
+ select: ["id", "agentId", "content", "contentHash", "visibility", "tags", "durability",
193
+ "source", "createdAt", "updatedAt", "expiresAt", "retrievalCount", "lastRetrieved",
194
+ "promotionStatus", "promotedAt", "promotedBy", "archived", "archivedAt", "archivedBy",
195
+ "parentId", "derivedFrom", "sessionId", "lastReflected", "supersedes", "subject",
196
+ "validFrom", "validTo", "_safetyFlags", "$distance"],
197
+ limit: candidateLimit,
198
+ };
199
+ if (conditions.length > 0)
200
+ semQuery.conditions = conditions;
201
+ const semResults = withDetachedTxn(ctx, () => databases.flair.Memory.search(semQuery));
202
+ for await (const record of semResults) {
203
+ // Same per-record temporal gate as the legacy HNSW loop.
204
+ if (record.expiresAt && Date.parse(record.expiresAt) < Date.now())
205
+ continue;
206
+ if (sinceDate && record.createdAt && new Date(record.createdAt) < sinceDate)
207
+ continue;
208
+ if (asOf && record.validFrom && record.validFrom > asOf)
209
+ continue;
210
+ if (asOf && record.validTo && record.validTo <= asOf)
211
+ continue;
212
+ semRecords.push(record);
213
+ semIds.push(record.id);
214
+ }
215
+ }
216
+ // ── (b) BM25 candidate records over the SCOPED corpus ────────────────
217
+ // SECURITY: fetch the corpus WITH the same conditions[] so Harper applies
218
+ // the agent boundary, then re-apply the identical predicate + temporal
219
+ // filters in-process (isAllowedBm25Candidate) BEFORE building/scoring the
220
+ // index. The BM25 index therefore only ever contains the caller's allowed
221
+ // memories — no other agent's content/term-frequency enters scoring or
222
+ // fusion. This is the conditions-filter-before-fusion gate.
223
+ // Explicit select (same fields as the HNSW path, no embedding / $distance)
224
+ // so the large embedding vector is never fetched into the BM25 corpus and
225
+ // never spread into a result payload.
226
+ const corpusSelect = ["id", "agentId", "content", "contentHash", "visibility", "tags", "durability",
227
+ "source", "createdAt", "updatedAt", "expiresAt", "retrievalCount", "lastRetrieved",
228
+ "promotionStatus", "promotedAt", "promotedBy", "archived", "archivedAt", "archivedBy",
229
+ "parentId", "derivedFrom", "sessionId", "lastReflected", "supersedes", "subject",
230
+ "validFrom", "validTo", "_safetyFlags"];
231
+ const corpusQuery = conditions.length > 0
232
+ ? { conditions, select: corpusSelect }
233
+ : { select: corpusSelect };
234
+ const corpusResults = withDetachedTxn(ctx, () => databases.flair.Memory.search(corpusQuery));
235
+ const allowedById = new Map();
236
+ const bm25Docs = [];
237
+ for await (const record of corpusResults) {
238
+ // Defense-in-depth: re-check the SAME conditions[] + temporal filters
239
+ // in-process. Even if a Harper query change ever let an out-of-scope
240
+ // record through, it is dropped here BEFORE it can be BM25-scored/fused.
241
+ if (!isAllowedBm25Candidate(record, conditions, { sinceDate, asOf }))
242
+ continue;
243
+ allowedById.set(record.id, record);
244
+ bm25Docs.push({ id: record.id, content: record.content });
245
+ }
246
+ // Carry semantic candidates that survived their temporal gate into the
247
+ // allowed map too (so a fused id always resolves to a record). Semantic
248
+ // records were fetched with the SAME conditions[], so they're in-scope.
249
+ for (const r of semRecords) {
250
+ if (!allowedById.has(r.id)) {
251
+ const { $distance, ...rest } = r;
252
+ allowedById.set(r.id, rest);
253
+ }
254
+ }
255
+ // ── (c) BM25 lexical ranking → top SEM_LIMIT (only when q present) ────
256
+ let bm25Ids = [];
257
+ if (q) {
258
+ const bm25 = buildBM25(bm25Docs);
259
+ const ranked = bm25.rank(String(q));
260
+ // Drop zero-score docs (no query-term overlap → contribute nothing) and
261
+ // cap at SEM_LIMIT — the production BM25 candidate window.
262
+ bm25Ids = ranked.filter(r => r.score > 0).slice(0, SEM_LIMIT).map(r => r.id);
263
+ }
264
+ // ── (d) Candidate-union RRF → normalized [0,1] rawScore ──────────────
265
+ // Union dedupes semantic ∪ bm25 ids; absent-from-a-list = 0 contribution.
266
+ const fused = fuseRrfNormalized(semIds, bm25Ids);
267
+ for (const [id, rrfRaw] of fused) {
268
+ const record = allowedById.get(id);
269
+ if (!record)
270
+ continue; // should not happen — union ⊆ allowed
271
+ const rawScore = rrfRaw; // already normalized to [0,1]
272
+ let finalScore = scoring === "raw" ? rawScore : compositeScore(rawScore, record);
273
+ if (temporalBoost > 1.0)
274
+ finalScore *= temporalBoost;
275
+ const isFlagged = record._safetyFlags && Array.isArray(record._safetyFlags) && record._safetyFlags.length > 0;
276
+ const source = record.agentId !== agentId ? record.agentId : undefined;
277
+ results.push({
278
+ ...record,
279
+ content: isFlagged ? wrapUntrusted(record.content, source) : record.content,
280
+ _score: Math.round(finalScore * 1000) / 1000,
281
+ _rawScore: scoring !== "raw" ? Math.round(rawScore * 1000) / 1000 : undefined,
282
+ _source: source,
283
+ });
284
+ }
285
+ }
286
+ else if (qEmb) {
287
+ // ─── HNSW vector search path ───────────────────────────────────────────
166
288
  const candidateLimit = limit * CANDIDATE_MULTIPLIER;
167
289
  const query = {
168
290
  sort: { attribute: "embedding", target: qEmb, distance: "cosine" },
@@ -50,8 +50,18 @@ export class WorkspaceState extends databases.flair.WorkspaceState {
50
50
  // there was no authenticated agent, so anonymous could write any record).
51
51
  if (auth.kind === "anonymous")
52
52
  return UNAUTH();
53
- if (auth.kind === "agent" && !auth.isAdmin && content.agentId !== auth.agentId) {
54
- return FORBIDDEN("forbidden: cannot write workspace state for another agent");
53
+ // No-forge: a non-admin agent's workspace record is ALWAYS attributed to the
54
+ // authenticated identity (from the Ed25519 signature), never the body. We do
55
+ // NOT trust `content.agentId` — overwriting it (rather than 403'ing a
56
+ // mismatch) mirrors Presence.post(): "agentId from signature, NOT from body".
57
+ // An admin may write on behalf of another agent (content.agentId honored if
58
+ // present, else defaults to the admin's own id). Internal in-process callers
59
+ // keep whatever agentId they pass.
60
+ if (auth.kind === "agent" && !auth.isAdmin) {
61
+ content.agentId = auth.agentId;
62
+ }
63
+ else if (auth.kind === "agent" && auth.isAdmin) {
64
+ content.agentId ||= auth.agentId;
55
65
  }
56
66
  content.createdAt = new Date().toISOString();
57
67
  content.timestamp ||= content.createdAt;