@tpsdev-ai/flair 0.51.1 → 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.
- package/README.md +10 -5
- package/dist/build-info.json +3 -3
- package/dist/cli.js +575 -547
- package/dist/doctor-client.js +35 -0
- package/dist/hook-install.js +74 -0
- package/dist/install/global-bin-path.js +14 -0
- package/dist/lib/auth-resolve.js +15 -0
- package/dist/lib/doctor-run.js +28 -15
- package/dist/lib/upgrade-exec-path.js +257 -0
- package/dist/lib/upgrade-plain-tree.js +558 -0
- package/dist/rem/promote-policy.js +204 -0
- package/dist/rem/restore.js +55 -15
- package/dist/rem/runner.js +203 -20
- package/dist/resources/AdminMemory.js +2 -1
- package/dist/resources/AgentSeed.js +26 -10
- package/dist/resources/Asset.js +203 -0
- package/dist/resources/AutoPromoteCandidates.js +2 -4
- package/dist/resources/Credential.js +14 -0
- package/dist/resources/Federation.js +80 -0
- package/dist/resources/Integration.js +12 -0
- package/dist/resources/Memory.js +158 -60
- package/dist/resources/MemoryBootstrap.js +63 -20
- package/dist/resources/MemoryCandidate.js +12 -0
- package/dist/resources/MemoryConsolidate.js +2 -1
- package/dist/resources/MemoryDedupStats.js +17 -2
- package/dist/resources/MemoryFeed.js +30 -0
- package/dist/resources/MemoryGrant.js +14 -0
- package/dist/resources/MemoryReflect.js +75 -17
- package/dist/resources/Message.js +190 -0
- package/dist/resources/OrgEvent.js +12 -0
- package/dist/resources/PromoteMemoryCandidate.js +76 -0
- package/dist/resources/RecordUsage.js +1 -1
- package/dist/resources/Relationship.js +12 -0
- package/dist/resources/SemanticSearch.js +45 -13
- package/dist/resources/Soul.js +54 -18
- package/dist/resources/WorkspaceState.js +12 -0
- package/dist/resources/auth-middleware.js +17 -44
- package/dist/resources/authority-field-guard.js +37 -0
- package/dist/resources/bm25-index-service.js +1 -1
- package/dist/resources/bm25-index.js +50 -11
- package/dist/resources/embedding-space-guard.js +238 -0
- package/dist/resources/embeddings-provider.js +32 -5
- package/dist/resources/federation-classify.js +23 -1
- package/dist/resources/health.js +11 -2
- package/dist/resources/hit-tracking.js +244 -0
- package/dist/resources/mcp-tools.js +272 -7
- package/dist/resources/memory-reflect-lib.js +111 -0
- package/dist/resources/migrations/embedding-stamp.js +22 -4
- package/dist/resources/owner-field-guard.js +62 -0
- package/dist/resources/promotion-stamp.js +29 -0
- package/dist/resources/record-owner-guard.js +71 -5
- package/dist/resources/record-types.js +30 -7
- package/dist/resources/relay-lib.js +205 -0
- package/dist/resources/relay-ops.js +294 -0
- package/dist/resources/skill-write.js +120 -0
- package/dist/resources/soul-adk-guard.js +68 -0
- package/dist/resources/soul-write-policy.js +63 -0
- package/dist/resources/table-helpers.js +2 -0
- package/dist/resources/usage-recording.js +3 -3
- package/dist/src/rem/promote-policy.js +204 -0
- package/docs/api-reference.md +374 -0
- package/docs/auth.md +52 -0
- package/docs/federation.md +4 -0
- package/docs/integrations.md +6 -6
- package/docs/mcp-clients.md +16 -1
- package/docs/releasing.md +11 -8
- package/docs/rem.md +20 -2
- package/docs/upgrade.md +47 -2
- package/package.json +13 -8
- package/schemas/memory.graphql +51 -2
- package/schemas/message.graphql +74 -0
package/dist/resources/Memory.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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,
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
752
|
-
|
|
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
|
|
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
|
-
|
|
1009
|
-
|
|
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
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
//
|
|
1079
|
-
//
|
|
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 (!
|
|
1085
|
-
|
|
1086
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
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
|
-
|
|
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
|