@tpsdev-ai/flair 0.39.0 → 0.41.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 +1 -1
- package/dist/cli.js +64 -2
- package/dist/lib/mcp-enable.js +91 -1
- package/dist/resources/SemanticSearch.js +29 -2
- package/docs/upgrade.md +8 -0
- package/package.json +11 -5
package/README.md
CHANGED
|
@@ -404,7 +404,7 @@ Shipped: Ed25519 identity and auth · memory CRUD with durability enforcement an
|
|
|
404
404
|
|
|
405
405
|
Next: git-backed memory sync · opt-in encryption at rest (AES-256-GCM per memory).
|
|
406
406
|
|
|
407
|
-
> **Note:** Flair runs on [Harper v5](https://harper.fast)
|
|
407
|
+
> **Note:** Flair runs on [Harper v5](https://harper.fast). We run it in production daily and track upstream closely. Pin your Harper version.
|
|
408
408
|
|
|
409
409
|
## License
|
|
410
410
|
|
package/dist/cli.js
CHANGED
|
@@ -774,6 +774,14 @@ export function buildDirectSpawnEnv(opts) {
|
|
|
774
774
|
HTTP_PORT: String(opts.httpPort),
|
|
775
775
|
OPERATIONSAPI_NETWORK_PORT: opsNetworkPortValue(opts.opsBindHost, opts.opsPort),
|
|
776
776
|
LOCAL_STUDIO: "false",
|
|
777
|
+
// flair#905 / lrf5: Harper's forceDowngradePrompt reads CONFIRM_DOWNGRADE
|
|
778
|
+
// from the environment (via the `prompt` npm package's assignCmdEnvVariables
|
|
779
|
+
// override). Under launchd/systemd stdin is not a TTY, so the prompt gets
|
|
780
|
+
// EOF and Harper exits 0 without starting — leaving the instance DOWN with
|
|
781
|
+
// no error. Setting this to "yes" makes the prompt non-interactive: Harper
|
|
782
|
+
// proceeds without blocking, which is the correct default for a managed
|
|
783
|
+
// restart where the operator already chose to proceed.
|
|
784
|
+
CONFIRM_DOWNGRADE: "yes",
|
|
777
785
|
};
|
|
778
786
|
if (opts.adminPass)
|
|
779
787
|
env.HDB_ADMIN_PASSWORD = opts.adminPass;
|
|
@@ -9688,6 +9696,50 @@ program
|
|
|
9688
9696
|
console.error(` Recover by hand: npm install -g @tpsdev-ai/flair@${toVersion} && flair start`);
|
|
9689
9697
|
process.exit(1);
|
|
9690
9698
|
}
|
|
9699
|
+
// flair#1053: when the engine (Harper) version changed, the pre-upgrade
|
|
9700
|
+
// snapshot is the ONLY way back — the old Harper cannot read data written
|
|
9701
|
+
// by the new one (e.g. 5.2 LZ4-compressed storage is unreadable by 5.1).
|
|
9702
|
+
// Restore it before restarting, or refuse loudly when none exists.
|
|
9703
|
+
if (engineVersionChanging) {
|
|
9704
|
+
if (snapshotPath) {
|
|
9705
|
+
console.log(`\nEngine version changed — restoring pre-upgrade snapshot before rollback...`);
|
|
9706
|
+
console.log(` snapshot: ${snapshotPath}`);
|
|
9707
|
+
console.log(` target: ${upgradeDataDir}`);
|
|
9708
|
+
try {
|
|
9709
|
+
await validateSnapshotArchive({ file: snapshotPath, targetDir: upgradeDataDir });
|
|
9710
|
+
rmSync(upgradeDataDir, { recursive: true, force: true });
|
|
9711
|
+
mkdirSync(upgradeDataDir, { recursive: true, mode: 0o700 });
|
|
9712
|
+
await extractSnapshotSafely({ file: snapshotPath, targetDir: upgradeDataDir });
|
|
9713
|
+
console.log(` ✅ snapshot restored`);
|
|
9714
|
+
}
|
|
9715
|
+
catch (err) {
|
|
9716
|
+
console.error(`❌ snapshot restore failed: ${err.message}`);
|
|
9717
|
+
console.error(` @tpsdev-ai/flair@${toVersion} is installed but the data directory could not be restored.`);
|
|
9718
|
+
console.error(` The snapshot itself is intact at ${snapshotPath} — restore it by hand:`);
|
|
9719
|
+
console.error(` flair snapshot restore "${snapshotPath}"`);
|
|
9720
|
+
console.error(` Then: flair start`);
|
|
9721
|
+
process.exit(1);
|
|
9722
|
+
}
|
|
9723
|
+
}
|
|
9724
|
+
else {
|
|
9725
|
+
// No snapshot exists — the old Harper WILL NOT BOOT against the new
|
|
9726
|
+
// data. Refuse loudly rather than attempting a guaranteed failure.
|
|
9727
|
+
console.error(`\n❌ Cannot roll back: the Harper engine version changed (${currentEngineVersion ?? "?"} → ${targetEngineVersion ?? "?"}) and no pre-upgrade snapshot exists.`);
|
|
9728
|
+
console.error(` The old Harper cannot read data written by the new engine — restarting without a snapshot restore would fail.`);
|
|
9729
|
+
console.error(` @tpsdev-ai/flair@${toVersion} is installed but NOT running.`);
|
|
9730
|
+
if (snapshotDecision === "nudge") {
|
|
9731
|
+
console.error(` A snapshot was skipped because --no-engine-snapshot was passed.`);
|
|
9732
|
+
console.error(` Recovery options:`);
|
|
9733
|
+
console.error(` 1. Re-upgrade to the version that wrote this data: npm install -g @tpsdev-ai/flair@${expectedFlairVersion ?? "latest"} && flair start`);
|
|
9734
|
+
console.error(` 2. Restore from a ` + "`flair backup`" + ` JSON export on a fresh data directory.`);
|
|
9735
|
+
}
|
|
9736
|
+
else {
|
|
9737
|
+
console.error(` No snapshot was taken (data directory may not have existed, or the snapshot step was skipped).`);
|
|
9738
|
+
console.error(` Recovery: re-upgrade to the version that wrote this data, or restore from a ` + "`flair backup`" + ` JSON export.`);
|
|
9739
|
+
}
|
|
9740
|
+
process.exit(1);
|
|
9741
|
+
}
|
|
9742
|
+
}
|
|
9691
9743
|
// Same post-swap rule as the upgrade restart above: the rolled-back
|
|
9692
9744
|
// version's own CLI is the thing that knows how to start it.
|
|
9693
9745
|
const rolledBackCli = resolveInstalledFlairCli(flairPackageDir(), toVersion);
|
|
@@ -10321,8 +10373,18 @@ async function stopFlairProcess(port, dataDir) {
|
|
|
10321
10373
|
}
|
|
10322
10374
|
catch { }
|
|
10323
10375
|
}
|
|
10324
|
-
//
|
|
10325
|
-
|
|
10376
|
+
// flair#905 / lrf5: wait for every signalled process to actually exit.
|
|
10377
|
+
// A blind 2-second sleep is not a guarantee — Harper may be flushing
|
|
10378
|
+
// RocksDB WAL/MANIFEST, and the next start will fail with a locked data
|
|
10379
|
+
// directory if the old process hasn't released it yet. The launchd path
|
|
10380
|
+
// above already does this via waitForProcessExit; the port-based path
|
|
10381
|
+
// must match that guarantee.
|
|
10382
|
+
for (const target of targets) {
|
|
10383
|
+
try {
|
|
10384
|
+
await waitForProcessExit(target, STARTUP_TIMEOUT_MS);
|
|
10385
|
+
}
|
|
10386
|
+
catch { /* best-effort — the next start will surface the real problem */ }
|
|
10387
|
+
}
|
|
10326
10388
|
}
|
|
10327
10389
|
/**
|
|
10328
10390
|
* Start the local Flair (Harper) process — launchd `start` on darwin when a
|
package/dist/lib/mcp-enable.js
CHANGED
|
@@ -748,6 +748,83 @@ export function buildClaudePasteBlock(resource) {
|
|
|
748
748
|
" (no client ID to enter — Claude presents its own Client ID Metadata Document URL automatically)",
|
|
749
749
|
].join("\n");
|
|
750
750
|
}
|
|
751
|
+
/** How long to wait for the ops API / PID change after a restart. */
|
|
752
|
+
const RESTART_WAIT_TIMEOUT_MS = 30_000;
|
|
753
|
+
/** Poll interval while waiting for the ops API after restart. */
|
|
754
|
+
const RESTART_WAIT_POLL_MS = 1000;
|
|
755
|
+
/**
|
|
756
|
+
* Wait for the ops API to respond after a restart, then poll until the PID
|
|
757
|
+
* changes (proving a genuine restart, not the old process still answering).
|
|
758
|
+
*
|
|
759
|
+
* After a real restart, the old process can briefly still respond to the first
|
|
760
|
+
* ops request. We capture the PID on every poll until it changes — if the
|
|
761
|
+
* window expires with the PID still the same we report thread-bounce failure
|
|
762
|
+
* so the operator knows the restart was a no-op rather than a timing quirk.
|
|
763
|
+
*
|
|
764
|
+
* `timeoutMs` and `pollMs` are injectable via `deps` so tests can run fast.
|
|
765
|
+
*/
|
|
766
|
+
async function waitForOpsApi(opsUrl, authHeader, prePid, deps = {}) {
|
|
767
|
+
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
768
|
+
const timeoutMs = deps.timeoutMs ?? RESTART_WAIT_TIMEOUT_MS;
|
|
769
|
+
const pollMs = deps.pollMs ?? RESTART_WAIT_POLL_MS;
|
|
770
|
+
const deadline = Date.now() + timeoutMs;
|
|
771
|
+
let attempt = 0;
|
|
772
|
+
while (Date.now() < deadline) {
|
|
773
|
+
attempt++;
|
|
774
|
+
try {
|
|
775
|
+
const res = await fetchImpl(opsUrl, {
|
|
776
|
+
method: "POST",
|
|
777
|
+
headers: { "Content-Type": "application/json", Authorization: authHeader },
|
|
778
|
+
body: JSON.stringify({ operation: "system_information", attributes: ["harperdb_processes"] }),
|
|
779
|
+
signal: AbortSignal.timeout(3000),
|
|
780
|
+
});
|
|
781
|
+
if (!res.ok) {
|
|
782
|
+
await new Promise((r) => setTimeout(r, pollMs));
|
|
783
|
+
continue;
|
|
784
|
+
}
|
|
785
|
+
// Ops API answered — extract the PID from this response
|
|
786
|
+
const data = await res.json();
|
|
787
|
+
const pid = data?.harperdb_processes?.core?.[0]?.pid;
|
|
788
|
+
if (!pid || typeof pid !== "number") {
|
|
789
|
+
await new Promise((r) => setTimeout(r, pollMs));
|
|
790
|
+
continue;
|
|
791
|
+
}
|
|
792
|
+
// If PID changed from pre-restart value, the restart is confirmed
|
|
793
|
+
if (pid !== prePid)
|
|
794
|
+
return { pid };
|
|
795
|
+
// PID still the same (old process may still be answering), keep polling
|
|
796
|
+
}
|
|
797
|
+
catch { /* not ready yet — connection refused, timeout, etc. */ }
|
|
798
|
+
await new Promise((r) => setTimeout(r, pollMs));
|
|
799
|
+
}
|
|
800
|
+
throw new Error(`ops API at ${opsUrl} did not confirm a new process within ${timeoutMs}ms (${attempt} attempts) ` +
|
|
801
|
+
`— the restart may have failed or the process bounced on the same thread (pid ${prePid} unchanged). ` +
|
|
802
|
+
`Restart the instance manually, then re-run: flair mcp enable`);
|
|
803
|
+
}
|
|
804
|
+
/**
|
|
805
|
+
* Capture the Harper core process PID from the ops API via `system_information`.
|
|
806
|
+
* This PID is the boot discriminator: it changes on every real restart.
|
|
807
|
+
*/
|
|
808
|
+
export async function captureBootDiscriminator(opsPortOrUrl, adminUser, adminPass, deps = {}) {
|
|
809
|
+
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
810
|
+
const opsUrl = resolveOpsUrl(opsPortOrUrl);
|
|
811
|
+
const authHeader = basicAuthHeader(adminUser, adminPass);
|
|
812
|
+
const res = await fetchImpl(opsUrl, {
|
|
813
|
+
method: "POST",
|
|
814
|
+
headers: { "Content-Type": "application/json", Authorization: authHeader },
|
|
815
|
+
body: JSON.stringify({ operation: "system_information", attributes: ["harperdb_processes"] }),
|
|
816
|
+
});
|
|
817
|
+
if (!res.ok) {
|
|
818
|
+
const text = await res.text().catch(() => "");
|
|
819
|
+
throw new Error(`system_information failed (HTTP ${res.status}): ${text}`);
|
|
820
|
+
}
|
|
821
|
+
const data = await res.json();
|
|
822
|
+
const pid = data?.harperdb_processes?.core?.[0]?.pid;
|
|
823
|
+
if (!pid || typeof pid !== "number") {
|
|
824
|
+
throw new Error("system_information returned no harperdb_processes.core entry with a PID");
|
|
825
|
+
}
|
|
826
|
+
return { pid };
|
|
827
|
+
}
|
|
751
828
|
/**
|
|
752
829
|
* Full `flair mcp enable` orchestration. No `process.exit`, no console
|
|
753
830
|
* output — directly unit-testable with a mocked fetch and temp dirs, same
|
|
@@ -898,10 +975,23 @@ export async function enableMcp(params, deps = {}) {
|
|
|
898
975
|
push(false, `not applied: pass --confirm-secrets-applied once the staged secrets are live on ${params.instance}, then re-run \`flair mcp enable\` (earlier steps are idempotent and will reuse what's already provisioned).`);
|
|
899
976
|
return { ok: false, dryRun, steps, failedStep: "apply-config-and-restart", secretsMechanism: secretsResult.mechanism, secretsPath: secretsResult.path };
|
|
900
977
|
}
|
|
901
|
-
// ── Apply config + restart ────────────────────────────────────────────────
|
|
902
978
|
currentStep = "apply-config-and-restart";
|
|
979
|
+
// ── Capture boot discriminator BEFORE restart (flair#1120) ─────────────
|
|
980
|
+
const preDiscriminator = await captureBootDiscriminator(params.instance, params.adminUser, params.adminPass, { fetchImpl: deps.fetchImpl });
|
|
903
981
|
await applyRemoteConfigAndRestart({ opsPortOrUrl: params.instance, adminUser: params.adminUser, adminPass: params.adminPass, configBlock }, { fetchImpl: deps.fetchImpl });
|
|
904
982
|
push(true, `set_configuration + restart succeeded against ${params.instance}`);
|
|
983
|
+
// ── Verify the process actually restarted (flair#1120) ──────────────────
|
|
984
|
+
// Poll the ops API until the PID changes — the old process can briefly
|
|
985
|
+
// still answer after a real restart, so a single post-capture is unreliable.
|
|
986
|
+
// waitForOpsApi guarantees PID change (or throws on timeout), so the restart
|
|
987
|
+
// is confirmed when this call returns.
|
|
988
|
+
currentStep = "verify-restart";
|
|
989
|
+
const postDiscriminator = await waitForOpsApi(resolveOpsUrl(params.instance), basicAuthHeader(params.adminUser, params.adminPass), preDiscriminator.pid, {
|
|
990
|
+
fetchImpl: deps.fetchImpl,
|
|
991
|
+
timeoutMs: deps.waitForOpsApiTimeoutMs,
|
|
992
|
+
pollMs: deps.waitForOpsApiPollMs,
|
|
993
|
+
});
|
|
994
|
+
push(true, `process restarted: pid changed ${preDiscriminator.pid} -> ${postDiscriminator.pid}`);
|
|
905
995
|
// ── Self-verify from the operator's machine, public origin, CIMD-inclusive
|
|
906
996
|
currentStep = "self-verify";
|
|
907
997
|
const verify = await selfVerifyMcpMetadata(issuer, { fetchImpl: deps.fetchImpl });
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Resource, databases } from "harper";
|
|
2
2
|
import { resolveAgentAuth, allowVerified } from "./agent-auth.js";
|
|
3
3
|
import { getEmbedding, getMode } from "./embeddings-provider.js";
|
|
4
|
-
import { patchRecord } from "./table-helpers.js";
|
|
4
|
+
import { patchRecord, withDetachedTxn } from "./table-helpers.js";
|
|
5
5
|
import { checkRateLimit, rateLimitResponse } from "./rate-limiter.js";
|
|
6
6
|
import { resolveReadScope } from "./memory-read-scope.js";
|
|
7
7
|
// The BM25 + union-RRF hybrid path is feature-flagged via hybridEnabled()
|
|
@@ -61,7 +61,7 @@ export class SemanticSearch extends Resource {
|
|
|
61
61
|
// recall-harness (test/bench/recall-harness/run.ts) and `recall-eval.mjs`
|
|
62
62
|
// before reconsidering this default if the compositeScore formula or
|
|
63
63
|
// corpus changes.
|
|
64
|
-
const { agentId: bodyAgentId, q, queryEmbedding, tag, subject, subjects, limit = 10, includeSuperseded = false, scoring = "raw", minScore = 0, since, asOf, includeTrust = false, abstain = false } = data || {};
|
|
64
|
+
const { agentId: bodyAgentId, q, queryEmbedding, tag, subject, subjects, limit = 10, includeSuperseded = false, scoring = "raw", minScore = 0, since, asOf, includeTrust = false, abstain = false, explain = false } = data || {};
|
|
65
65
|
// Authenticated identity lives on the Harper Resource context (getContext().request).
|
|
66
66
|
// `this.request` is NOT populated on Harper v5 Resources — prior reads here
|
|
67
67
|
// silently returned undefined and the defense-in-depth scope check below
|
|
@@ -179,6 +179,33 @@ export class SemanticSearch extends Resource {
|
|
|
179
179
|
});
|
|
180
180
|
}
|
|
181
181
|
}
|
|
182
|
+
// ─── Explain mode: return Harper's ENGINE-LEVEL query plan ────────────
|
|
183
|
+
// When explain=true, construct the same search query that the HNSW leg
|
|
184
|
+
// would use and pass explain:true through to Harper's Table.search().
|
|
185
|
+
// Harper's cost-based planner re-sorts conditions by estimated count at
|
|
186
|
+
// execution (the scope OR-group estimates Infinity and can never drive;
|
|
187
|
+
// a selective tags-equals wins the seek). The returned plan shows the
|
|
188
|
+
// ENGINE's chosen order — the proof the spec requires.
|
|
189
|
+
//
|
|
190
|
+
// No search is executed; no side effects (rate-limit, hit-tracking).
|
|
191
|
+
if (explain) {
|
|
192
|
+
const ctx = this.getContext?.();
|
|
193
|
+
const explainQuery = {
|
|
194
|
+
sort: qEmb ? { attribute: "embedding", target: qEmb, distance: "cosine" } : undefined,
|
|
195
|
+
select: DEFAULT_SELECT,
|
|
196
|
+
limit,
|
|
197
|
+
explain: true,
|
|
198
|
+
};
|
|
199
|
+
if (conditions.length > 0)
|
|
200
|
+
explainQuery.conditions = conditions;
|
|
201
|
+
const plan = withDetachedTxn(ctx, () => databases.flair.Memory.search(explainQuery));
|
|
202
|
+
return {
|
|
203
|
+
explain: true,
|
|
204
|
+
plan,
|
|
205
|
+
tag,
|
|
206
|
+
scoring,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
182
209
|
const hybrid = hybridEnabled();
|
|
183
210
|
// The overfetch policy (how many raw candidates to pull from the
|
|
184
211
|
// HNSW/BM25 legs relative to what the caller ultimately wants) is THIS
|
package/docs/upgrade.md
CHANGED
|
@@ -350,6 +350,14 @@ if you want to see it scripted end-to-end.
|
|
|
350
350
|
between Flair versions.
|
|
351
351
|
- **Config:** `~/.flair/config.yaml` format is additive — new options fall back to
|
|
352
352
|
defaults when absent, old options aren't removed out from under you.
|
|
353
|
+
- **Harper 5.2:** Starting with the release that pins Harper 5.2.0 (see CHANGELOG). This upgrade is **forward-only** — Harper 5.2 writes LZ4-compressed storage
|
|
354
|
+
that Harper 5.1.x cannot read. If you need to roll back, restore the pre-upgrade snapshot: `flair snapshot restore <path>` (use `flair snapshot list` to find available snapshots). Note this is the physical engine snapshot restore, distinct from `flair restore` which replays a logical JSON export and cannot recover a 5.1-incompatible data directory. Installing an older Harper version will not boot against a
|
|
355
|
+
5.2-written data directory. Flair automatically takes a snapshot when the Harper engine
|
|
356
|
+
minor version changes, so a valid snapshot will exist if you
|
|
357
|
+
upgraded via `flair upgrade`. Real snapshots for production datasets run hundreds of
|
|
358
|
+
megabytes — plan disk capacity accordingly. Cleanup of old engine snapshots is manual;
|
|
359
|
+
`flair snapshot list` shows available snapshots, and you can delete entries you no longer
|
|
360
|
+
need (e.g. `rm ~/.flair/upgrade-snapshots/flair-data-<old>.tar.gz`).
|
|
353
361
|
|
|
354
362
|
## Rollback
|
|
355
363
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tpsdev-ai/flair",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.41.0",
|
|
4
4
|
"packageManager": "bun@1.3.10",
|
|
5
5
|
"description": "Identity, memory, and soul for AI agents. Cryptographic identity (Ed25519), semantic memory with local embeddings, and persistent personality — all in a single process.",
|
|
6
6
|
"type": "module",
|
|
@@ -64,11 +64,11 @@
|
|
|
64
64
|
"@harperfast/oauth": "2.4.0",
|
|
65
65
|
"@types/js-yaml": "4.0.9",
|
|
66
66
|
"commander": "14.0.3",
|
|
67
|
-
"harper": "5.
|
|
67
|
+
"harper": "5.2.0",
|
|
68
68
|
"harper-fabric-embeddings": "^0.5.0",
|
|
69
69
|
"jose": "6.2.2",
|
|
70
|
-
"js-yaml": "4.3.
|
|
71
|
-
"tar": "7.5.
|
|
70
|
+
"js-yaml": "^4.3.1",
|
|
71
|
+
"tar": "^7.5.22",
|
|
72
72
|
"tweetnacl": "1.0.3"
|
|
73
73
|
},
|
|
74
74
|
"overrides": {
|
|
@@ -76,7 +76,13 @@
|
|
|
76
76
|
"brace-expansion": "^5.0.9",
|
|
77
77
|
"undici": "^8.9.0",
|
|
78
78
|
"fast-uri": "^4.1.2",
|
|
79
|
-
"hono": "^4.12.34"
|
|
79
|
+
"hono": "^4.12.34",
|
|
80
|
+
"js-yaml": "^4.3.1",
|
|
81
|
+
"adm-zip": "^0.6.0",
|
|
82
|
+
"@opentelemetry/core": "^2.8.0",
|
|
83
|
+
"uuid": "^11.1.1",
|
|
84
|
+
"tar": "^7.5.22",
|
|
85
|
+
"@tootallnate/once": "^2.0.1"
|
|
80
86
|
},
|
|
81
87
|
"devDependencies": {
|
|
82
88
|
"@playwright/test": "1.59.1",
|