@tpsdev-ai/flair 0.51.2 → 0.52.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 (71) hide show
  1. package/README.md +10 -5
  2. package/dist/build-info.json +3 -3
  3. package/dist/cli.js +575 -547
  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/upgrade-exec-path.js +257 -0
  10. package/dist/lib/upgrade-plain-tree.js +558 -0
  11. package/dist/rem/promote-policy.js +204 -0
  12. package/dist/rem/restore.js +55 -15
  13. package/dist/rem/runner.js +203 -20
  14. package/dist/resources/AdminMemory.js +2 -1
  15. package/dist/resources/AgentSeed.js +26 -10
  16. package/dist/resources/Asset.js +203 -0
  17. package/dist/resources/AutoPromoteCandidates.js +2 -4
  18. package/dist/resources/Credential.js +14 -0
  19. package/dist/resources/Federation.js +80 -0
  20. package/dist/resources/Integration.js +12 -0
  21. package/dist/resources/Memory.js +158 -60
  22. package/dist/resources/MemoryBootstrap.js +63 -20
  23. package/dist/resources/MemoryCandidate.js +12 -0
  24. package/dist/resources/MemoryConsolidate.js +2 -1
  25. package/dist/resources/MemoryDedupStats.js +17 -2
  26. package/dist/resources/MemoryFeed.js +30 -0
  27. package/dist/resources/MemoryGrant.js +14 -0
  28. package/dist/resources/MemoryReflect.js +75 -17
  29. package/dist/resources/Message.js +190 -0
  30. package/dist/resources/OrgEvent.js +12 -0
  31. package/dist/resources/PromoteMemoryCandidate.js +76 -0
  32. package/dist/resources/RecordUsage.js +1 -1
  33. package/dist/resources/Relationship.js +12 -0
  34. package/dist/resources/SemanticSearch.js +45 -13
  35. package/dist/resources/Soul.js +54 -18
  36. package/dist/resources/WorkspaceState.js +12 -0
  37. package/dist/resources/auth-middleware.js +17 -44
  38. package/dist/resources/authority-field-guard.js +37 -0
  39. package/dist/resources/bm25-index-service.js +1 -1
  40. package/dist/resources/bm25-index.js +50 -11
  41. package/dist/resources/embedding-space-guard.js +238 -0
  42. package/dist/resources/embeddings-provider.js +32 -5
  43. package/dist/resources/federation-classify.js +23 -1
  44. package/dist/resources/health.js +11 -2
  45. package/dist/resources/hit-tracking.js +244 -0
  46. package/dist/resources/mcp-tools.js +272 -7
  47. package/dist/resources/memory-reflect-lib.js +111 -0
  48. package/dist/resources/migrations/embedding-stamp.js +22 -4
  49. package/dist/resources/owner-field-guard.js +62 -0
  50. package/dist/resources/promotion-stamp.js +29 -0
  51. package/dist/resources/record-owner-guard.js +71 -5
  52. package/dist/resources/record-types.js +30 -7
  53. package/dist/resources/relay-lib.js +205 -0
  54. package/dist/resources/relay-ops.js +294 -0
  55. package/dist/resources/skill-write.js +120 -0
  56. package/dist/resources/soul-adk-guard.js +68 -0
  57. package/dist/resources/soul-write-policy.js +63 -0
  58. package/dist/resources/table-helpers.js +2 -0
  59. package/dist/resources/usage-recording.js +3 -3
  60. package/dist/src/rem/promote-policy.js +204 -0
  61. package/docs/api-reference.md +374 -0
  62. package/docs/auth.md +52 -0
  63. package/docs/federation.md +4 -0
  64. package/docs/integrations.md +6 -6
  65. package/docs/mcp-clients.md +16 -1
  66. package/docs/releasing.md +11 -8
  67. package/docs/rem.md +20 -2
  68. package/docs/upgrade.md +47 -2
  69. package/package.json +6 -5
  70. package/schemas/memory.graphql +51 -2
  71. package/schemas/message.graphql +74 -0
@@ -1,19 +1,25 @@
1
1
  import { databases } from "harper";
2
2
  import { patchRecord, withDetachedTxn } from "./table-helpers.js";
3
3
  import { isAdmin, resolveAgentAuth } from "./agent-auth.js";
4
+ import { guardAuthorityFields } from "./authority-field-guard.js";
5
+ import { isForbiddenOwnerMutation } from "./record-owner-guard.js";
6
+ import { guardOwnerFieldImmutable } from "./owner-field-guard.js";
4
7
  import { localInstanceId } from "./instance-identity.js";
5
8
  import { getEmbedding, getModelId } from "./embeddings-provider.js";
9
+ import { isEmbeddingSpaceUniform, noteWriteStamp } from "./embedding-space-guard.js";
6
10
  import { scanFields, isStrictMode } from "./content-safety.js";
7
11
  import { invalidEntitiesResponse } from "./entity-vocab.js";
8
12
  import { checkRateLimit, rateLimitResponse } from "./rate-limiter.js";
9
13
  import { assertValidVisibility, assertVisibilityAllowedForDurability } from "./memory-visibility.js";
10
14
  import { assertValidDurability } from "./memory-durability.js";
15
+ import { enforceSkillDurability, rejectSkillWritePath, skillEmbedText, skillScanGate } from "./skill-write.js";
11
16
  import { DEDUP_COSINE_THRESHOLD_DEFAULT, DEDUP_LEXICAL_THRESHOLD_DEFAULT, DEDUP_MIN_CONTENT_LENGTH, computeMatchConfidence, cosineSimilarity, isConservativeMatch, } from "./dedup.js";
12
17
  import { buildProvenance, makeAuthGate, makeReadScope, makeByIdReadGate, makeScopedSearch, resolveAuthGate, stampAttribution, FORBIDDEN, UNAUTH, } from "./record-type-kit.js";
13
18
  import { RECORD_TYPES } from "./record-types.js";
14
19
  import { attachTrust } from "./trust-block.js";
15
20
  import { recordCitations } from "./usage-recording.js";
16
21
  import { noteMemoryUpsert, noteMemoryDelete } from "./bm25-index-service.js";
22
+ import { applyHitStats, clearHitStats, overlayHitStatsResult } from "./hit-tracking.js";
17
23
  /**
18
24
  * flair#744 slice 1 — read the opt-in `includeTrust` flag for a by-id get.
19
25
  * Two entry shapes: an in-process caller (resources/mcp-tools.ts's memory_get)
@@ -112,6 +118,16 @@ const memoryAuthGate = makeAuthGate();
112
118
  async function findConservativeDedupMatch(ctx, agentId, contentText, embedding, cosineThreshold, lexicalThreshold) {
113
119
  if (!agentId || !embedding || embedding.length === 0)
114
120
  return null;
121
+ // ── Vector-space uniformity guard (embedding-space-guard slice 1) ──────────
122
+ // When the corpus is not uniform in the current embedding space, the cosine
123
+ // compare below would cross vector spaces (Harper zero-pads / returns a
124
+ // garbage score). No-op the dedup cosine leg — treat as no-match — through
125
+ // the SAME single chokepoint the recall leg (SemanticSearch) consults.
126
+ // ADVISORY ONLY: the write always proceeds (runDedupGate already computed and
127
+ // stamped a fresh CURRENT-space embedding); this only skips a comparison that
128
+ // can't be trusted while spaces are mixed. Never suppresses a write.
129
+ if (!(await isEmbeddingSpaceUniform()))
130
+ return null;
115
131
  try {
116
132
  const query = {
117
133
  sort: { attribute: "embedding", target: embedding, distance: "cosine" },
@@ -119,7 +135,11 @@ async function findConservativeDedupMatch(ctx, agentId, contentText, embedding,
119
135
  { attribute: "agentId", comparator: "equals", value: agentId },
120
136
  { attribute: "archived", comparator: "not_equal", value: true },
121
137
  ],
122
- select: ["id", "content", "$distance"],
138
+ // flair#1546 dedup footnote: `trigger`/`tags` widen the candidate
139
+ // projection so the LEXICAL leg can compare trigger-vs-trigger for skill
140
+ // rows (see the computeMatchConfidence call below). Non-skill candidates
141
+ // are unaffected — skillEmbedText falls back to `content`.
142
+ select: ["id", "content", "trigger", "tags", "$distance"],
123
143
  limit: 1,
124
144
  };
125
145
  let top = null;
@@ -188,7 +208,15 @@ async function findConservativeDedupMatch(ctx, agentId, contentText, embedding,
188
208
  const candidateEmbedding = Array.isArray(fullCandidate?.embedding) ? fullCandidate.embedding : [];
189
209
  cosine = cosineSimilarity(embedding, candidateEmbedding);
190
210
  }
191
- const confidence = computeMatchConfidence(contentText, top.content, cosine);
211
+ // flair#1546 dedup footnote (Kern, non-blocking): the lexical leg must
212
+ // compare the SAME text the vector represents on BOTH sides. `contentText`
213
+ // is already skillEmbedText(new) (the trigger for a skill write); the
214
+ // candidate side must match — skillEmbedText(top) returns the candidate's
215
+ // `trigger` when it is a skill row, its `content` otherwise. Pre-fix this
216
+ // crossed the new row's trigger against the candidate's stored `content`
217
+ // (trigger-vs-content), under-flagging near-duplicate skill triggers.
218
+ const candidateLexText = skillEmbedText(top);
219
+ const confidence = computeMatchConfidence(contentText, candidateLexText, cosine);
192
220
  if (!isConservativeMatch(confidence.cosine, confidence.lexical, cosineThreshold, lexicalThreshold)) {
193
221
  return null;
194
222
  }
@@ -216,7 +244,13 @@ async function runDedupGate(ctx, content) {
216
244
  delete content.dedup;
217
245
  delete content.dedupThreshold;
218
246
  delete content.lexicalThreshold;
219
- if (typeof content.content !== "string" || content.content.length < DEDUP_MIN_CONTENT_LENGTH) {
247
+ // flair#1542: skill-tagged rows embed from `trigger`, not `content` the
248
+ // dedup gate must compare the SAME text the stored vector represents, or a
249
+ // skill's dedup cosine would cross the trigger-space vector against
250
+ // content-space candidates. Non-skill rows are byte-identical (skillEmbedText
251
+ // returns `content`).
252
+ const embedText = skillEmbedText(content);
253
+ if (typeof embedText !== "string" || embedText.length < DEDUP_MIN_CONTENT_LENGTH) {
220
254
  return null;
221
255
  }
222
256
  // flair#504 Phase 2: 'document' — this embedding IS the stored vector (the
@@ -241,7 +275,7 @@ async function runDedupGate(ctx, content) {
241
275
  let embedding = Array.isArray(content.embedding) ? content.embedding : null;
242
276
  if (!embedding) {
243
277
  try {
244
- embedding = await getEmbedding(content.content, "document");
278
+ embedding = await getEmbedding(embedText, "document");
245
279
  }
246
280
  catch {
247
281
  embedding = null;
@@ -253,7 +287,7 @@ async function runDedupGate(ctx, content) {
253
287
  }
254
288
  if (!embedding)
255
289
  return null;
256
- return findConservativeDedupMatch(ctx, content.agentId, content.content, embedding, cosineThreshold, lexicalThreshold);
290
+ return findConservativeDedupMatch(ctx, content.agentId, embedText, embedding, cosineThreshold, lexicalThreshold);
257
291
  }
258
292
  /** Build the final write response: always `written: true`, always includes
259
293
  * `id`, `visibility`, and layers the dedup collision signal on top when
@@ -482,7 +516,7 @@ export class Memory extends databases.flair.Memory {
482
516
  * allowCreate/allowUpdate/allowDelete are deliberately NOT added here:
483
517
  * post()/put()/delete() already self-enforce per-agent ownership inline
484
518
  * (resolveAgentAuth + explicit agentId checks in post()/put(), and the
485
- * isAdmin durability check in delete()). Adding allow* on top of that,
519
+ * stored-owner check in delete()). Adding allow* on top of that,
486
520
  * unverified, risks regressing owner writes/deletes on a P0 security fix
487
521
  * that is scoped to the read leak — left as-is on purpose.
488
522
  */
@@ -523,7 +557,9 @@ export class Memory extends databases.flair.Memory {
523
557
  // returned untouched (attachTrust returns the same reference) ⇒
524
558
  // byte-identical to pre-slice-1.
525
559
  if (result && typeof result === "object" && !(result instanceof Response) && typeof result.agentId === "string") {
526
- return attachTrust(result, wantsTrust(target, opts));
560
+ const ctx = this.getContext?.();
561
+ const withHits = await applyHitStats(result, ctx);
562
+ return attachTrust(withHits, wantsTrust(target, opts));
527
563
  }
528
564
  return result;
529
565
  }
@@ -550,7 +586,7 @@ export class Memory extends databases.flair.Memory {
550
586
  if (gate.kind === "denied")
551
587
  return gate.response;
552
588
  if (gate.kind === "unfiltered")
553
- return super.search(query);
589
+ return overlayHitStatsResult(super.search(query), ctx);
554
590
  // Non-admin agent: scope to own (any visibility) + granted owners' SHARED
555
591
  // memories only (Layer 1 private-exclusion). Centralized in
556
592
  // memoryReadScope (record-type-kit.ts's makeReadScope(), parameterized
@@ -562,9 +598,12 @@ export class Memory extends databases.flair.Memory {
562
598
  // makeScopedSearch (record-type-kit.ts) — same correct composition
563
599
  // MemoryCandidate.search() already applies — so a caller-supplied
564
600
  // `operator: "or"` cannot boolean-inject past the owner scope.
565
- return memoryScopedSearch(gate.agentId, query, (q) => withDetachedTxn(ctx, () => super.search(q)));
601
+ return overlayHitStatsResult(memoryScopedSearch(gate.agentId, query, (q) => withDetachedTxn(ctx, () => super.search(q))), ctx);
566
602
  }
567
603
  async post(content, context) {
604
+ const authorityDenial = await guardAuthorityFields(() => super.get(), content, "Memory");
605
+ if (authorityDenial)
606
+ return authorityDenial;
568
607
  // Rate limiting — use authenticated agent ID, not client-supplied body field
569
608
  const ctx = this.getContext?.();
570
609
  const authenticatedAgent = ctx?.request?.tpsAgent;
@@ -619,6 +658,18 @@ export class Memory extends databases.flair.Memory {
619
658
  }
620
659
  }
621
660
  content.durability ||= "standard";
661
+ // ── flair#1542: skills are forced durability=persistent ──
662
+ // A skill-tagged write must never be reaped by the 30-day reaper (the
663
+ // "standard" default) nor expire (ephemeral/session). enforceSkillDurability
664
+ // rejects ephemeral/session outright and forces every other value to
665
+ // "persistent" — placed AFTER the default so it sees the effective tier,
666
+ // and BEFORE the visibility default below so a skill lands on the
667
+ // persistent→shared branch, not the standard→private one.
668
+ {
669
+ const skillDurabilityDenial = enforceSkillDurability(content);
670
+ if (skillDurabilityDenial)
671
+ return skillDurabilityDenial;
672
+ }
622
673
  // ── flair#1336: honor a caller-supplied createdAt (parity with put()) ──
623
674
  // put() — the other HTTP-reachable create path — has always preserved the
624
675
  // caller's createdAt (`content.createdAt ?? now`), and adk-flair's
@@ -637,8 +688,8 @@ export class Memory extends databases.flair.Memory {
637
688
  content.updatedAt = nowIso;
638
689
  content.archived = content.archived ?? false;
639
690
  // ─── Default visibility (durability-keyed) — Layer 1, part A ────────────
640
- // post() only ever creates a NEW record — patchRecord/supersede-close/
641
- // retrievalCount bumps all route through put() instead (see put()'s
691
+ // post() only ever creates a NEW record — patchRecord/supersede-close
692
+ // route through put() instead (see put()'s
642
693
  // pre-existing-record guard below), so there is no "don't overwrite an
643
694
  // existing record's visibility" concern here. Explicit visibility on the
644
695
  // write ALWAYS overrides; only stamp the default when the caller left it
@@ -730,6 +781,16 @@ export class Memory extends databases.flair.Memory {
730
781
  content._safetyFlags = safety.flags;
731
782
  }
732
783
  }
784
+ // ── flair#1542: SkillScan gate BEFORE the embed ──
785
+ // Every skill-tagged write is statically scanned (shell/network/fs/env/
786
+ // encoding/unicode) BEFORE any embedding is computed, so a rejected write
787
+ // pays no embed. Fail-closed on high/critical; allow-with-flag on medium.
788
+ // Non-skill writes are a no-op (skillScanGate returns null).
789
+ {
790
+ const skillScanDenial = skillScanGate(content);
791
+ if (skillScanDenial)
792
+ return skillScanDenial;
793
+ }
733
794
  // Server-side conservative-duplicate gate (memory-integrity fix). A
734
795
  // supersede write is an intentional version-link, not an ambiguous "is
735
796
  // this a duplicate of something else" situation — bypass the gate for it
@@ -747,9 +808,11 @@ export class Memory extends databases.flair.Memory {
747
808
  // Generate embedding from content text (no-op if the dedup gate above
748
809
  // already computed one for this content). flair#504 Phase 2: 'document'
749
810
  // — see runDedupGate's comment above for why all three Memory doc sites
750
- // must move together.
751
- if (content.content && !content.embedding) {
752
- const vec = await getEmbedding(content.content, "document");
811
+ // must move together. flair#1542: skill-tagged rows embed from `trigger`
812
+ // (skillEmbedText), not `content`.
813
+ const embedText = skillEmbedText(content);
814
+ if (embedText && !content.embedding) {
815
+ const vec = await getEmbedding(embedText, "document");
753
816
  if (vec) {
754
817
  content.embedding = vec;
755
818
  content.embeddingModel = getModelId();
@@ -776,6 +839,11 @@ export class Memory extends databases.flair.Memory {
776
839
  // synchronous hook is what makes a store immediately searchable rather
777
840
  // than searchable-after-the-feed-turns.
778
841
  noteMemoryUpsert(content);
842
+ // embedding-space-guard slice 1: keep the write-maintained latch current —
843
+ // a persisted FOREIGN stamp (federation / replication / an explicit-stamp
844
+ // write) trips the gate; a normal local write stamps the current id and
845
+ // never does.
846
+ noteWriteStamp(content?.embeddingModel);
779
847
  // ── THEN close the superseded record ────────────────────────────────────
780
848
  // Write-new-BEFORE-close-old: the previous order (close-old via a fire-
781
849
  // and-forget `.catch(()=>{})` BEFORE the new write) could tombstone the
@@ -799,7 +867,43 @@ export class Memory extends databases.flair.Memory {
799
867
  }
800
868
  return buildWriteResponse(content, result, dedupMatch);
801
869
  }
870
+ // PATCH routes past put(), so agentId immutability is enforced on both verbs
871
+ // via the one shared delegate. (Admin/internal — including the _reindex
872
+ // path in put() — pass through the delegate untouched.)
873
+ async patch(content, query) {
874
+ const authorityDenial = await guardAuthorityFields(() => super.get(), content, "Memory");
875
+ if (authorityDenial)
876
+ return authorityDenial;
877
+ const denial = await guardOwnerFieldImmutable(this, () => super.get(), content, "agentId");
878
+ if (denial)
879
+ return denial;
880
+ // ── flair#1542 + residual (Kern #1543 review 5135715289): reject skill patches ──
881
+ // patch() routes past put() (and thus past the SkillScan gate + forced
882
+ // durability), so a skill write on this verb would land unscanned. There are
883
+ // TWO ways a patch is a skill write, and the mint-time check saw only the first:
884
+ // 1. the PATCH BODY carries the skill tag — rejectSkillWritePath(content).
885
+ // 2. the STORED record is ALREADY a skill, and the body mutates `content`
886
+ // (or anything else) WITHOUT re-declaring the tag. isSkillWrite(body) is
887
+ // then false, so the pre-residual check let the edit land — a skill's
888
+ // procedure could be rewritten with NO SkillScan (the residual). Fold
889
+ // the existing record into the check: a patch to a row whose STORED tags
890
+ // include `skill` is rejected the same as a mint-time skill write.
891
+ // Skills are written via skill_store (→ Memory.post) or Memory.put; no
892
+ // memory_patch tool exists and no internal path patches a skill row (hit-
893
+ // tracking goes through table.put, not this override), so rejecting is safe.
894
+ const existingForSkill = (await Promise.resolve(super.get()).catch(() => null));
895
+ const skillDenial = rejectSkillWritePath(content) ?? rejectSkillWritePath(existingForSkill);
896
+ if (skillDenial)
897
+ return skillDenial;
898
+ return super.patch(content, query);
899
+ }
802
900
  async put(content) {
901
+ const authorityDenial = await guardAuthorityFields(() => super.get(), content, "Memory");
902
+ if (authorityDenial)
903
+ return authorityDenial;
904
+ const __ownerDenial = await guardOwnerFieldImmutable(this, () => super.get(), content, "agentId");
905
+ if (__ownerDenial)
906
+ return __ownerDenial;
803
907
  // Reindex migration bypass: admin-only escape hatch used by the
804
908
  // MemoryReindex admin endpoint to re-PUT each existing record byte-for-byte
805
909
  // (no updatedAt bump, no embedding regen, no safety rescan) so Harper
@@ -819,6 +923,7 @@ export class Memory extends databases.flair.Memory {
819
923
  delete content._reindex;
820
924
  const reindexed = await super.put(content);
821
925
  noteMemoryUpsert(content);
926
+ noteWriteStamp(content?.embeddingModel); // embedding-space-guard slice 1 (see post())
822
927
  return reindexed;
823
928
  }
824
929
  // Create/update ownership (same rule as post): a non-admin agent may only
@@ -858,6 +963,16 @@ export class Memory extends databases.flair.Memory {
858
963
  return new Response(JSON.stringify({ error: "invalid_durability", message: durabilityError }), { status: 400, headers: { "content-type": "application/json" } });
859
964
  }
860
965
  }
966
+ // ── flair#1542: skills are forced durability=persistent (mirrors post()) ──
967
+ // put() stamps no durability default (updates carry the pre-existing tier),
968
+ // so this runs on the raw write value: a skill-tagged write with an explicit
969
+ // ephemeral/session tier is rejected, and every other value (including an
970
+ // absent one) is forced to "persistent" so the reaper never archives a skill.
971
+ {
972
+ const skillDurabilityDenial = enforceSkillDurability(content);
973
+ if (skillDurabilityDenial)
974
+ return skillDurabilityDenial;
975
+ }
861
976
  const now = new Date().toISOString();
862
977
  content.updatedAt = now;
863
978
  // Set defaults that post() sets — put() is also used for new records via CLI
@@ -869,7 +984,7 @@ export class Memory extends databases.flair.Memory {
869
984
  // untouched). See the dedup-gate block further down for why an existing
870
985
  // id skips the gate; the SAME "does a record already exist" check gates
871
986
  // the visibility default (Layer 1 part A): patchRecord/supersede-
872
- // close/retrievalCount bumps all route through put() with a MERGED
987
+ // close all route through put() with a MERGED
873
988
  // `{...existing, ...patch}` payload, and must never have their stored
874
989
  // visibility overwritten by a default recomputed from that merged content
875
990
  // — only a genuinely NEW id gets the default stamped.
@@ -973,6 +1088,15 @@ export class Memory extends databases.flair.Memory {
973
1088
  content._safetyFlags = null;
974
1089
  }
975
1090
  }
1091
+ // ── flair#1542: SkillScan gate BEFORE the embed (mirrors post()) ──
1092
+ // Every skill-tagged write is statically scanned before any embedding is
1093
+ // computed, so a rejected write pays no embed. Fail-closed on high/critical;
1094
+ // allow-with-flag on medium. Non-skill writes are a no-op.
1095
+ {
1096
+ const skillScanDenial = skillScanGate(content);
1097
+ if (skillScanDenial)
1098
+ return skillScanDenial;
1099
+ }
976
1100
  // Server-side conservative-duplicate gate (memory-integrity fix). PUT is
977
1101
  // an upsert: only run the gate for a FRESH create (target id does not yet
978
1102
  // exist) that is NOT a supersede-link write. An update of an EXISTING id
@@ -1004,9 +1128,11 @@ export class Memory extends databases.flair.Memory {
1004
1128
  // already computed one for this content). flair#504 Phase 2: 'document'
1005
1129
  // — this is also the regen branch `flair reembed` triggers (clears
1006
1130
  // embedding/embeddingModel then hits this put()), so it's what actually
1007
- // re-embeds a stale row WITH the prefix once stage 2 runs.
1008
- if (content.content && !content.embedding) {
1009
- const vec = await getEmbedding(content.content, "document");
1131
+ // re-embeds a stale row WITH the prefix once stage 2 runs. flair#1542:
1132
+ // skill-tagged rows embed from `trigger` (skillEmbedText), not `content`.
1133
+ const embedText = skillEmbedText(content);
1134
+ if (embedText && !content.embedding) {
1135
+ const vec = await getEmbedding(embedText, "document");
1010
1136
  if (vec) {
1011
1137
  content.embedding = vec;
1012
1138
  content.embeddingModel = getModelId();
@@ -1017,24 +1143,6 @@ export class Memory extends databases.flair.Memory {
1017
1143
  content.archivedAt = now;
1018
1144
  // archivedBy should be set by the caller (CLI stamps req.tpsAgent via query param)
1019
1145
  }
1020
- // If approving promotion, record timestamp
1021
- if (content.promotionStatus === "approved" && !content.promotedAt) {
1022
- content.promotedAt = now;
1023
- }
1024
- // Upgrade to permanent when approved — the LEGACY in-place approval flow
1025
- // (an admin marks an EXISTING row approved without naming a tier; the
1026
- // auth-middleware admin-gates setting promotionStatus over HTTP). An
1027
- // explicit durability on the SAME write now wins (flair#1257 slice 3):
1028
- // the candidate-promotion paths (#1205b-2 /AutoPromoteCandidates and the
1029
- // human `flair rem promote`) write NEW rows carrying promotionStatus:
1030
- // "approved" purely as an audit stamp ALONGSIDE an explicit durability:
1031
- // "persistent" — the unconditional coercion here silently lifted every
1032
- // promoted claim into the never-reaped permanent tier while every audit
1033
- // surface (CLI output, specs, review rulings) said persistent. A write
1034
- // that names its tier keeps it; only a tier-less approval still upgrades.
1035
- if (content.promotionStatus === "approved" && (content.durability === undefined || content.durability === null)) {
1036
- content.durability = "permanent";
1037
- }
1038
1146
  // Write-time provenance stamp (memory-provenance slice 1) — see
1039
1147
  // buildProvenance's doc above post(). Applies to every put() (fresh
1040
1148
  // create AND update/patch) — never gated on preExisting, so an update
@@ -1057,6 +1165,7 @@ export class Memory extends databases.flair.Memory {
1057
1165
  const result = await super.put(content);
1058
1166
  // flair#1357 — read-your-write for the lexical leg (see post()).
1059
1167
  noteMemoryUpsert(content);
1168
+ noteWriteStamp(content?.embeddingModel); // embedding-space-guard slice 1 (see post())
1060
1169
  // ── THEN close the superseded record (see post()) ───────────────────────
1061
1170
  await closeSupersededIfNeeded(ctx, content, "put");
1062
1171
  // flair#744 slice A: citation-on-write — POST-COMMIT, fully
@@ -1072,34 +1181,23 @@ export class Memory extends databases.flair.Memory {
1072
1181
  return buildWriteResponse(content, result, dedupMatch);
1073
1182
  }
1074
1183
  async delete(id) {
1075
- // Use super.get(id), NOT this.get(id): the new get() override above 404s
1076
- // (a truthy Response) for a non-owner/non-granted id, which would
1077
- // otherwise short-circuit the `record.durability === "permanent"` check
1078
- // below (a Response has no .durability) and silently bypass the
1079
- // admin-only permanent-delete guard for cross-agent deletes. This keeps
1080
- // delete()'s own pre-existing ownership/admin logic exactly as it was
1081
- // before the read-gate fix — the read-scoping override must not leak
1082
- // into delete()'s internal record lookup.
1184
+ const auth = await resolveAgentAuth(this.getContext?.());
1185
+ if (auth.kind === "anonymous")
1186
+ return UNAUTH();
1187
+ // Read stored ownership, not the read-scoped get() response. Enforce here
1188
+ // as well as middleware so MCP/in-process callers have the same policy.
1083
1189
  const record = await super.get(id);
1084
- if (!record) {
1085
- const gone = await super.delete(id);
1086
- noteMemoryDelete(id);
1087
- return gone;
1088
- }
1089
- if (record.durability === "permanent") {
1090
- // Middleware already guards this for non-admins, but belt-and-suspenders
1091
- const ctx = this.getContext?.();
1092
- const request = ctx?.request ?? ctx;
1093
- const actorId = request?.tpsAgent;
1094
- if (actorId && !(await isAdmin(actorId))) {
1095
- return new Response(JSON.stringify({ error: "permanent_memory_cannot_be_deleted_by_non_admin" }), {
1096
- status: 403,
1097
- headers: { "Content-Type": "application/json" },
1098
- });
1099
- }
1190
+ if (auth.kind === "agent" && !auth.isAdmin &&
1191
+ isForbiddenOwnerMutation(record, RECORD_TYPES.Memory.ownerField, auth.agentId)) {
1192
+ return FORBIDDEN("forbidden: cannot delete memory owned by another agent");
1100
1193
  }
1194
+ // Durability controls retention, not the owner's authority to delete.
1101
1195
  const deleted = await super.delete(id);
1102
1196
  noteMemoryDelete(id);
1197
+ const deletedId = typeof id === "string" ? id : record?.id;
1198
+ if (typeof deletedId === "string" && deletedId.length > 0) {
1199
+ await clearHitStats(deletedId, this.getContext?.()).catch(() => { });
1200
+ }
1103
1201
  return deleted;
1104
1202
  }
1105
1203
  }
@@ -79,7 +79,7 @@ import { estimateTokens } from "./token-estimate.js";
79
79
  * memoriesTruncated, teammateFindingsIncluded, teammateFindingsTruncated,
80
80
  * teammateFindingsMatched, agentId, scope, soul, memories, predicted,
81
81
  * teammateFindings, events, soulTokens, memoryTokens, trustTokens,
82
- * eventsTokens, scaffoldTokens[, currentTaskHint][, predictedHint] }
82
+ * eventsTokens, scaffoldTokens[, currentTaskHint][, taskRetrievalHint][, predictedHint] }
83
83
  *
84
84
  * TOKEN LEDGER (flair#1270): the counters decompose `tokenEstimate` from the
85
85
  * payload alone —
@@ -282,7 +282,7 @@ export class BootstrapMemories extends Resource {
282
282
  return allowVerified(this.getContext?.());
283
283
  }
284
284
  async post(data, _context) {
285
- const { agentId: bodyAgentId, currentTask, maxTokens = 4000, includeSoul = true, since, channel, // e.g., "discord", "tps-mail", "claude-code"
285
+ const { agentId: bodyAgentId, currentTask: rawCurrentTask, maxTokens = 4000, includeSoul = true, since, channel, // e.g., "discord", "tps-mail", "claude-code"
286
286
  surface, // e.g., "tps-build", "tps-review", "cli-session"
287
287
  subjects, // e.g., ["flair", "auth"] — entities to preload context for
288
288
  includeTrust = false, // flair#744 slice 1 — opt-in per-memory trust block
@@ -306,6 +306,7 @@ export class BootstrapMemories extends Resource {
306
306
  // bodies twice. When false, `context` is a compact structural pointer (no
307
307
  // bodies), so nothing crosses the wire twice on that path.
308
308
  includeContext = true, } = data || {};
309
+ const currentTask = typeof rawCurrentTask === "string" ? rawCurrentTask.trim() : "";
309
310
  // Authenticated identity lives on getContext().request — `this.request` is
310
311
  // NOT populated on Harper v5 Resources. Reading it returned undefined and
311
312
  // the scope check was silently bypassed, letting a non-admin agent read
@@ -685,7 +686,14 @@ export class BootstrapMemories extends Resource {
685
686
  // any memory cover your current task", not the whole session load). Stays
686
687
  // null when there's no currentTask / no embedding ⇒ never abstains.
687
688
  let taskBestSimilarity = null;
688
- // --- 2. Permanent memories (always included, highest priority) ---
689
+ // Protect task recall from pinned/recent admission. Soul keeps its existing
690
+ // priority; unused task space is returned to permanent memories below.
691
+ const taskReserve = currentTask
692
+ ? Math.max(0, Math.min(tokenBudget, Math.floor(maxTokens * 0.3)))
693
+ : 0;
694
+ tokenBudget -= taskReserve;
695
+ let taskRetrievalHint;
696
+ // --- 2. Permanent memories ---
689
697
  // Own-scoped pushdown: `agentId==self` + `durability==permanent`, both
690
698
  // @indexed (a seek, not a scan) — strictly narrower than the prior
691
699
  // load-then-filter (own records are always visible to their own agent
@@ -731,7 +739,7 @@ export class BootstrapMemories extends Resource {
731
739
  if (m.supersedes)
732
740
  permanentSupersededIds.add(m.supersedes);
733
741
  const permanent = permanentRows.filter((m) => !permanentSupersededIds.has(m.id));
734
- for (const m of permanent) {
742
+ const admitPermanent = (m) => {
735
743
  const line = formatMemory(m, agentId);
736
744
  const struct = leanMemory(m, "permanent");
737
745
  // #1199 (0.44.11) — charge what SHIPS (structured on the /mcp path, prose
@@ -754,7 +762,9 @@ export class BootstrapMemories extends Resource {
754
762
  else {
755
763
  truncatedOwnIds.add(m.id); // #1207 — budget-skip, deduped against inclusions at the end
756
764
  }
757
- }
765
+ };
766
+ for (const m of permanent)
767
+ admitPermanent(m);
758
768
  // --- 3. Recent memories (adaptive window) ---
759
769
  // Own-scoped, non-permanent, bounded + createdAt-desc pushdown (agentId
760
770
  // and durability are both @indexed) — replaces the org-wide load's
@@ -925,7 +935,10 @@ export class BootstrapMemories extends Resource {
925
935
  // cross-agent hits.
926
936
  const semanticTeammateMatches = [];
927
937
  // --- 4. Task-relevant memories (semantic search) ---
928
- if (currentTask && tokenBudget > 200) {
938
+ tokenBudget += taskReserve;
939
+ if (currentTask && tokenBudget <= 0)
940
+ taskRetrievalHint = "Task retrieval skipped: no content budget remains.";
941
+ if (currentTask && tokenBudget > 0) {
929
942
  let queryEmbedding = null;
930
943
  try {
931
944
  // flair#504 Phase 2: 'query' — currentTask is the bootstrap's
@@ -933,6 +946,8 @@ export class BootstrapMemories extends Resource {
933
946
  queryEmbedding = await getEmbedding(currentTask, "query");
934
947
  }
935
948
  catch { }
949
+ if (!queryEmbedding)
950
+ taskRetrievalHint = "Task retrieval skipped: query embedding unavailable.";
936
951
  if (queryEmbedding) {
937
952
  // flair#1207 — exclude own memories ALREADY placed via the authoritative
938
953
  // set (permanent + recent + predicted actually admitted). The old set was
@@ -1033,6 +1048,8 @@ export class BootstrapMemories extends Resource {
1033
1048
  // (+ legacy keyword bump) per #985/#1267 — for display/reporting;
1034
1049
  // ORDER and score can legitimately disagree (a BM25 rank-1 rescue
1035
1050
  // outranks higher-cosine bland hits, which is the recall win).
1051
+ if (candidates.length === 0)
1052
+ taskRetrievalHint = "Task retrieval found no visible active candidates.";
1036
1053
  const scored = candidates
1037
1054
  .filter((m) => !includedIds.has(m.id))
1038
1055
  .map((m) => ({ memory: m, score: m._score }));
@@ -1130,6 +1147,15 @@ export class BootstrapMemories extends Resource {
1130
1147
  }
1131
1148
  }
1132
1149
  }
1150
+ if (currentTask) {
1151
+ if (!taskRetrievalHint && sections.relevant.length + sections.teammate.length === 0) {
1152
+ taskRetrievalHint = "Task candidates were already included or did not fit the remaining content budget.";
1153
+ }
1154
+ for (const m of permanent) {
1155
+ if (!includedOwnIds.has(m.id))
1156
+ admitPermanent(m);
1157
+ }
1158
+ }
1133
1159
  // --- 4c. Collision surfacing (flair#681 — "others in the room") ---
1134
1160
  // Joins two independently-scoped surfaces into a single ranked list:
1135
1161
  // - Entity overlap (WorkspaceState + OrgEvent): exact vocabulary-string
@@ -1157,21 +1183,30 @@ export class BootstrapMemories extends Resource {
1157
1183
  ? data.entities.filter((e) => isValidEntity(e))
1158
1184
  : [];
1159
1185
  if (callerEntities.length === 0) {
1160
- const ownRows = withDetachedTxn(ctx, () => databases.flair.WorkspaceState.search({
1161
- conditions: [{ attribute: "agentId", comparator: "equals", value: agentId }],
1162
- select: ["entities", "timestamp"],
1163
- }));
1164
- let latestEntities = [];
1165
- let latestTs = "";
1166
- for await (const row of ownRows) {
1167
- if (!Array.isArray(row.entities) || row.entities.length === 0)
1168
- continue;
1169
- if ((row.timestamp || "") > latestTs) {
1170
- latestTs = row.timestamp || "";
1171
- latestEntities = row.entities;
1186
+ // Try the indexed collision window before older workspace history;
1187
+ // empty rows must not hide an older entity-bearing row.
1188
+ // Only fall back to older history when the recent window has none.
1189
+ const workspaceSince = new Date(Date.now() - COLLISION_WINDOW_DAYS * 24 * 3600_000).toISOString();
1190
+ for (const comparator of ["greater_than_equal", "less_than"]) {
1191
+ const ownRows = withDetachedTxn(ctx, () => databases.flair.WorkspaceState.search({
1192
+ conditions: [
1193
+ { attribute: "agentId", comparator: "equals", value: agentId },
1194
+ { attribute: "timestamp", comparator, value: workspaceSince },
1195
+ ],
1196
+ select: ["entities", "timestamp"],
1197
+ }));
1198
+ let latestTs = "";
1199
+ for await (const row of ownRows) {
1200
+ if (!Array.isArray(row.entities) || row.entities.length === 0)
1201
+ continue;
1202
+ if ((row.timestamp || "") > latestTs) {
1203
+ latestTs = row.timestamp || "";
1204
+ callerEntities = row.entities;
1205
+ }
1172
1206
  }
1207
+ if (callerEntities.length > 0)
1208
+ break;
1173
1209
  }
1174
- callerEntities = latestEntities;
1175
1210
  }
1176
1211
  const entityMatches = [];
1177
1212
  if (callerEntities.length > 0) {
@@ -1265,7 +1300,14 @@ export class BootstrapMemories extends Resource {
1265
1300
  : new Date(Date.now() - 24 * 3600_000);
1266
1301
  const eventSinceStr = eventSince.toISOString();
1267
1302
  const eventResults = [];
1268
- for await (const event of databases.flair.OrgEvent.search()) {
1303
+ // Seek the indexed lookback window before materializing events. Keep detail
1304
+ // for no-op suppression/dedup even when the response omits it. No limit here:
1305
+ // expiry, targeting, dedup and budget admission must run before the cap.
1306
+ const recentEvents = withDetachedTxn(ctx, () => databases.flair.OrgEvent.search({
1307
+ conditions: [{ attribute: "createdAt", comparator: "greater_than_equal", value: eventSinceStr }],
1308
+ select: ["id", "kind", "summary", "detail", "targetIds", "createdAt", "expiresAt", "scope"],
1309
+ }));
1310
+ for await (const event of recentEvents) {
1269
1311
  if (!event.createdAt || event.createdAt < eventSinceStr)
1270
1312
  continue;
1271
1313
  if (event.expiresAt && new Date(event.expiresAt) < new Date())
@@ -1599,6 +1641,7 @@ export class BootstrapMemories extends Resource {
1599
1641
  // own soul/memories/predicted as structured containers (empty `{}`/`[]`,
1600
1642
  // never absent, so "empty" is distinguishable from "unsupported").
1601
1643
  agentId,
1644
+ ...(taskRetrievalHint ? { taskRetrievalHint } : {}),
1602
1645
  scope: scopeInfo,
1603
1646
  soul: soulMap,
1604
1647
  memories: includedOwnMemories,
@@ -1,5 +1,6 @@
1
1
  import { databases } from "harper";
2
2
  import { resolveAgentAuth } from "./agent-auth.js";
3
+ import { guardOwnerFieldImmutable } from "./owner-field-guard.js";
3
4
  import { makeAuthGate, makeReadScope, makeByIdReadGate, resolveAuthGate, stampAttribution, FORBIDDEN, UNAUTH, } from "./record-type-kit.js";
4
5
  import { RECORD_TYPES } from "./record-types.js";
5
6
  // Parameterized from RECORD_TYPES.MemoryCandidate (record-types slice 2,
@@ -134,7 +135,18 @@ export class MemoryCandidate extends databases.flair.MemoryCandidate {
134
135
  * agent's candidate) but never stamps one in — the promote/reject flow
135
136
  * always carries the original agentId forward untouched.
136
137
  */
138
+ // PATCH routes past put(), so agentId immutability is enforced on both verbs
139
+ // via the one shared delegate.
140
+ async patch(content, query) {
141
+ const denial = await guardOwnerFieldImmutable(this, () => super.get(), content, "agentId");
142
+ if (denial)
143
+ return denial;
144
+ return super.patch(content, query);
145
+ }
137
146
  async put(content) {
147
+ const __ownerDenial = await guardOwnerFieldImmutable(this, () => super.get(), content, "agentId");
148
+ if (__ownerDenial)
149
+ return __ownerDenial;
138
150
  const ctx = this.getContext?.();
139
151
  const auth = await resolveAgentAuth(ctx);
140
152
  if (auth.kind === "anonymous")
@@ -18,6 +18,7 @@
18
18
  import { Resource, databases } from "harper";
19
19
  import { isAdmin, allowVerified } from "./agent-auth.js";
20
20
  import { evaluate, parseDuration } from "./memory-consolidate-lib.js";
21
+ import { applyHitStats } from "./hit-tracking.js";
21
22
  export class ConsolidateMemories extends Resource {
22
23
  // Self-authorize via the Ed25519 agent verify (auth reshape removes the gate's
23
24
  // admin elevation). Any verified agent may consolidate; the isAdmin checks in
@@ -56,7 +57,7 @@ export class ConsolidateMemories extends Resource {
56
57
  continue;
57
58
  if (scope === "standard" && record.durability !== "standard")
58
59
  continue;
59
- const candidate = evaluate(record, now, olderThanMs);
60
+ const candidate = evaluate(await applyHitStats(record, ctx), now, olderThanMs);
60
61
  candidates.push(candidate);
61
62
  if (candidates.length >= limit * 3)
62
63
  break; // over-fetch to sort