@tpsdev-ai/flair 0.35.0 → 0.37.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.
@@ -211,6 +211,55 @@ export function makeByIdReadGate(readScope) {
211
211
  return record;
212
212
  };
213
213
  }
214
+ // ─── (c) Scoped search — makeScopedSearch ────────────────────────────────
215
+ /**
216
+ * Produces a scoped search() override that nests the caller's conditions
217
+ * inside the agent-scope condition as the OUTERMOST `and` block, so a
218
+ * caller-supplied `operator: "or"` cannot boolean-inject past the owner
219
+ * scope.
220
+ *
221
+ * This is the correct composition that MemoryCandidate.search() already
222
+ * applies (the only resource that got it right). Memory.search() and
223
+ * WorkspaceState.search() both used a flat prepend — `[agentCondition,
224
+ * ...query.conditions]` — which lets a caller-supplied `query.operator`
225
+ * survive and turn the scope condition into one OR-branch.
226
+ *
227
+ * Every table that composes this gets the correct nesting by default;
228
+ * a fifth table added later cannot accidentally reintroduce the flat-
229
+ * prepend bug.
230
+ *
231
+ * `superSearch` is a caller-supplied closure so the class's own
232
+ * `super.search()` (which cannot be referenced from outside the class
233
+ * body) stays exactly where it was.
234
+ */
235
+ export function makeScopedSearch(readScope) {
236
+ return async function scopedSearch(agentId, query, superSearch) {
237
+ const scope = await readScope(agentId);
238
+ const agentCondition = scope.condition;
239
+ // Object with a conditions array — nest caller's conditions inside the
240
+ // scope condition as the outermost AND block so a caller-supplied
241
+ // `operator: "or"` cannot boolean-inject past the owner scope.
242
+ if (query && typeof query === "object" && !Array.isArray(query)) {
243
+ if (Array.isArray(query.conditions) && query.conditions.length > 0) {
244
+ return superSearch({
245
+ ...query,
246
+ conditions: [agentCondition, { conditions: query.conditions, operator: query.operator || "and" }],
247
+ operator: "and",
248
+ });
249
+ }
250
+ // No (or empty) conditions array — just scope, preserving other query
251
+ // properties but NOT a caller-supplied operator (force "and").
252
+ const { conditions: _c, operator: _o, ...rest } = query;
253
+ return superSearch({ ...rest, conditions: [agentCondition], operator: "and" });
254
+ }
255
+ // Plain array or no query — wrap in an object with operator "and" so
256
+ // the scope condition is always the outermost AND.
257
+ const conditions = Array.isArray(query) && query.length > 0
258
+ ? [agentCondition, { conditions: query, operator: "and" }]
259
+ : [agentCondition];
260
+ return superSearch({ conditions, operator: "and" });
261
+ };
262
+ }
214
263
  export function stampAttribution(auth, content, field, mode, forbiddenMessage) {
215
264
  if (auth.kind !== "agent")
216
265
  return {}; // internal → always passthrough
@@ -178,3 +178,48 @@ export function formatVersionNudge(result) {
178
178
  `Upgrade: npm i -g ${FLAIR_PKG_NAME}@latest`;
179
179
  return { severity: gap.severity, message };
180
180
  }
181
+ /**
182
+ * The version an INSTANCE reports, or null when it cannot be determined.
183
+ *
184
+ * flair#1072. Every other line `doctor` prints about a remote target is
185
+ * genuinely remote; the currency claim was about the local CLI. This asks the
186
+ * instance instead.
187
+ *
188
+ * Returns null — never a fallback — when the instance is unreachable, answers
189
+ * without a version, or times out. The whole defect being fixed is a fallback
190
+ * to the number already in hand, and an older instance that does not expose its
191
+ * version is exactly the case where that fallback is most tempting and most
192
+ * wrong. A caller that gets null must say "unknown", not substitute its own
193
+ * version.
194
+ *
195
+ * Deliberately short-timeout and failure-swallowing: doctor runs against
196
+ * possibly-down instances by design, and "cannot determine" is a legitimate,
197
+ * reportable answer rather than an error to propagate.
198
+ */
199
+ export async function probeInstanceVersion(baseUrl, timeoutMs = 5000, fetchImpl = fetch) {
200
+ const url = `${String(baseUrl).replace(/\/+$/, "")}/Health`;
201
+ const ctrl = new AbortController();
202
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
203
+ try {
204
+ const res = await fetchImpl(url, { signal: ctrl.signal });
205
+ if (!res.ok)
206
+ return null;
207
+ const body = await res.json();
208
+ if (!body || typeof body !== "object")
209
+ return null;
210
+ const v = body.version;
211
+ // "dev" and other non-semver markers are real answers from a real server,
212
+ // but they cannot be compared against a published version. Treat them as
213
+ // undeterminable rather than feeding them to a semver comparison — a
214
+ // Fabric peer mid-failed-deploy reports exactly this (harper#2061).
215
+ if (typeof v !== "string" || !/^\d+\.\d+\.\d+/.test(v))
216
+ return null;
217
+ return v;
218
+ }
219
+ catch {
220
+ return null;
221
+ }
222
+ finally {
223
+ clearTimeout(timer);
224
+ }
225
+ }
package/docs/upgrade.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  This page covers the mechanics of upgrading Flair — the general path, valid across
4
4
  versions. For **what changed in a specific release** (behavior changes, new surfaces,
5
- breaking changes), see [`CHANGELOG.md`](../CHANGELOG.md) — each version has its own
5
+ breaking changes), see [`CHANGELOG.md`](https://github.com/tpsdev-ai/flair/blob/main/CHANGELOG.md) — each version has its own
6
6
  `## [X.Y.Z]` section. Check the CHANGELOG entries between your current version and the
7
7
  target version before upgrading anything you depend on in production.
8
8
 
@@ -78,18 +78,23 @@ actually running:
78
78
  instead of retrying in a loop — see [Downgrade](#downgrade) for the
79
79
  restore procedure.
80
80
 
81
- ### Pre-upgrade snapshot (opt-in)
81
+ ### Pre-upgrade snapshot (opt-in for same-engine, unconditional on engine change)
82
82
 
83
83
  flair#637 added a **physical**, byte-exact snapshot of `~/.flair/data` — the whole
84
84
  directory (RocksDB files, keys, config, `admin-pass`), not just the logical records a
85
- `flair backup` JSON export covers. As of 2026-07-08 this is **opt-in**: pass
86
- `--snapshot` to `flair upgrade` to take one before the package swap. It's off by
87
- default — matching how Harper's own upgrade CLI behaves (it recommends a backup before
88
- proceeding, but never auto-tars your data directory for you) — because the
89
- tested-downgrade guarantee below already covers the failure mode a snapshot exists
90
- for, and the old opt-out default meant every upgrade paid the cost (the data dir can
91
- be 800MB+; keep-last-3 retention meant up to ~2.5GB of snapshots sitting around)
92
- whether or not you wanted it.
85
+ `flair backup` JSON export covers. As of 2026-07-08 this is **opt-in** for same-engine
86
+ upgrades: pass `--snapshot` to `flair upgrade` to take one before the package swap.
87
+ It's off by default — matching how Harper's own upgrade CLI behaves (it recommends a
88
+ backup before proceeding, but never auto-tars your data directory for you) — because
89
+ the downgrade-boot test (see below) covers same-engine downgrades, and the old opt-out
90
+ default meant every upgrade paid the cost (the data dir can be 800MB+; keep-last-3
91
+ retention meant up to ~2.5GB of snapshots sitting around) whether or not you wanted it.
92
+
93
+ **When the engine (Harper) version changes** (flair#1047), the snapshot is
94
+ **unconditional** — the tested-downgrade guarantee does not hold across engine version
95
+ boundaries, and the backwards-boot refusal + snapshot recovery path is the invariant
96
+ that applies. Opting out requires `--no-engine-snapshot` and prints what is being
97
+ given up.
93
98
 
94
99
  ```bash
95
100
  flair upgrade --snapshot
@@ -365,7 +370,7 @@ flair restore ~/flair-backup-<date>.json
365
370
 
366
371
  ### Known issue — upgrading *from* an older version can still report a false rollback
367
372
 
368
- The 0.25.1 fix (see [`CHANGELOG.md`](../CHANGELOG.md)) makes `flair upgrade` resolve a
373
+ The 0.25.1 fix (see [`CHANGELOG.md`](https://github.com/tpsdev-ai/flair/blob/main/CHANGELOG.md)) makes `flair upgrade` resolve a
369
374
  credentials-only post-restart-verification failure to `healthy-unverified` instead of
370
375
  rolling back. That fix is **forward-only**: it lives in the *new* CLI code, but an
371
376
  upgrade's post-restart verification is run by the CLI that was already installed
@@ -489,6 +494,20 @@ current build, writes a memory and a presence row, stops it *without* wiping the
489
494
  directory, then boots the last **npm-published** `@tpsdev-ai/flair` against that exact
490
495
  same directory and confirms it comes up healthy and can read both rows back.
491
496
 
497
+ **The guarantee is now restated (flair#1050):** there is never a silent bad outcome.
498
+ Either the old binary boots and serves the corpus correctly, **or** it refuses to start
499
+ with a message naming what wrote the store, what is running, and how to recover — and a
500
+ pre-upgrade snapshot exists to recover *from*. The first branch (clean boot) holds for
501
+ same-engine upgrades; the second (refusal + snapshot) applies when the engine version
502
+ changes, which is the case where downgrade was never ours to guarantee.
503
+
504
+ **First known engine-version break:** Harper 5.1 → 5.2 (2026-08). 5.2.0 creates the
505
+ `hdb_secret` store on first boot against an existing data directory, and the older binary
506
+ will not start against it. Harper's 5.2.0 release notes document no rollback procedure.
507
+ The backwards-boot refusal (flair#1049) catches this: the old binary refuses to start,
508
+ naming both versions and the data directory, with recovery instructions. A pre-upgrade
509
+ snapshot exists at the named path. Restoring it returns the store to a working state.
510
+
492
511
  **As observed when this suite was added (2026-07-08):** the npm-published baseline
493
512
  (0.21.0) boots cleanly against data written by a HEAD build roughly 14 commits ahead of
494
513
  it (several security-hardening and CLI-behavior changes, no Flair schema migration, and
@@ -507,7 +526,7 @@ you haven't personally tested.
507
526
 
508
527
  ## See also
509
528
 
510
- - [`CHANGELOG.md`](../CHANGELOG.md) — what actually changed, version by version.
529
+ - [`CHANGELOG.md`](https://github.com/tpsdev-ai/flair/blob/main/CHANGELOG.md) — what actually changed, version by version.
511
530
  - [`docs/releasing.md`](releasing.md) — how a release gets published in the first
512
531
  place (staged npm publish with 2FA approval), if you're curious why a new version
513
532
  shows up when it does.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tpsdev-ai/flair",
3
- "version": "0.35.0",
3
+ "version": "0.37.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",
@@ -52,8 +52,7 @@
52
52
  "prepublishOnly": "npm run build && npm run build:cli",
53
53
  "test": "bun test",
54
54
  "test:e2e": "playwright test",
55
- "release": "./scripts/release.sh",
56
- "postinstall": "node -e \"try{const{chmodSync,statSync}=require('fs');for(const p of ['dist/cli-shim.cjs','dist/cli.js']){try{if(statSync(p).isFile()){chmodSync(p,0o755);console.error('@tpsdev-ai/flair: chmod +x ' + p + ' OK')}}catch(e){if(e.code!=='ENOENT')console.error('postinstall warn:',e.message)}}}catch(e){console.error('postinstall warn:',e.message)}\""
55
+ "release": "./scripts/release.sh"
57
56
  },
58
57
  "publishConfig": {
59
58
  "access": "public"
@@ -73,7 +72,11 @@
73
72
  "tweetnacl": "1.0.3"
74
73
  },
75
74
  "overrides": {
76
- "react-native-fs": "npm:empty-npm-package@1.0.0"
75
+ "react-native-fs": "npm:empty-npm-package@1.0.0",
76
+ "brace-expansion": "^5.0.9",
77
+ "undici": "^8.9.0",
78
+ "fast-uri": "^4.1.2",
79
+ "hono": "^4.12.34"
77
80
  },
78
81
  "devDependencies": {
79
82
  "@playwright/test": "1.59.1",