@tpsdev-ai/flair 0.51.2 → 0.53.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.
Files changed (74) hide show
  1. package/README.md +10 -5
  2. package/dist/build-info.json +3 -3
  3. package/dist/cli.js +1037 -566
  4. package/dist/doctor-client.js +35 -0
  5. package/dist/hook-install.js +74 -0
  6. package/dist/install/global-bin-path.js +14 -0
  7. package/dist/lib/auth-resolve.js +15 -0
  8. package/dist/lib/doctor-run.js +28 -15
  9. package/dist/lib/launchd-repair.js +198 -0
  10. package/dist/lib/stabilize-mqtt-network.js +123 -0
  11. package/dist/lib/upgrade-exec-path.js +257 -0
  12. package/dist/lib/upgrade-plain-tree.js +558 -0
  13. package/dist/rem/promote-policy.js +204 -0
  14. package/dist/rem/restore.js +55 -15
  15. package/dist/rem/runner.js +203 -20
  16. package/dist/resources/AdminMemory.js +2 -1
  17. package/dist/resources/AgentSeed.js +26 -10
  18. package/dist/resources/Asset.js +203 -0
  19. package/dist/resources/AutoPromoteCandidates.js +2 -4
  20. package/dist/resources/Credential.js +14 -0
  21. package/dist/resources/Federation.js +80 -0
  22. package/dist/resources/Integration.js +12 -0
  23. package/dist/resources/Memory.js +158 -60
  24. package/dist/resources/MemoryBootstrap.js +63 -20
  25. package/dist/resources/MemoryCandidate.js +12 -0
  26. package/dist/resources/MemoryConsolidate.js +2 -1
  27. package/dist/resources/MemoryDedupStats.js +17 -2
  28. package/dist/resources/MemoryFeed.js +30 -0
  29. package/dist/resources/MemoryGrant.js +14 -0
  30. package/dist/resources/MemoryReflect.js +75 -17
  31. package/dist/resources/Message.js +190 -0
  32. package/dist/resources/OrgEvent.js +12 -0
  33. package/dist/resources/PromoteMemoryCandidate.js +76 -0
  34. package/dist/resources/RecordUsage.js +1 -1
  35. package/dist/resources/Relationship.js +12 -0
  36. package/dist/resources/SemanticSearch.js +45 -13
  37. package/dist/resources/Soul.js +54 -18
  38. package/dist/resources/WorkspaceState.js +12 -0
  39. package/dist/resources/auth-middleware.js +17 -44
  40. package/dist/resources/authority-field-guard.js +37 -0
  41. package/dist/resources/bm25-index-service.js +1 -1
  42. package/dist/resources/bm25-index.js +50 -11
  43. package/dist/resources/embedding-space-guard.js +238 -0
  44. package/dist/resources/embeddings-provider.js +32 -5
  45. package/dist/resources/federation-classify.js +23 -1
  46. package/dist/resources/health.js +11 -2
  47. package/dist/resources/hit-tracking.js +244 -0
  48. package/dist/resources/mcp-tools.js +272 -7
  49. package/dist/resources/memory-reflect-lib.js +111 -0
  50. package/dist/resources/migrations/embedding-stamp.js +22 -4
  51. package/dist/resources/owner-field-guard.js +62 -0
  52. package/dist/resources/promotion-stamp.js +29 -0
  53. package/dist/resources/record-owner-guard.js +71 -5
  54. package/dist/resources/record-types.js +30 -7
  55. package/dist/resources/relay-lib.js +205 -0
  56. package/dist/resources/relay-ops.js +294 -0
  57. package/dist/resources/skill-write.js +120 -0
  58. package/dist/resources/soul-adk-guard.js +68 -0
  59. package/dist/resources/soul-write-policy.js +63 -0
  60. package/dist/resources/table-helpers.js +2 -0
  61. package/dist/resources/usage-recording.js +3 -3
  62. package/dist/src/rem/promote-policy.js +204 -0
  63. package/docs/api-reference.md +374 -0
  64. package/docs/auth.md +52 -0
  65. package/docs/federation.md +4 -0
  66. package/docs/integrations.md +6 -6
  67. package/docs/mcp-clients.md +16 -1
  68. package/docs/releasing.md +11 -8
  69. package/docs/rem.md +20 -2
  70. package/docs/upgrade.md +47 -2
  71. package/package.json +6 -5
  72. package/schemas/memory.graphql +51 -2
  73. package/schemas/message.graphql +74 -0
  74. package/templates/launchd/start-flair-with-admin-pass.sh +73 -0
@@ -1,7 +1,9 @@
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, withDetachedTxn } from "./table-helpers.js";
4
+ import { isEmbeddingSpaceUniform, spaceGuardDiagnostics } from "./embedding-space-guard.js";
5
+ import { withDetachedTxn } from "./table-helpers.js";
6
+ import { applyHitStats, noteSearchHits } from "./hit-tracking.js";
5
7
  import { checkRateLimit, rateLimitResponse } from "./rate-limiter.js";
6
8
  import { resolveReadScope } from "./memory-read-scope.js";
7
9
  // The BM25 + union-RRF hybrid path is feature-flagged via hybridEnabled()
@@ -61,7 +63,7 @@ export class SemanticSearch extends Resource {
61
63
  // recall-harness (test/bench/recall-harness/run.ts) and `recall-eval.mjs`
62
64
  // before reconsidering this default if the compositeScore formula or
63
65
  // corpus changes.
64
- const { agentId: bodyAgentId, q, queryEmbedding, tag, subject, subjects, limit = 10, includeSuperseded = false, scoring = "raw", minScore = 0, since, asOf, includeTrust = false, includeMetadata = false, abstain = false, explain = false, includeLegs = false, includeArchived = false } = data || {};
66
+ const { agentId: bodyAgentId, q, queryEmbedding, tag, subject, subjects, limit = 10, includeSuperseded = false, scoring = "raw", minScore = 0, since, asOf, includeTrust = false, includeMetadata = false, includeTrigger = false, abstain = false, explain = false, includeLegs = false, includeArchived = false } = data || {};
65
67
  // Authenticated identity lives on the Harper Resource context (getContext().request).
66
68
  // `this.request` is NOT populated on Harper v5 Resources — prior reads here
67
69
  // silently returned undefined and the defense-in-depth scope check below
@@ -121,6 +123,24 @@ export class SemanticSearch extends Resource {
121
123
  }
122
124
  catch { }
123
125
  }
126
+ // ─── Vector-space uniformity guard (embedding-space-guard slice 1) ────────
127
+ // If the store is NOT uniform in the current embedding space (a mixed-space
128
+ // corpus during a re-embed / model change), cosining the query against
129
+ // stored vectors returns garbage — Harper zero-pads a mismatched-dimension
130
+ // vector and returns a bogus score instead of throwing; same-dims /
131
+ // different-space is silent garbage too. REFUSE the embedding leg and
132
+ // degrade to keyword-only LOUDLY: drop qEmb so retrieveCandidates takes the
133
+ // keyword-only branch (reusing the existing mode:'none' graceful-degrade
134
+ // contract), and surface a structured warning naming both spaces + the
135
+ // `flair reembed` remedy below. Never serve mixed-space vectors, never
136
+ // merely log. Consulted through the SAME single chokepoint the write-time
137
+ // dedup leg uses (resources/embedding-space-guard.ts) — O(1) on the uniform
138
+ // happy path.
139
+ let spaceGuardDegraded = false;
140
+ if (qEmb && !(await isEmbeddingSpaceUniform())) {
141
+ qEmb = undefined;
142
+ spaceGuardDegraded = true;
143
+ }
124
144
  // ─── Temporal intent detection ────────────────────────────────────────────
125
145
  let sinceDate = since ? new Date(since) : null;
126
146
  let temporalBoost = 1.0;
@@ -250,10 +270,17 @@ export class SemanticSearch extends Resource {
250
270
  // with `includeMetadata: true`. `subject` needs no widening — it is
251
271
  // already in DEFAULT_SELECT. Neither flag ⇒ select stays undefined ⇒
252
272
  // response bytes unchanged.
253
- select: (includeTrust || includeMetadata)
273
+ // flair#1546: `includeTrigger` opts the skill recall signal into the
274
+ // projection. DEFAULT_SELECT deliberately omits `trigger` (a skill-only
275
+ // column — the shared retrieval core must not grow it for every consumer,
276
+ // same K&S projection ruling as `metadata`), so skill_search opts it in
277
+ // per-request to return the trigger in its lightweight catalog. Neither
278
+ // flag ⇒ select stays undefined ⇒ response bytes unchanged.
279
+ select: (includeTrust || includeMetadata || includeTrigger)
254
280
  ? [...DEFAULT_SELECT,
255
281
  ...(includeTrust ? ["provenance"] : []),
256
- ...(includeMetadata ? ["metadata"] : [])]
282
+ ...(includeMetadata ? ["metadata"] : []),
283
+ ...(includeTrigger ? ["trigger"] : [])]
257
284
  : undefined,
258
285
  // flair#744 slice 2 + confidence-band refinement: attach the absolute
259
286
  // per-result cosine confidence when the caller opts into abstention OR
@@ -291,15 +318,13 @@ export class SemanticSearch extends Resource {
291
318
  // the final slice needs no additional sort. A cross-encoder rerank stage
292
319
  // used to sit here and reorder the pool before this slice; it was removed
293
320
  // in flair#893 after measuring Δp@3 = 0.000 at 4.1× query latency.
294
- const topResults = filteredResults.slice(0, limit);
295
- // Async hit tracking don't block the response
321
+ // Overlay committed hit stats BEFORE noting this search's increment so
322
+ // the response still shows the pre-hit count (same contract as the old
323
+ // fire-and-forget Memory patch, which ran after the slice).
324
+ const topResults = await Promise.all(filteredResults.slice(0, limit).map((r) => applyHitStats(r, ctx)));
325
+ // Async hit tracking — MemoryHitStat only, never a Memory rewrite.
296
326
  const now = new Date().toISOString();
297
- for (const r of topResults) {
298
- patchRecord(databases.flair.Memory, r.id, {
299
- retrievalCount: (r.retrievalCount || 0) + 1,
300
- lastRetrieved: now,
301
- }).catch(() => { });
302
- }
327
+ noteSearchHits(topResults.map((r) => r.id), now, ctx);
303
328
  // flair#744 slice 1 — opt-in inline trust-evidence block. Assembled HERE,
304
329
  // in the response tail, strictly AFTER read-scope resolution
305
330
  // (retrieveCandidates + scope.isAllowed already ran) and purely for the
@@ -334,7 +359,14 @@ export class SemanticSearch extends Resource {
334
359
  response.bestScore = abstention.bestScore;
335
360
  response.threshold = abstention.threshold;
336
361
  }
337
- if (!qEmb && q && getMode() === "none") {
362
+ if (spaceGuardDegraded) {
363
+ const diag = spaceGuardDiagnostics();
364
+ response._warning =
365
+ `semantic search unavailable — the store has mixed embedding spaces ` +
366
+ `(current: ${diag.current}; found: ${diag.found.join(", ")}); results are keyword-only. ` +
367
+ `Reconcile to one space with: flair reembed --stale-only`;
368
+ }
369
+ else if (!qEmb && q && getMode() === "none") {
338
370
  response._warning = "semantic search unavailable — results are keyword-only";
339
371
  }
340
372
  // flair#1358: opt-in per-leg candidate ids for the bench instrument.
@@ -1,25 +1,21 @@
1
1
  import { databases } from "harper";
2
- import { resolveAgentAuth } from "./agent-auth.js";
2
+ import { guardOwnerFieldImmutable } from "./owner-field-guard.js";
3
3
  import { localInstanceId } from "./instance-identity.js";
4
- import { makeAuthGate, stampAttribution, UNAUTH } from "./record-type-kit.js";
4
+ import { makeAuthGate, stampAttribution } from "./record-type-kit.js";
5
5
  import { RECORD_TYPES } from "./record-types.js";
6
- /**
7
- * Deny anonymous; enforce per-agent write ownership for non-admin agents.
8
- * The previous header-based check only fired when an agent WAS present (it read
9
- * x-tps-agent), so an anonymous request — which carries no x-tps-agent — slipped
10
- * through. With the non-rejecting gate, each write path self-enforces (resolveAgentAuth
11
- * distinguishes internal/agent/anonymous). Mirrors the WorkspaceState pattern.
12
- *
13
- * No-forge attribution — mode/field drawn from RECORD_TYPES.Soul (record-
14
- * types slice 2, flair#520) rather than hand-typed literals. "validate-
15
- * truthy" (see record-type-kit.ts's stampAttribution doc) — rejects a
16
- * PRESENT, mismatched agentId; passes through untouched when absent. Same
17
- * idiom as Memory.post()/put().
18
- */
6
+ import { authorizeSoulWrite, refuseSoulWriteContent, soulProvenance } from "./soul-write-policy.js";
7
+ // Source authorization is independent of principal ownership: an admin runtime
8
+ // may manage records elsewhere, but it cannot author identity-defining Soul.
19
9
  async function enforceWriteAuth(self, data) {
20
- const auth = await resolveAgentAuth(self.getContext?.());
21
- if (auth.kind === "anonymous")
22
- return UNAUTH();
10
+ const { auth, source, denied } = await authorizeSoulWrite(self.getContext?.());
11
+ if (denied)
12
+ return denied;
13
+ if (!data || typeof data !== "object" || Array.isArray(data)) {
14
+ return new Response(JSON.stringify({ error: "soul_write_requires_one_record" }), {
15
+ status: 400, headers: { "Content-Type": "application/json" },
16
+ });
17
+ }
18
+ data.provenance = soulProvenance(auth, source, new Date().toISOString());
23
19
  const attr = stampAttribution(auth, data, RECORD_TYPES.Soul.ownerField, RECORD_TYPES.Soul.attribution.post, "forbidden: agentId must match authenticated agent");
24
20
  return attr.denied ?? null;
25
21
  }
@@ -43,6 +39,10 @@ export class Soul extends databases.flair.Soul {
43
39
  const denied = await enforceWriteAuth(this, content);
44
40
  if (denied)
45
41
  return denied;
42
+ // Learned artifacts cannot gain identity authority through an operator write.
43
+ const learnedDenied = await refuseSoulWriteContent(content);
44
+ if (learnedDenied)
45
+ return learnedDenied;
46
46
  content.durability ||= "permanent";
47
47
  content.createdAt = new Date().toISOString();
48
48
  content.updatedAt = content.createdAt;
@@ -56,10 +56,40 @@ export class Soul extends databases.flair.Soul {
56
56
  }
57
57
  return super.post(content, context);
58
58
  }
59
+ // PATCH must validate the merged value and retained legacy tags, rather
60
+ // than treating an omitted field as proof that no learned content exists.
61
+ async patch(content, query) {
62
+ const denied = await enforceWriteAuth(this, content);
63
+ if (denied)
64
+ return denied;
65
+ const denial = await guardOwnerFieldImmutable(this, () => super.get(), content, "agentId");
66
+ if (denial)
67
+ return denial;
68
+ // Fail-closed, same as Memory's stored-state read: a throw aborts the
69
+ // write; missing/unreadable stored state cannot authorize a PATCH that
70
+ // typically omits agentId (that used to skip the content-provenance match).
71
+ const existing = await super.get();
72
+ if (!existing || typeof existing !== "object" || existing instanceof Response) {
73
+ return new Response(JSON.stringify({ error: "soul_stored_state_unavailable" }), {
74
+ status: 403,
75
+ headers: { "Content-Type": "application/json" },
76
+ });
77
+ }
78
+ const learnedDenied = await refuseSoulWriteContent({ ...existing, ...content });
79
+ if (learnedDenied)
80
+ return learnedDenied;
81
+ return super.patch(content, query);
82
+ }
59
83
  async put(content, context) {
60
84
  const denied = await enforceWriteAuth(this, content);
61
85
  if (denied)
62
86
  return denied;
87
+ const learnedDenied = await refuseSoulWriteContent(content);
88
+ if (learnedDenied)
89
+ return learnedDenied;
90
+ const ownerDenial = await guardOwnerFieldImmutable(this, () => super.get(), content, "agentId");
91
+ if (ownerDenial)
92
+ return ownerDenial;
63
93
  content.updatedAt = new Date().toISOString();
64
94
  // Write-time originatorInstanceId stamp — see post() above / Memory.ts's
65
95
  // stampOriginatorInstanceId doc. No-op if already set.
@@ -68,4 +98,10 @@ export class Soul extends databases.flair.Soul {
68
98
  }
69
99
  return super.put(content, context);
70
100
  }
101
+ async delete(id) {
102
+ const { denied } = await authorizeSoulWrite(this.getContext?.());
103
+ if (denied)
104
+ return denied;
105
+ return super.delete(id);
106
+ }
71
107
  }
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import { databases } from "harper";
11
11
  import { resolveAgentAuth } from "./agent-auth.js";
12
+ import { guardOwnerFieldImmutable } from "./owner-field-guard.js";
12
13
  import { invalidEntitiesResponse } from "./entity-vocab.js";
13
14
  import { makeAuthGate, makeReadScope, makeByIdReadGate, makeScopedSearch, resolveAuthGate, stampAttribution, FORBIDDEN, UNAUTH, } from "./record-type-kit.js";
14
15
  import { RECORD_TYPES } from "./record-types.js";
@@ -115,7 +116,18 @@ export class WorkspaceState extends databases.flair.WorkspaceState {
115
116
  return entitiesError;
116
117
  return super.post(content);
117
118
  }
119
+ // PATCH routes past put(), so agentId immutability is enforced on both verbs
120
+ // via the one shared delegate.
121
+ async patch(content, query) {
122
+ const denial = await guardOwnerFieldImmutable(this, () => super.get(), content, "agentId");
123
+ if (denial)
124
+ return denial;
125
+ return super.patch(content, query);
126
+ }
118
127
  async put(content) {
128
+ const __ownerDenial = await guardOwnerFieldImmutable(this, () => super.get(), content, "agentId");
129
+ if (__ownerDenial)
130
+ return __ownerDenial;
119
131
  const auth = await this._auth();
120
132
  if (auth.kind === "anonymous")
121
133
  return UNAUTH();
@@ -444,6 +444,11 @@ server.http(async (request, nextLayer) => {
444
444
  (method === "POST" || method === "PUT" || method === "PATCH")) {
445
445
  if (!request.tpsAgentIsAdmin) {
446
446
  try {
447
+ // NOTE: dead code — Harper's middleware Request has no parsed body
448
+ // (no .clone()/.json()), so this body-check never fires. Owner/
449
+ // attribution enforcement lives in the resource layer (record-owner-
450
+ // guard + owner-field-guard). Kept for a separate cleanup PR — not
451
+ // live coverage.
447
452
  const clone = request.clone();
448
453
  const body = await clone.json();
449
454
  if (body?.authorId && body.authorId !== agentId) {
@@ -478,6 +483,11 @@ server.http(async (request, nextLayer) => {
478
483
  (method === "POST" || method === "PUT" || method === "PATCH")) {
479
484
  if (!request.tpsAgentIsAdmin) {
480
485
  try {
486
+ // NOTE: dead code — Harper's middleware Request has no parsed body
487
+ // (no .clone()/.json()), so this body-check never fires. Owner/
488
+ // attribution enforcement lives in the resource layer (record-owner-
489
+ // guard + owner-field-guard). Kept for a separate cleanup PR — not
490
+ // live coverage.
481
491
  const clone = request.clone();
482
492
  const body = await clone.json();
483
493
  if (body?.agentId && body.agentId !== agentId) {
@@ -546,6 +556,11 @@ server.http(async (request, nextLayer) => {
546
556
  if (method !== "DELETE") {
547
557
  let bodyAgentId = null;
548
558
  try {
559
+ // NOTE: dead code — Harper's middleware Request has no parsed body
560
+ // (no .clone()/.json()), so this body-check never fires. Owner/
561
+ // attribution enforcement lives in the resource layer (record-owner-
562
+ // guard + owner-field-guard). Kept for a separate cleanup PR — not
563
+ // live coverage.
549
564
  const clone = request.clone();
550
565
  const body = await clone.json();
551
566
  bodyAgentId = body?.agentId ?? null;
@@ -562,50 +577,8 @@ server.http(async (request, nextLayer) => {
562
577
  // one condition enforced in two places is how the two drift apart.
563
578
  }
564
579
  }
565
- // Memory promotion guard: only admin can approve or set durability=permanent
566
- if (((url.pathname === "/Memory" || url.pathname.startsWith("/Memory/") || url.pathname === "/memory" || url.pathname.startsWith("/memory/"))) &&
567
- (method === "PUT" || method === "POST" || method === "PATCH")) {
568
- if (!request.tpsAgentIsAdmin) {
569
- try {
570
- const clone = request.clone();
571
- const body = await clone.json();
572
- const setsApproved = body?.promotionStatus === "approved";
573
- const setsPermanent = body?.durability === "permanent";
574
- const setsArchived = body?.archived === true;
575
- if (setsApproved || setsPermanent || setsArchived) {
576
- return new Response(JSON.stringify({
577
- error: "forbidden: only admins can approve promotions, set permanent durability, or archive memories"
578
- }), { status: 403 });
579
- }
580
- }
581
- catch { }
582
- }
583
- }
584
- // Memory DELETE: permanent memories are admin-only to purge.
585
- //
586
- // The OWNERSHIP half of this guard — which was the model the shared
587
- // record-ownership rule above was built from, and the only one in this file
588
- // that already covered PATCH — now lives there and covers every table. What
589
- // remains here is the part that is NOT about ownership: durability. An agent
590
- // owning a permanent memory still may not purge it.
591
- if (((url.pathname === "/Memory" || url.pathname.startsWith("/Memory/") || url.pathname === "/memory" || url.pathname.startsWith("/memory/"))) &&
592
- method === "DELETE") {
593
- if (!request.tpsAgentIsAdmin) {
594
- try {
595
- const pathParts = url.pathname.split("/").filter(Boolean);
596
- const memId = pathParts[1] ? decodeURIComponent(pathParts[1]) : null;
597
- if (memId) {
598
- const record = await databases.flair.Memory.get(memId);
599
- if (record?.durability === "permanent") {
600
- return new Response(JSON.stringify({
601
- error: "forbidden: only admins can purge permanent memories"
602
- }), { status: 403 });
603
- }
604
- }
605
- }
606
- catch { }
607
- }
608
- }
580
+ // Memory workflow-field provenance is enforced on parsed resource writes.
581
+ // Memory deletion uses the shared stored-owner rule for every tier.
609
582
  }
610
583
  // ── WorkspaceState read guard: agent-scoped reads ───────────────────────────
611
584
  if (method === "GET" && !request.tpsAgentIsAdmin) {
@@ -0,0 +1,37 @@
1
+ /** Workflow verdicts can only be stamped by their trusted raw-table paths.
2
+ * Keep unchanged echoes compatible with full-row clients, and preserve omitted
3
+ * stamps on PUT; omission must not erase a verdict. A content change drops the
4
+ * stamp: the verdict is bound to the reviewed text. No admin/body bypass. */
5
+ export const AUTHORITY_FIELDS = {
6
+ Memory: ["promotionStatus", "promotedAt", "promotedBy"],
7
+ };
8
+ export async function guardAuthorityFields(getExisting, content, table) {
9
+ const existing = await getExisting();
10
+ for (const field of AUTHORITY_FIELDS[table]) {
11
+ if (Object.hasOwn(content, field) && content[field] !== existing?.[field]) {
12
+ return new Response(JSON.stringify({ error: `forbidden: ${field} is set by the promotion workflow` }), {
13
+ status: 403, headers: { "content-type": "application/json" },
14
+ });
15
+ }
16
+ }
17
+ // The stamp means this specific content was reviewed. Echo-tolerance and
18
+ // omitted-field restore apply only when the body does not change `content`.
19
+ // Null the columns (do not merely delete) so PATCH and PUT both drop them.
20
+ if (existing && Object.hasOwn(content, "content") && content.content !== existing.content) {
21
+ for (const field of AUTHORITY_FIELDS[table])
22
+ content[field] = null;
23
+ return null;
24
+ }
25
+ for (const field of AUTHORITY_FIELDS[table]) {
26
+ if (existing && Object.hasOwn(existing, field))
27
+ content[field] = existing[field];
28
+ }
29
+ return null;
30
+ }
31
+ /** Drop workflow stamps from a request-origin raw write. Unconditional: even
32
+ * stamps the guard would restore onto an omitted-field update must not land
33
+ * through ingest paths that are not a promotion-stamp site. */
34
+ export function stripAuthorityFields(record, table) {
35
+ for (const field of AUTHORITY_FIELDS[table])
36
+ delete record[field];
37
+ }
@@ -127,7 +127,7 @@ function record(ev) {
127
127
  }
128
128
  /** Read-your-write hook: call immediately after a committed Memory write that
129
129
  * changed content or any scope/temporal attribute. Safe to call for writes
130
- * that changed neither (it is an idempotent re-index of one row). */
130
+ * that changed neither (an unchanged indexed projection is ignored). */
131
131
  export function noteMemoryUpsert(row) {
132
132
  const r = project(row);
133
133
  if (r)
@@ -89,7 +89,21 @@ export const SUPPORTED_SCOPE_ATTRS = [
89
89
  export const TEMPORAL_ATTRS = ["createdAt", "expiresAt", "validFrom", "validTo"];
90
90
  /** The `select` a corpus scan needs in order to feed this index. */
91
91
  export const INDEX_SELECT = ["id", "content", ...SUPPORTED_SCOPE_ATTRS, ...TEMPORAL_ATTRS];
92
+ // Bound exact-body retention independently of corpus size. Oversized bodies
93
+ // and evicted entries use the existing re-tokenization path, never a hash.
94
+ const MAX_CACHED_BODIES = 1024;
95
+ const MAX_CACHED_BODY_CHARS = 512 * 1024;
96
+ const INDEX_META_ATTRS = [...SUPPORTED_SCOPE_ATTRS, ...TEMPORAL_ATTRS];
92
97
  const SUPPORTED_ATTR_SET = new Set(SUPPORTED_SCOPE_ATTRS);
98
+ // Indexed metadata consists of scalars and arrays of scalars. Unknown object
99
+ // shapes never qualify for the fast path. Arrays are copied on admission so a
100
+ // caller reusing and mutating its record cannot change the comparison snapshot.
101
+ function sameIndexValue(a, b) {
102
+ if (Array.isArray(a) && Array.isArray(b)) {
103
+ return a.length === b.length && a.every((v, i) => (v === null || typeof v !== "object") && v === b[i]);
104
+ }
105
+ return (a === null || typeof a !== "object") && a === b;
106
+ }
93
107
  function emptyPosting() {
94
108
  return { slots: new Int32Array(4), tfs: new Int32Array(4), len: 0 };
95
109
  }
@@ -181,12 +195,33 @@ export class Bm25Index {
181
195
  sweptTo = 0;
182
196
  /** Slots retired by the sweep — removed from the aggregates. */
183
197
  retired = new Set();
198
+ cachedBodies = new Map();
199
+ cachedBodyChars = 0;
200
+ forgetBody(id) {
201
+ const content = this.cachedBodies.get(id);
202
+ if (content === undefined)
203
+ return;
204
+ this.cachedBodyChars -= content.length;
205
+ this.cachedBodies.delete(id);
206
+ }
207
+ rememberBody(id, content) {
208
+ this.forgetBody(id);
209
+ if (content.length > MAX_CACHED_BODY_CHARS)
210
+ return;
211
+ while (this.cachedBodies.size >= MAX_CACHED_BODIES || this.cachedBodyChars + content.length > MAX_CACHED_BODY_CHARS) {
212
+ this.forgetBody(this.cachedBodies.keys().next().value);
213
+ }
214
+ this.cachedBodies.set(id, content);
215
+ this.cachedBodyChars += content.length;
216
+ }
184
217
  get size() { return this.slotOf.size; }
185
218
  /** Live postings, for the memory-footprint assertions in the tests. */
186
219
  get postingCount() { return this.totalPostings - this.deadPostings; }
187
220
  get termCount() { return this.postings.size; }
188
221
  has(id) { return this.slotOf.has(id); }
189
222
  clear() {
223
+ this.cachedBodies.clear();
224
+ this.cachedBodyChars = 0;
190
225
  this.slots = [];
191
226
  this.slotOf.clear();
192
227
  this.freeSlots = [];
@@ -201,30 +236,32 @@ export class Bm25Index {
201
236
  this.retired.clear();
202
237
  }
203
238
  // ─── Maintenance ──────────────────────────────────────────────────────────
204
- /**
205
- * Add or replace a document. Deliberately NOT "diff the content and patch
206
- * the postings": an upsert always tombstones the old slot and appends a new
207
- * one. Detecting an unchanged body would need a content fingerprint, and a
208
- * fingerprint collision is a silently-wrong lexical index — the one failure
209
- * mode this index may not have. The cost is a dead posting run per update,
210
- * reclaimed by `compact()` below at O(1) amortized.
211
- */
239
+ /** Ignore writes whose complete indexed projection is unchanged. Relevant
240
+ * changes still replace the slot, preserving aggregate and expiry handling. */
212
241
  upsert(record) {
213
242
  const id = record?.id;
214
243
  if (typeof id !== "string" || id.length === 0)
215
244
  return;
245
+ const content = record.content || "";
246
+ const previousSlot = this.slotOf.get(id);
247
+ const previous = previousSlot === undefined ? null : this.slots[previousSlot];
248
+ if (previous && this.cachedBodies.get(id) === content &&
249
+ INDEX_META_ATTRS.every((attr) => (attr in previous.meta) === (attr in record) && sameIndexValue(previous.meta[attr], record[attr]))) {
250
+ this.rememberBody(id, content);
251
+ return;
252
+ }
216
253
  this.remove(id);
217
- const tokens = tokenize(record.content || "");
254
+ const tokens = tokenize(content);
218
255
  const tf = new Map();
219
256
  for (const t of tokens)
220
257
  tf.set(t, (tf.get(t) || 0) + 1);
221
258
  const meta = { id };
222
259
  for (const attr of SUPPORTED_SCOPE_ATTRS)
223
260
  if (attr in record)
224
- meta[attr] = record[attr];
261
+ meta[attr] = Array.isArray(record[attr]) ? record[attr].slice() : record[attr];
225
262
  for (const attr of TEMPORAL_ATTRS)
226
263
  if (attr in record)
227
- meta[attr] = record[attr];
264
+ meta[attr] = Array.isArray(record[attr]) ? record[attr].slice() : record[attr];
228
265
  const slot = this.freeSlots.length > 0 ? this.freeSlots.pop() : this.slots.length;
229
266
  const entry = {
230
267
  id,
@@ -236,6 +273,7 @@ export class Bm25Index {
236
273
  };
237
274
  this.slots[slot] = entry;
238
275
  this.slotOf.set(id, slot);
276
+ this.rememberBody(id, content);
239
277
  for (const [term, count] of tf) {
240
278
  let p = this.postings.get(term);
241
279
  if (!p) {
@@ -262,6 +300,7 @@ export class Bm25Index {
262
300
  return;
263
301
  const entry = this.slots[slot];
264
302
  this.slotOf.delete(id);
303
+ this.forgetBody(id);
265
304
  this.slots[slot] = null;
266
305
  if (entry) {
267
306
  if (!this.retired.delete(slot))