@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 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), currently in beta. We run it in production daily and track upstream closely. Pin your Harper version.
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
- // Wait briefly for shutdown
10325
- await new Promise((r) => setTimeout(r, 2000));
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
@@ -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.39.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.1.22",
67
+ "harper": "5.2.0",
68
68
  "harper-fabric-embeddings": "^0.5.0",
69
69
  "jose": "6.2.2",
70
- "js-yaml": "4.3.0",
71
- "tar": "7.5.20",
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",