@tpsdev-ai/flair 0.44.11 → 0.44.12

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.
@@ -7,6 +7,7 @@ import { scanFields, isStrictMode } from "./content-safety.js";
7
7
  import { invalidEntitiesResponse } from "./entity-vocab.js";
8
8
  import { checkRateLimit, rateLimitResponse } from "./rate-limiter.js";
9
9
  import { assertValidVisibility } from "./memory-visibility.js";
10
+ import { assertValidDurability } from "./memory-durability.js";
10
11
  import { DEDUP_COSINE_THRESHOLD_DEFAULT, DEDUP_LEXICAL_THRESHOLD_DEFAULT, DEDUP_MIN_CONTENT_LENGTH, computeMatchConfidence, cosineSimilarity, isConservativeMatch, } from "./dedup.js";
11
12
  import { buildProvenance, makeAuthGate, makeReadScope, makeByIdReadGate, makeScopedSearch, resolveAuthGate, stampAttribution, FORBIDDEN, UNAUTH, } from "./record-type-kit.js";
12
13
  import { RECORD_TYPES } from "./record-types.js";
@@ -599,6 +600,19 @@ export class Memory extends databases.flair.Memory {
599
600
  const usedMemoryIds = content?.usedMemoryIds;
600
601
  if (content && typeof content === "object")
601
602
  delete content.usedMemoryIds;
603
+ // ── flair#1238: refuse an unrecognised durability BEFORE defaulting ──
604
+ // defaultVisibilityForDurability treats any non-permanent/persistent string
605
+ // as the private branch, so an unknown durability via raw REST (or a future
606
+ // non-Python adapter) is silently accepted and lands on the narrower private
607
+ // branch by accident — fail-safe, but unvalidated by contract. Refusing at
608
+ // the schema boundary makes it safe by construction (mirrors the visibility
609
+ // guard below). Absent durability is accepted and defaulted to "standard".
610
+ {
611
+ const durabilityError = assertValidDurability(content.durability);
612
+ if (durabilityError) {
613
+ return new Response(JSON.stringify({ error: "invalid_durability", message: durabilityError }), { status: 400, headers: { "content-type": "application/json" } });
614
+ }
615
+ }
602
616
  content.durability ||= "standard";
603
617
  content.createdAt = new Date().toISOString();
604
618
  content.updatedAt = content.createdAt;
@@ -789,6 +803,17 @@ export class Memory extends databases.flair.Memory {
789
803
  const usedMemoryIds = content?.usedMemoryIds;
790
804
  if (content && typeof content === "object")
791
805
  delete content.usedMemoryIds;
806
+ // ── flair#1238: refuse an unrecognised durability (mirrors post()) ──
807
+ // put() is the other HTTP-reachable write path (fresh create via CLI, and
808
+ // the update/patch path). Same guard as post(): a present-but-unknown
809
+ // durability is refused with 400; absent is accepted (no default stamped
810
+ // here — put() leaves durability untouched for updates).
811
+ {
812
+ const durabilityError = assertValidDurability(content.durability);
813
+ if (durabilityError) {
814
+ return new Response(JSON.stringify({ error: "invalid_durability", message: durabilityError }), { status: 400, headers: { "content-type": "application/json" } });
815
+ }
816
+ }
792
817
  const now = new Date().toISOString();
793
818
  content.updatedAt = now;
794
819
  // Set defaults that post() sets — put() is also used for new records via CLI
@@ -142,6 +142,35 @@ const MAX_COLLISION_ENTRIES = 10;
142
142
  // null` (no relevance score to band), which is CORRECT (Kern's #1220 ruling),
143
143
  // never a scoring failure — see the `matchQualityNote` in the response tail.
144
144
  const LIFECYCLE_SECTIONS = new Set(["permanent", "recent", "predicted"]);
145
+ // flair#1199 trust-admission — build the EXACT trust entry that ships for one
146
+ // included memory (id + section + block + the conditional matchQualityNote), so
147
+ // the admission loop can charge its REAL serialized cost at the same moment it
148
+ // charges the content cost. Single source of truth: the response tail reuses
149
+ // this same builder, so the charged size and the shipped size can never drift
150
+ // (the #1226 "charge what ships" principle, extended to the per-item trust
151
+ // block). The block is assembled purely for SIZING + the response — its content
152
+ // never enters any authority/scope/attribution/dedup decision (the #735/#744
153
+ // zero-authority invariant).
154
+ //
155
+ // flair#1225 — Kern ruled (on #1220) that a null `matchQuality` on an own-recent
156
+ // (lifecycle) entry is CORRECT: lifecycle sections (permanent/recent/predicted)
157
+ // are a window LOAD, not a retrieval surface, so there is no similarity to band
158
+ // — the null means "not scored here", never a scoring failure on the caller's
159
+ // own records (the #1201 misread: own-recent null beside a teammate band).
160
+ // Behavior is unchanged (per Kern); the matchQualityNote only makes the null
161
+ // self-describing in the payload — Fix 3's "any absent field says why" — so a
162
+ // connector reads it right without knowing the #1201 contract.
163
+ function buildTrustEntry(m, section) {
164
+ const block = buildTrustBlock(m);
165
+ const matchQualityNote = block.matchQuality === null
166
+ ? (LIFECYCLE_SECTIONS.has(section)
167
+ ? `matchQuality is null because '${section}' is a lifecycle-window section, not a retrieval `
168
+ + `surface — there is no relevance score to band. This is correct (per flair#1225), not a scoring failure.`
169
+ : "matchQuality is null because no semantic similarity was attached to this result "
170
+ + "(e.g. a by-id read or a keyword-only degraded match).")
171
+ : undefined;
172
+ return { id: m.id, section, ...block, ...(matchQualityNote ? { matchQualityNote } : {}) };
173
+ }
145
174
  // flair#1199/#1206 — the default cap on how many org events bootstrap ships.
146
175
  // Overridable per-request via `maxEvents`. Event slots are scarce AND (as of
147
176
  // #1199) token-charged, so this bounds both the count and the spend; the shared
@@ -670,12 +699,17 @@ export class BootstrapMemories extends Resource {
670
699
  // on the REST path); see contentCost. #1207 stays honored: on the prose
671
700
  // path this is still the prose-line cost, so REST recall is unchanged.
672
701
  const cost = contentCost(struct, line);
673
- if (cost <= tokenBudget) {
702
+ // flair#1199 trust-admission charge the trust block's real serialized
703
+ // cost at the same moment as the content cost (per-item trust is CONTENT,
704
+ // not fixed scaffolding; see buildTrustEntry).
705
+ const trustEntry = includeTrust ? buildTrustEntry(m, "permanent") : null;
706
+ const trustCost = trustEntry ? estimateTokens(JSON.stringify(trustEntry)) : 0;
707
+ if (cost + trustCost <= tokenBudget) {
674
708
  sections.permanent.push(line);
675
709
  includedOwnMemories.push(struct);
676
- if (includeTrust)
677
- includedTrustMemories.push({ m, section: "permanent" });
678
- tokenBudget -= cost;
710
+ if (trustEntry)
711
+ includedTrustMemories.push(trustEntry);
712
+ tokenBudget -= cost + trustCost;
679
713
  includedOwnIds.add(m.id); // #1207 — count by unique own-memory id
680
714
  }
681
715
  else {
@@ -743,16 +777,20 @@ export class BootstrapMemories extends Resource {
743
777
  const line = formatMemory(m, agentId);
744
778
  const struct = leanMemory(m, "recent");
745
779
  const cost = contentCost(struct, line); // #1199 (0.44.11) — charge what ships; see contentCost
746
- if (recentSpent + cost > recentBudget) {
780
+ // flair#1199 trust-admission charge the trust block's real serialized
781
+ // cost against BOTH the recent sub-budget and the shared tokenBudget.
782
+ const trustEntry = includeTrust ? buildTrustEntry(m, "recent") : null;
783
+ const trustCost = trustEntry ? estimateTokens(JSON.stringify(trustEntry)) : 0;
784
+ if (recentSpent + cost + trustCost > recentBudget) {
747
785
  truncatedOwnIds.add(m.id); // #1207 — budget-skip; may still be admitted later via the task-relevant loop (deduped at the end)
748
786
  continue;
749
787
  }
750
788
  sections.recent.push(line);
751
789
  includedOwnMemories.push(struct);
752
- if (includeTrust)
753
- includedTrustMemories.push({ m, section: "recent" });
754
- recentSpent += cost;
755
- tokenBudget -= cost;
790
+ if (trustEntry)
791
+ includedTrustMemories.push(trustEntry);
792
+ recentSpent += cost + trustCost;
793
+ tokenBudget -= cost + trustCost;
756
794
  includedOwnIds.add(m.id); // #1207 — count by unique own-memory id
757
795
  }
758
796
  // --- 3b. Subject-predicted context ---
@@ -787,16 +825,20 @@ export class BootstrapMemories extends Resource {
787
825
  const line = formatMemory(m, agentId);
788
826
  const struct = leanMemory(m, "predicted");
789
827
  const cost = contentCost(struct, line); // #1199 (0.44.11) — charge what ships; see contentCost
790
- if (predictedSpent + cost > predictedBudget) {
828
+ // flair#1199 trust-admission charge the trust block's real serialized
829
+ // cost against BOTH the predicted sub-budget and the shared tokenBudget.
830
+ const trustEntry = includeTrust ? buildTrustEntry(m, "predicted") : null;
831
+ const trustCost = trustEntry ? estimateTokens(JSON.stringify(trustEntry)) : 0;
832
+ if (predictedSpent + cost + trustCost > predictedBudget) {
791
833
  truncatedOwnIds.add(m.id); // #1207 — budget-skip (deduped against inclusions at the end)
792
834
  continue;
793
835
  }
794
836
  sections.predicted.push(line);
795
837
  includedPredicted.push(struct);
796
- if (includeTrust)
797
- includedTrustMemories.push({ m, section: "predicted" });
798
- predictedSpent += cost;
799
- tokenBudget -= cost;
838
+ if (trustEntry)
839
+ includedTrustMemories.push(trustEntry);
840
+ predictedSpent += cost + trustCost;
841
+ tokenBudget -= cost + trustCost;
800
842
  includedOwnIds.add(m.id); // #1207 — count by unique own-memory id; also the task-relevant loop's exclusion set (no predicted→relevant double-admit)
801
843
  }
802
844
  }
@@ -985,7 +1027,13 @@ export class BootstrapMemories extends Resource {
985
1027
  }
986
1028
  : leanMemory(m, "relevant");
987
1029
  const cost = contentCost(struct, line);
988
- if (cost > tokenBudget) {
1030
+ // flair#1199 trust-admission charge the trust block's real serialized
1031
+ // cost at the same moment as the content cost. The section (teammate vs
1032
+ // relevant) is decided by `m._source`, so build the entry with the right
1033
+ // section before the budget check.
1034
+ const trustEntry = includeTrust ? buildTrustEntry(m, m._source ? "teammate" : "relevant") : null;
1035
+ const trustCost = trustEntry ? estimateTokens(JSON.stringify(trustEntry)) : 0;
1036
+ if (cost + trustCost > tokenBudget) {
989
1037
  // flair#1207 — a size-skip in the score-ordered task-relevant loop
990
1038
  // is no longer silent: record it on the denominator matching the
991
1039
  // record's origin (own → truncatedOwnIds, teammate → the separate
@@ -1006,9 +1054,9 @@ export class BootstrapMemories extends Resource {
1006
1054
  // memoriesIncluded — that different-denominator mix is what let
1007
1055
  // included exceed available.
1008
1056
  includedTeammateFindings.push(struct);
1009
- if (includeTrust)
1010
- includedTrustMemories.push({ m, section: "teammate" });
1011
- tokenBudget -= cost;
1057
+ if (trustEntry)
1058
+ includedTrustMemories.push(trustEntry);
1059
+ tokenBudget -= cost + trustCost;
1012
1060
  teammateFindingsIncluded++;
1013
1061
  }
1014
1062
  else {
@@ -1016,9 +1064,9 @@ export class BootstrapMemories extends Resource {
1016
1064
  // flair#1182 — own task-relevant records join the `memories`
1017
1065
  // container.
1018
1066
  includedOwnMemories.push(struct);
1019
- if (includeTrust)
1020
- includedTrustMemories.push({ m, section: "relevant" });
1021
- tokenBudget -= cost;
1067
+ if (trustEntry)
1068
+ includedTrustMemories.push(trustEntry);
1069
+ tokenBudget -= cost + trustCost;
1022
1070
  includedOwnIds.add(m.id); // #1207 — count by unique own-memory id
1023
1071
  }
1024
1072
  }
@@ -1346,28 +1394,12 @@ export class BootstrapMemories extends Resource {
1346
1394
  // caller's own records. flair#1225 (0.44.11) — the null on a lifecycle
1347
1395
  // section is now SELF-EXPLAINING (matchQualityNote below), not just legible
1348
1396
  // via `section`.
1349
- const trust = includeTrust
1350
- ? includedTrustMemories.map(({ m, section }) => {
1351
- const block = buildTrustBlock(m);
1352
- // flair#1225 Kern ruled (on #1220) that a null `matchQuality` on an
1353
- // own-recent (lifecycle) entry is CORRECT: lifecycle sections
1354
- // (permanent/recent/predicted) are a window LOAD, not a retrieval
1355
- // surface, so there is no similarity to band — the null means "not
1356
- // scored here", never a scoring failure on the caller's own records
1357
- // (the #1201 misread: own-recent null beside a teammate band). Behavior
1358
- // is unchanged (per Kern); this only makes the null self-describing in
1359
- // the payload — Fix 3's "any absent field says why" — so a connector
1360
- // reads it right without knowing the #1201 contract.
1361
- const matchQualityNote = block.matchQuality === null
1362
- ? (LIFECYCLE_SECTIONS.has(section)
1363
- ? `matchQuality is null because '${section}' is a lifecycle-window section, not a retrieval `
1364
- + `surface — there is no relevance score to band. This is correct (per flair#1225), not a scoring failure.`
1365
- : "matchQuality is null because no semantic similarity was attached to this result "
1366
- + "(e.g. a by-id read or a keyword-only degraded match).")
1367
- : undefined;
1368
- return { id: m.id, section, ...block, ...(matchQualityNote ? { matchQualityNote } : {}) };
1369
- })
1370
- : undefined;
1397
+ // flair#1199 trust-admission the trust entries were built (and their real
1398
+ // serialized cost charged) at admission time via buildTrustEntry, so the
1399
+ // response tail just ships them as-is. The #1225 matchQualityNote logic now
1400
+ // lives in buildTrustEntry (single source of truth the charged size and
1401
+ // the shipped size can never drift).
1402
+ const trust = includeTrust ? includedTrustMemories : undefined;
1371
1403
  // flair#744 slice 2 — opt-in abstention verdict for the task-relevance
1372
1404
  // surface. Present ONLY when `abstain` is requested (byte-identical to
1373
1405
  // pre-slice-2 otherwise); scoped to whether any memory covered
@@ -0,0 +1,40 @@
1
+ /**
2
+ * ─── The single "is this a valid durability" writer-intent guard ────────────
3
+ *
4
+ * Mirror of resources/memory-visibility.ts's assertValidVisibility, for the
5
+ * durability enum. Same asymmetry, same reason:
6
+ *
7
+ * - READING an unknown durability must be permissive. A row written before a
8
+ * tier existed (or by a non-Python adapter) may hold anything, and the read
9
+ * side must keep resolving it exactly as before — defaultVisibilityForDurability
10
+ * treats any non-permanent/persistent string as the private branch, and that
11
+ * fail-safe must not change.
12
+ * - WRITING an unknown durability must be refused. Today an unknown value via
13
+ * raw REST (or a future non-Python adapter) is silently accepted and lands on
14
+ * the narrower private branch by accident — fail-safe, but unvalidated by
15
+ * contract. Refusing at the schema boundary makes it safe by construction and
16
+ * makes adk-flair's "validated server-side" claim true as written (flair#1238,
17
+ * from Sherlock's #1237 review).
18
+ *
19
+ * Deliberately has ZERO imports — same load-bearing reason as memory-visibility.ts:
20
+ * this module is a pure function + constant that any caller can import without
21
+ * dragging in "harper".
22
+ */
23
+ /** The only values a WRITER may supply. */
24
+ export const WRITABLE_DURABILITIES = ["permanent", "persistent", "standard", "ephemeral"];
25
+ /**
26
+ * Reject a durability a writer supplied that is not one of the four valid values.
27
+ * Returns an error message, or null when the value is acceptable.
28
+ *
29
+ * `undefined`/`null` are accepted: omitting the field is how a caller asks for
30
+ * the default ("standard"), and that is a documented, intentional path.
31
+ */
32
+ export function assertValidDurability(durability) {
33
+ if (durability === undefined || durability === null)
34
+ return null;
35
+ if (typeof durability === "string" && WRITABLE_DURABILITIES.includes(durability)) {
36
+ return null;
37
+ }
38
+ return (`durability must be ${WRITABLE_DURABILITIES.map((v) => `"${v}"`).join(" or ")} ` +
39
+ `(got: ${JSON.stringify(durability)}). Omit it to use the default "standard".`);
40
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tpsdev-ai/flair",
3
- "version": "0.44.11",
3
+ "version": "0.44.12",
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",