@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.
- package/dist/cli-shim.cjs +66 -0
- package/dist/cli.js +290 -11
- package/dist/fabric-upgrade.js +367 -0
- package/dist/resources/A2AAdapter.js +31 -5
- package/dist/resources/OrgEvent.js +12 -2
- package/dist/resources/Presence.js +3 -9
- package/dist/resources/SemanticSearch.js +124 -2
- package/dist/resources/WorkspaceState.js +12 -2
- package/dist/resources/agent-auth.js +3 -10
- package/dist/resources/auth-middleware.js +3 -10
- package/dist/resources/b64.js +40 -0
- package/dist/resources/bm25-filter.js +84 -0
- package/dist/resources/bm25.js +130 -0
- package/package.json +3 -3
|
@@ -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
|
|
448
|
-
if (!
|
|
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
|
|
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
|
-
|
|
27
|
-
|
|
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
|
-
|
|
44
|
-
|
|
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
|
-
|
|
165
|
-
if (
|
|
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
|
-
|
|
54
|
-
|
|
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;
|