@tpsdev-ai/flair 0.23.0 → 0.24.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.
@@ -291,8 +291,26 @@ async function hasWriteGrant(granteeId, ownerId) {
291
291
  * requires a "write" MemoryGrant from the target's owner (reuses the existing
292
292
  * agent-auth/grant machinery — no parallel auth logic). Returns a Response to
293
293
  * short-circuit with (400/403), or null to continue.
294
+ *
295
+ * flair#704: an explicit `supersedes: null` — the shape most JSON writers
296
+ * produce for an unset optional field (`JSON.stringify({supersedes: undefined})`
297
+ * drops the key, but plenty of writers instead do `{supersedes: x ?? null}`)
298
+ * — must be treated identically to the key being OMITTED, per the
299
+ * additive-schema convention (flair#695: an explicit null on an
300
+ * optional/nullable field reads as absent, not as a distinct value). Fixed by
301
+ * deleting the key BEFORE the type check below, so (a) the check never
302
+ * rejects it, and (b) `super.put()`/`super.post()` — Harper full-record
303
+ * replacement, see table-helpers.ts's header comment — never persists a
304
+ * literal `null` where "absent" was intended: the stored row ends up
305
+ * byte-for-byte identical to the omitted-key case, so every downstream
306
+ * `!content.supersedes` / `content.supersedes &&` check below (and in
307
+ * closeSupersededIfNeeded) already treats it as unset with no further
308
+ * changes needed.
294
309
  */
295
310
  async function validateAndAuthorizeSupersedes(content, auth) {
311
+ if (content.supersedes === null) {
312
+ delete content.supersedes;
313
+ }
296
314
  if (content.supersedes !== undefined && typeof content.supersedes !== "string") {
297
315
  return new Response(JSON.stringify({ error: "supersedes must be a string (memory ID)" }), {
298
316
  status: 400, headers: { "Content-Type": "application/json" },
@@ -32,7 +32,7 @@ import { dirname, join } from "node:path";
32
32
  import { fileURLToPath } from "node:url";
33
33
  import { createRequire } from "node:module";
34
34
  import { resolveAgentAuth, verifyAgentRequest } from "./agent-auth.js";
35
- import { WINDOW_MS, isNonceReplay, recordNonce, importEd25519Key, b64ToArrayBuffer } from "./ed25519-auth.js";
35
+ import { WINDOW_MS, isNonceReplay, recordNonce, importEd25519Key, b64ToArrayBuffer, parseTpsEd25519Header } from "./ed25519-auth.js";
36
36
  // ─── Constants ────────────────────────────────────────────────────────────────
37
37
  const CURRENT_TASK_MAX_LENGTH = 200;
38
38
  const VALID_ACTIVITIES = new Set(["coding", "reviewing", "planning", "debugging", "idle"]);
@@ -409,11 +409,11 @@ export class Presence extends databases.flair.Presence {
409
409
  agentId = middlewareAgent;
410
410
  }
411
411
  else {
412
- const m = authHeader.match(/^TPS-Ed25519\s+([^:]+):(\d+):([^:]+):(.+)$/);
413
- if (!m) {
412
+ const parsed = parseTpsEd25519Header(authHeader);
413
+ if (!parsed) {
414
414
  return new Response(JSON.stringify({ error: "Ed25519 agent auth required for heartbeat" }), { status: 401, headers: { "Content-Type": "application/json" } });
415
415
  }
416
- const [, headerAgentId, tsRaw, nonce, sigB64] = m;
416
+ const { agentId: headerAgentId, tsRaw, nonce, signatureB64: sigB64 } = parsed;
417
417
  const ts = Number(tsRaw);
418
418
  const now = Date.now();
419
419
  if (!Number.isFinite(ts) || Math.abs(now - ts) > WINDOW_MS) {
@@ -16,7 +16,7 @@
16
16
  * 30s timestamp window + a per-(agent,nonce) seen-set pruned to that window.
17
17
  */
18
18
  import { databases } from "@harperfast/harper";
19
- import { WINDOW_MS, isNonceReplay, recordNonce, importEd25519Key, b64ToArrayBuffer } from "./ed25519-auth.js";
19
+ import { WINDOW_MS, isNonceReplay, recordNonce, importEd25519Key, b64ToArrayBuffer, parseTpsEd25519Header } from "./ed25519-auth.js";
20
20
  /**
21
21
  * Shared Harper user that verified Ed25519 agents resolve to (least-privilege
22
22
  * `flair_agent` role), replacing the old admin super_user elevation. Single
@@ -56,15 +56,14 @@ async function getAdminAgents() {
56
56
  export async function isAdmin(agentId) {
57
57
  return (await getAdminAgents()).has(agentId);
58
58
  }
59
- const HEADER_RE = /^TPS-Ed25519\s+([^:]+):(\d+):([^:]+):(.+)$/;
60
59
  async function doVerify(request) {
61
60
  const header = request?.headers?.get?.("authorization") ??
62
61
  request?.headers?.asObject?.authorization ??
63
62
  "";
64
- const m = HEADER_RE.exec(header);
65
- if (!m)
63
+ const parsed = parseTpsEd25519Header(header);
64
+ if (!parsed)
66
65
  return null;
67
- const [, agentId, tsRaw, nonce, signatureB64] = m;
66
+ const { agentId, tsRaw, nonce, signatureB64 } = parsed;
68
67
  const ts = Number(tsRaw);
69
68
  const now = Date.now();
70
69
  if (!Number.isFinite(ts) || Math.abs(now - ts) > WINDOW_MS)
@@ -2,7 +2,7 @@ import { patchRecord } from "./table-helpers.js";
2
2
  import { server, databases } from "@harperfast/harper";
3
3
  import { getEmbedding } from "./embeddings-provider.js";
4
4
  import { isAdmin, FLAIR_AGENT_USERNAME } from "./agent-auth.js";
5
- import { WINDOW_MS, isNonceReplay, recordNonce, importEd25519Key, b64ToArrayBuffer } from "./ed25519-auth.js";
5
+ import { WINDOW_MS, isNonceReplay, recordNonce, importEd25519Key, b64ToArrayBuffer, parseTpsEd25519Header } from "./ed25519-auth.js";
6
6
  import { resolveReadScope } from "./memory-read-scope.js";
7
7
  // --- Admin credentials ---
8
8
  // Admin auth is sourced exclusively from Harper's own environment variables
@@ -230,8 +230,8 @@ server.http(async (request, nextLayer) => {
230
230
  return nextLayer(request);
231
231
  }
232
232
  // ── Ed25519 agent auth ────────────────────────────────────────────────────
233
- const m = header.match(/^TPS-Ed25519\s+([^:]+):(\d+):([^:]+):(.+)$/);
234
- if (!m) {
233
+ const parsed = parseTpsEd25519Header(header);
234
+ if (!parsed) {
235
235
  // For browser-accessible admin pages, emit `WWW-Authenticate: Basic` so
236
236
  // the browser shows a native auth dialog instead of a bare 401 page.
237
237
  // JSON API endpoints don't get this — they should keep the structured
@@ -260,7 +260,7 @@ server.http(async (request, nextLayer) => {
260
260
  catch { /* frozen headers — annotation on the request object still applies */ }
261
261
  return nextLayer(request);
262
262
  }
263
- const [, agentId, tsRaw, nonce, signatureB64] = m;
263
+ const { agentId, tsRaw, nonce, signatureB64 } = parsed;
264
264
  const ts = Number(tsRaw);
265
265
  const now = Date.now();
266
266
  if (!Number.isFinite(ts) || Math.abs(now - ts) > WINDOW_MS)
@@ -42,6 +42,43 @@ export { b64ToArrayBuffer };
42
42
  * stated "plugin-shaped, config via env" design intent.
43
43
  */
44
44
  export const WINDOW_MS = Number(process.env.FLAIR_AGENT_AUTH_WINDOW_MS) || 30_000;
45
+ // ─── Auth header parsing (single shared implementation) ────────────────────
46
+ //
47
+ // One parser for the `Authorization: TPS-Ed25519 <id>:<ts>:<nonce>:<sig>`
48
+ // header, used by all 3 call sites (auth-middleware.ts, agent-auth.ts,
49
+ // Presence.ts) so the grammar and its input bounds can't drift.
50
+ /**
51
+ * Upper bound on the accepted Authorization header length. A well-formed
52
+ * TPS-Ed25519 header (`TPS-Ed25519 <id>:<ts>:<nonce>:<sig>`) is a few hundred
53
+ * chars at most; anything materially larger is malformed. The header is
54
+ * untrusted client input, so we cap its length before running the regex,
55
+ * keeping parse cost bounded regardless of the input's shape.
56
+ */
57
+ export const MAX_AUTH_HEADER_LEN = 4096;
58
+ /**
59
+ * TPS-Ed25519 auth header grammar.
60
+ *
61
+ * The two colon-delimited text captures use `[^:\s]+` (not `[^:]+`) so they are
62
+ * disjoint from the preceding `\s+`: with no character shared between the two
63
+ * adjacent quantifiers there is exactly one way to split any input, so matching
64
+ * is linear-time on every input (including long, degenerate ones). A real
65
+ * agentId / nonce / signature never contains whitespace, so excluding it is
66
+ * behavior-preserving for well-formed headers.
67
+ */
68
+ export const TPS_ED25519_HEADER_RE = /^TPS-Ed25519\s+([^:\s]+):(\d+):([^:\s]+):(.+)$/;
69
+ /**
70
+ * Parse a TPS-Ed25519 `Authorization` header value into its fields, or return
71
+ * null if it is over `MAX_AUTH_HEADER_LEN` or doesn't match the grammar.
72
+ * Callers treat null exactly as a header that carries no valid agent auth.
73
+ */
74
+ export function parseTpsEd25519Header(header) {
75
+ if (!header || header.length > MAX_AUTH_HEADER_LEN)
76
+ return null;
77
+ const m = TPS_ED25519_HEADER_RE.exec(header);
78
+ if (!m)
79
+ return null;
80
+ return { agentId: m[1], tsRaw: m[2], nonce: m[3], signatureB64: m[4] };
81
+ }
45
82
  // ─── Replay guard (single shared instance) ─────────────────────────────────
46
83
  //
47
84
  // nonceSeen is the ONE module-level singleton — the whole point of this
@@ -87,6 +87,41 @@
87
87
  import { resolveModelsDir } from "./embeddings-provider.js";
88
88
  const LOGICAL_NAME = "default";
89
89
  const MODEL_NAME = "nomic-embed-text";
90
+ /**
91
+ * Pooling declaration (HFE 0.5.0+, harper-fabric-embeddings' `init()`
92
+ * `pooling` option). Verification, not override: the native addon has no
93
+ * pooling knob of its own — llama.cpp always pools by whatever the GGUF's
94
+ * own `<arch>.pooling_type` metadata says. Declaring the expectation makes
95
+ * HFE assert it at init and fail loudly on absent/mismatched metadata,
96
+ * instead of a metadata-less or mismatched conversion silently pooling the
97
+ * wrong way (see node_modules/harper-fabric-embeddings/README.md's `init()`
98
+ * table for the exact contract this PR bumps to).
99
+ *
100
+ * "mean" is nomic-embed-text-v1.5's actual pooling type — NOT assumed from
101
+ * the model's reputation, directly confirmed against the shipped GGUF:
102
+ * `node_modules/.bin/node-llama-cpp inspect gguf
103
+ * ~/.flair/data/models/nomic-embed-text-v1.5.Q4_K_M.gguf` reports
104
+ * `"nomic-bert": { pooling_type: 1 }`, and llama.cpp's
105
+ * `enum llama_pooling_type` maps 1 -> `LLAMA_POOLING_TYPE_MEAN` (0 = none,
106
+ * 1 = mean, 2 = cls, 3 = last, 4 = rank). nomic-embed-text is a
107
+ * NomicBertModel architecture, not Qwen3 (last-token) — flair registers no
108
+ * Qwen3-class embedding model today (the Qwen3-Reranker-0.6B in
109
+ * resources/rerank-provider.ts is a SEPARATE code path that calls
110
+ * node-llama-cpp directly for generative yes/no scoring, never goes through
111
+ * HFE's register()/init(), and has no embedding-pooling context at all — see
112
+ * that file's header). If a Qwen3-class (last-token-pooling) embedding model
113
+ * is ever registered here, it must declare `pooling: "last"`, not "mean".
114
+ *
115
+ * Applies to the bench-only `modelPath` override too (`benchModelPathOverride()`
116
+ * below): `FLAIR_RECALL_HARNESS_MODEL_PATH` lets an operator point this
117
+ * registration at an arbitrary GGUF for a Q4/Q8 bakeoff, and this constant
118
+ * assumes whatever file lands there is still nomic-family (mean-pooling) —
119
+ * true for every bakeoff run to date (same base model, different quant).
120
+ * HFE 0.5.0's verification is exactly the safety net that turns "someone
121
+ * points --model-file at a non-nomic, non-mean-pooling GGUF" into a loud
122
+ * boot-time failure instead of a silently-wrong pooling pass.
123
+ */
124
+ const EMBEDDING_POOLING = "mean";
90
125
  /**
91
126
  * BENCH-ONLY escape hatch — NOT a production feature flag, never documented
92
127
  * as an operator setting, and never read anywhere else in this codebase.
@@ -131,7 +166,9 @@ export async function registerEmbeddingsBackend() {
131
166
  await register({
132
167
  logicalName: LOGICAL_NAME,
133
168
  kind: "embedding",
134
- config: modelPath ? { modelPath } : { modelName: MODEL_NAME, modelsDir: resolveModelsDir() },
169
+ config: modelPath
170
+ ? { modelPath, pooling: EMBEDDING_POOLING }
171
+ : { modelName: MODEL_NAME, modelsDir: resolveModelsDir(), pooling: EMBEDDING_POOLING },
135
172
  });
136
173
  }
137
174
  catch (err) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tpsdev-ai/flair",
3
- "version": "0.23.0",
3
+ "version": "0.24.0",
4
4
  "description": "Identity, memory, and soul for AI agents. Cryptographic identity (Ed25519), semantic memory with local embeddings, and persistent personality — all in a single process.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -55,10 +55,10 @@
55
55
  },
56
56
  "dependencies": {
57
57
  "@harperfast/harper": "5.1.17",
58
- "@harperfast/oauth": "2.2.0",
58
+ "@harperfast/oauth": "2.4.0",
59
59
  "@types/js-yaml": "4.0.9",
60
60
  "commander": "14.0.3",
61
- "harper-fabric-embeddings": "^0.4.0",
61
+ "harper-fabric-embeddings": "^0.5.0",
62
62
  "jose": "6.2.2",
63
63
  "js-yaml": "4.1.1",
64
64
  "node-llama-cpp": "3.18.1",