@tpsdev-ai/flair 0.39.0 → 0.40.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
 
@@ -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.
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.40.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",
@@ -67,8 +67,8 @@
67
67
  "harper": "5.1.22",
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",