From f0fd2a91b35457002f7ff8f33d32230b041af1cc Mon Sep 17 00:00:00 2001 From: Srinath Date: Sat, 13 Jun 2026 20:04:07 -0400 Subject: [PATCH 1/2] feat(search): add temporal decay to recall ranking Recall fuses BM25 + vector + graph into a single RRF relevance score with no notion of time, so an equally-relevant note from this morning and one from a year ago rank identically. This adds an opt-in temporal decay reweight that blends an exponential recency factor (and optional importance term) into the relevance score, so fresh and reinforced memories surface ahead of equally-relevant stale ones. Design: - Exponential decay parameterized by a configurable HALF-LIFE (days), the interpretable forgetting-curve knob. - Multiplicative reweight of the (small, unnormalized) RRF score rather than additive, so relevance stays the dominant signal. - A floor on the multiplier so decay demotes but never erases an old-but-highly-relevant hit. - "Use it or lose it": effective age is measured from the later of creation or last access (reuses the existing AccessLog), so recall refreshes recency. - Importance slows decay, mirroring the Generative Agents importance term. OFF by default (AGENTMEMORY_TEMPORAL_DECAY=true), matching the project's opt-in posture for changes that alter recall ordering. When disabled the reweight short-circuits with zero added cost. Adds src/functions/temporal-decay.ts (pure, fully unit-tested), config getters + safeParseFloat, HybridSearch integration, and .env.example docs. Co-Authored-By: Claude Opus 4.8 Signed-off-by: Srinath --- .env.example | 5 + src/config.ts | 54 ++++++++++ src/functions/temporal-decay.ts | 181 ++++++++++++++++++++++++++++++++ src/index.ts | 17 +++ src/state/hybrid-search.ts | 64 ++++++++++- test/temporal-decay.test.ts | 173 ++++++++++++++++++++++++++++++ 6 files changed, 489 insertions(+), 5 deletions(-) create mode 100644 src/functions/temporal-decay.ts create mode 100644 test/temporal-decay.test.ts diff --git a/.env.example b/.env.example index 77ca0f3a3..20cff3a67 100644 --- a/.env.example +++ b/.env.example @@ -96,6 +96,11 @@ # BM25_WEIGHT=0.4 # Hybrid search weight for BM25 leg # VECTOR_WEIGHT=0.6 # Hybrid search weight for vector leg # AGENTMEMORY_GRAPH_WEIGHT=0.2 # Graph traversal bonus on smart-search ranking +# AGENTMEMORY_TEMPORAL_DECAY=true # Blend a recency factor into recall ranking so fresh/reinforced memories outrank equally-relevant stale ones. Default off (changes recall ordering). +# AGENTMEMORY_TEMPORAL_DECAY_HALF_LIFE_DAYS=14 # Days after which an un-accessed memory's recency weight halves. Recall refreshes the clock (use-it-or-lose-it). +# AGENTMEMORY_TEMPORAL_DECAY_RECENCY_WEIGHT=0.5 # Blend weight for the recency factor [0,1]. Higher = time dominates ranking. +# AGENTMEMORY_TEMPORAL_DECAY_IMPORTANCE_WEIGHT=0.2 # Blend weight for importance [0,1]. Higher = important memories resist decay. recency+importance should stay <= 1. +# AGENTMEMORY_TEMPORAL_DECAY_FLOOR=0.2 # Minimum decay multiplier [0,1]. Decay demotes but never erases: a stale hit keeps at least this fraction of its relevance. # TOKEN_BUDGET=2000 # Max tokens injected via mem::context per session # MAX_OBS_PER_SESSION=500 # Per-session observation cap before consolidation kicks in # SUMMARIZE_CHUNK_SIZE=400 # When mem::summarize sees a session larger than this, it chunks observations and map-reduces (chunk-summarize → reduce-merge) to stay within the LLM's context window. Default 400 ≈ 50k tokens per chunk at ~110 tok/obs. Native sessions are capped by MAX_OBS_PER_SESSION; chunking primarily matters for bulk-imported jsonl sessions, which bypass that cap. diff --git a/src/config.ts b/src/config.ts index f68da2e31..e91fed629 100644 --- a/src/config.ts +++ b/src/config.ts @@ -16,6 +16,12 @@ function safeParseInt(value: string | undefined, fallback: number): number { return Number.isNaN(parsed) ? fallback : parsed; } +function safeParseFloat(value: string | undefined, fallback: number): number { + if (!value) return fallback; + const parsed = parseFloat(value); + return Number.isFinite(parsed) ? parsed : fallback; +} + const DATA_DIR = join(homedir(), ".agentmemory"); const ENV_FILE = join(DATA_DIR, ".env"); @@ -403,6 +409,54 @@ export function getConsolidationDecayDays(): number { return safeParseInt(getMergedEnv()["CONSOLIDATION_DECAY_DAYS"], 30); } +// Temporal decay for recall ranking (see functions/temporal-decay.ts). +// OFF by default — like auto-compress and context-injection, anything that +// changes recall ordering is opt-in so existing deployments keep their +// current behavior until a user explicitly enables it. Enable with +// AGENTMEMORY_TEMPORAL_DECAY=true. When on, recall blends an exponential +// recency factor (and optional importance term) into the relevance score so +// fresh/reinforced memories outrank equally-relevant stale ones. +export function isTemporalDecayEnabled(): boolean { + const v = getMergedEnv()["AGENTMEMORY_TEMPORAL_DECAY"]; + return v === "true" || v === "1"; +} + +// Recency half-life in days: a memory un-accessed for this long has its +// recency weight halved. Default 14 days — two weeks balances "this week's +// work is hot" against not aggressively forgetting month-old context. +export function getTemporalDecayHalfLifeDays(): number { + return safeParseFloat( + getMergedEnv()["AGENTMEMORY_TEMPORAL_DECAY_HALF_LIFE_DAYS"], + 14, + ); +} + +// Blend weight for the recency factor in [0,1]. Higher = time dominates. +export function getTemporalDecayRecencyWeight(): number { + return safeParseFloat( + getMergedEnv()["AGENTMEMORY_TEMPORAL_DECAY_RECENCY_WEIGHT"], + 0.5, + ); +} + +// Blend weight for importance in [0,1]. Higher = important memories resist +// decay more. recencyWeight + importanceWeight should stay <= 1. +export function getTemporalDecayImportanceWeight(): number { + return safeParseFloat( + getMergedEnv()["AGENTMEMORY_TEMPORAL_DECAY_IMPORTANCE_WEIGHT"], + 0.2, + ); +} + +// Floor on the decay multiplier in [0,1]. Guarantees decay demotes but +// never erases: a stale hit keeps at least this fraction of its relevance. +export function getTemporalDecayFloor(): number { + return safeParseFloat( + getMergedEnv()["AGENTMEMORY_TEMPORAL_DECAY_FLOOR"], + 0.2, + ); +} + export function isStandaloneMcp(): boolean { return getMergedEnv()["STANDALONE_MCP"] === "true"; } diff --git a/src/functions/temporal-decay.ts b/src/functions/temporal-decay.ts new file mode 100644 index 000000000..893eee264 --- /dev/null +++ b/src/functions/temporal-decay.ts @@ -0,0 +1,181 @@ +// Temporal decay for recall ranking. +// +// Memory recall in agentmemory fuses BM25, vector, and graph signals into +// a single RRF relevance score (see HybridSearch). Pure relevance has no +// notion of time: a year-old note and one written this morning rank purely +// on lexical/semantic match. Temporal decay layers a "use it or lose it" +// recency signal on top — the same principle the Generative Agents +// retrieval model and LangChain's time-weighted retriever encode — so that +// recent and reinforced memories surface ahead of stale ones of equal +// relevance, without ever fully suppressing an old-but-highly-relevant hit. +// +// Design choices (best-practice notes): +// - Exponential decay with a configurable HALF-LIFE rather than a raw +// decay-per-hour constant. Half-life is the interpretable knob: "after +// N days an un-accessed memory's recency weight halves." This is the +// standard parameterization for forgetting curves. +// - Multiplicative reweight of the relevance score (not additive). The +// RRF relevance score is small and unnormalized; adding a [0,1] recency +// term would swamp it. A bounded multiplier keeps relevance the +// dominant signal and applies decay as a graded demotion. +// - A FLOOR on the multiplier so decay can demote but never erase. An +// ancient memory that is the single best lexical+semantic match still +// gets at least `floor` of its relevance, preserving recall safety. +// - Reinforcement via last-access time ("use it or lose it"): the +// effective age is measured from the most recent of creation or last +// access, so retrieving a memory refreshes its recency. The caller +// supplies the effective timestamp; this module stays pure. +// - Importance slows decay: callers may blend a memory's importance into +// the multiplier so consequential memories resist forgetting, matching +// the importance term in the Generative Agents score. + +export interface TemporalDecayParams { + /** Master switch. When false, applyTemporalDecay is a no-op pass-through. */ + enabled: boolean; + /** + * Recency half-life in days. After this many days without access an + * un-reinforced memory's recency factor halves. Must be > 0. + */ + halfLifeDays: number; + /** + * Blend weight for the recency factor, in [0, 1]. Higher means time + * matters more in the final multiplier. + */ + recencyWeight: number; + /** + * Blend weight for normalized importance, in [0, 1]. Higher means + * important memories resist decay more strongly. recencyWeight + + * importanceWeight should be <= 1; the residual is the relevance-only + * floor of the blend (time- and importance-agnostic). + */ + importanceWeight: number; + /** + * Minimum multiplier, in [0, 1]. Guarantees finalScore >= floor * + * relevance so decay never fully erases a relevant hit. Default keeps a + * stale, unimportant memory at a fraction of its relevance rather than 0. + */ + floor: number; +} + +export const DEFAULT_TEMPORAL_DECAY: TemporalDecayParams = { + enabled: false, + halfLifeDays: 14, + recencyWeight: 0.5, + importanceWeight: 0.2, + floor: 0.2, +}; + +const MS_PER_DAY = 1000 * 60 * 60 * 24; + +function clamp01(value: number): number { + if (!Number.isFinite(value)) return 0; + if (value < 0) return 0; + if (value > 1) return 1; + return value; +} + +/** + * Exponential recency factor in (0, 1]. + * - 1.0 at age 0 (or negative age, e.g. clock skew), + * - 0.5 at exactly one half-life, + * - asymptotically 0 as age grows. + * + * `ageMs` is the elapsed time since the memory's effective timestamp + * (creation or last access, whichever is later). A non-positive or + * non-finite half-life disables decay (returns 1). + */ +export function recencyFactor(ageMs: number, halfLifeDays: number): number { + if (!Number.isFinite(halfLifeDays) || halfLifeDays <= 0) return 1; + if (!Number.isFinite(ageMs) || ageMs <= 0) return 1; + const ageDays = ageMs / MS_PER_DAY; + return Math.pow(0.5, ageDays / halfLifeDays); +} + +/** + * Normalize the per-memory params into a guaranteed-sane shape so the + * multiplier is provably in [floor, 1]. Weights are clamped to [0,1] and, + * if they sum above 1, scaled down proportionally so the relevance-only + * residual never goes negative. + */ +function normalizeParams(params: TemporalDecayParams): { + halfLifeDays: number; + recencyWeight: number; + importanceWeight: number; + floor: number; +} { + const halfLifeDays = + Number.isFinite(params.halfLifeDays) && params.halfLifeDays > 0 + ? params.halfLifeDays + : DEFAULT_TEMPORAL_DECAY.halfLifeDays; + let recencyWeight = clamp01(params.recencyWeight); + let importanceWeight = clamp01(params.importanceWeight); + const weightSum = recencyWeight + importanceWeight; + if (weightSum > 1) { + recencyWeight /= weightSum; + importanceWeight /= weightSum; + } + return { + halfLifeDays, + recencyWeight, + importanceWeight, + floor: clamp01(params.floor), + }; +} + +/** + * Compute the bounded decay multiplier in [floor, 1] for a single memory. + * Exposed for testing and for callers that want the factor without applying + * it to a score. + */ +export function decayMultiplier( + ageMs: number, + importance: number, + params: TemporalDecayParams, +): number { + const { halfLifeDays, recencyWeight, importanceWeight, floor } = + normalizeParams(params); + const recency = recencyFactor(ageMs, halfLifeDays); + const importanceNorm = clamp01(importance); + // Relevance-only residual: the share of the blend that ignores both time + // and importance, so a stale unimportant hit still scores on relevance. + const baseWeight = 1 - recencyWeight - importanceWeight; + const blend = + baseWeight + recencyWeight * recency + importanceWeight * importanceNorm; + // blend is in [0, 1]; lift it into [floor, 1]. + return floor + (1 - floor) * clamp01(blend); +} + +/** + * Reweight a relevance score by temporal decay. Returns the relevance + * unchanged when decay is disabled. `nowMs` defaults to Date.now() and is a + * parameter only so tests can pin time. + */ +export function applyTemporalDecay( + relevance: number, + memory: { effectiveTimestampMs: number; importance: number }, + params: TemporalDecayParams, + nowMs: number = Date.now(), +): number { + if (!params.enabled) return relevance; + const ageMs = nowMs - memory.effectiveTimestampMs; + return relevance * decayMultiplier(ageMs, memory.importance, params); +} + +/** + * Resolve a memory's effective timestamp (ms since epoch) as the later of + * its creation/observation time and its last-access time. Reinforcement + * (recall) refreshes recency: a frequently-retrieved old memory ages from + * its last touch, not its birth. Invalid inputs fall back gracefully. + */ +export function effectiveTimestampMs( + observationTimestamp: string | undefined, + lastAccessIso?: string, +): number { + const obsMs = observationTimestamp + ? Date.parse(observationTimestamp) + : NaN; + const accessMs = lastAccessIso ? Date.parse(lastAccessIso) : NaN; + const obs = Number.isFinite(obsMs) ? obsMs : 0; + const access = Number.isFinite(accessMs) ? accessMs : 0; + return Math.max(obs, access); +} diff --git a/src/index.ts b/src/index.ts index 4233e8a67..65c766659 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,11 @@ import { isConsolidationEnabled, isContextInjectionEnabled, isDropStaleIndexEnabled, + isTemporalDecayEnabled, + getTemporalDecayHalfLifeDays, + getTemporalDecayRecencyWeight, + getTemporalDecayImportanceWeight, + getTemporalDecayFloor, } from "./config.js"; import { createProvider, @@ -354,6 +359,13 @@ async function main() { const bm25Index = getSearchIndex(); const graphWeight = parseFloat(getEnvVar("AGENTMEMORY_GRAPH_WEIGHT") || "0.3"); + const temporalDecay = { + enabled: isTemporalDecayEnabled(), + halfLifeDays: getTemporalDecayHalfLifeDays(), + recencyWeight: getTemporalDecayRecencyWeight(), + importanceWeight: getTemporalDecayImportanceWeight(), + floor: getTemporalDecayFloor(), + }; const hybridSearch = new HybridSearch( bm25Index, vectorIndex, @@ -362,6 +374,11 @@ async function main() { embeddingConfig.bm25Weight, embeddingConfig.vectorWeight, graphWeight, + process.env.RERANK_ENABLED === "true", + temporalDecay, + ); + bootLog( + `Temporal decay: ${temporalDecay.enabled ? `enabled (half-life ${temporalDecay.halfLifeDays}d)` : "disabled"}`, ); registerSmartSearchFunction(sdk, kv, (query, limit) => diff --git a/src/state/hybrid-search.ts b/src/state/hybrid-search.ts index d234a3efc..8a732a272 100644 --- a/src/state/hybrid-search.ts +++ b/src/state/hybrid-search.ts @@ -16,6 +16,13 @@ import { } from "../functions/graph-retrieval.js"; import { extractEntitiesFromQuery } from "../functions/query-expansion.js"; import { rerank } from "./reranker.js"; +import { + type TemporalDecayParams, + DEFAULT_TEMPORAL_DECAY, + applyTemporalDecay, + effectiveTimestampMs, +} from "../functions/temporal-decay.js"; +import { type AccessLog } from "../functions/access-tracker.js"; const RRF_K = 60; @@ -31,6 +38,7 @@ export class HybridSearch { private vectorWeight = 0.6, private graphWeight = 0.3, private rerankEnabled = process.env.RERANK_ENABLED === "true", + private decayParams: TemporalDecayParams = DEFAULT_TEMPORAL_DECAY, ) { this.graphRetrieval = new GraphRetrieval(kv); } @@ -225,18 +233,64 @@ export class HybridSearch { const diversified = this.diversifyBySession(combined, retrievalDepth); const enriched = await this.enrichResults(diversified, retrievalDepth); - if (this.rerankEnabled && enriched.length > 1) { + // Temporal decay reweight. Applied after enrichment (where each result + // carries its observation timestamp + importance) and before rerank, so + // recency acts as a retrieval prior that shapes which hits enter the + // rerank window; the cross-encoder then refines precision within it. + // No-op when disabled — guarded inside applyDecayReweight. + const decayed = await this.applyDecayReweight(enriched); + + if (this.rerankEnabled && decayed.length > 1) { try { - const head = enriched.slice(0, rerankWindow); - const tail = enriched.slice(rerankWindow); + const head = decayed.slice(0, rerankWindow); + const tail = decayed.slice(rerankWindow); const reranked = await rerank(query, head, rerankWindow); return reranked.concat(tail).slice(0, limit); } catch { - return enriched.slice(0, limit); + return decayed.slice(0, limit); } } - return enriched.slice(0, limit); + return decayed.slice(0, limit); + } + + // Multiply each result's combinedScore by its temporal-decay factor and + // re-sort. Recency is measured from the later of the observation's + // timestamp and its last-access time (reinforcement refreshes recency), + // so frequently-recalled memories age from their last touch. A single + // batched access-log read keeps this to one extra KV round-trip per query + // when enabled, and the whole method short-circuits when it isn't. + private async applyDecayReweight( + results: HybridSearchResult[], + ): Promise { + if (!this.decayParams.enabled || results.length === 0) return results; + + const now = Date.now(); + const accessLogs = await Promise.all( + results.map((r) => + this.kv + .get(KV.accessLog, r.observation.id) + .catch(() => null), + ), + ); + + const reweighted = results.map((r, i) => { + const lastAt = accessLogs[i]?.lastAt || undefined; + const effectiveTs = effectiveTimestampMs( + r.observation.timestamp, + lastAt, + ); + const combinedScore = applyTemporalDecay( + r.combinedScore, + { effectiveTimestampMs: effectiveTs, importance: r.observation.importance }, + this.decayParams, + now, + ); + return { ...r, combinedScore }; + }); + + reweighted.sort((a, b) => b.combinedScore - a.combinedScore); + return reweighted; } private diversifyBySession( diff --git a/test/temporal-decay.test.ts b/test/temporal-decay.test.ts new file mode 100644 index 000000000..4e11d5244 --- /dev/null +++ b/test/temporal-decay.test.ts @@ -0,0 +1,173 @@ +import { describe, it, expect } from "vitest"; +import { + recencyFactor, + decayMultiplier, + applyTemporalDecay, + effectiveTimestampMs, + DEFAULT_TEMPORAL_DECAY, + type TemporalDecayParams, +} from "../src/functions/temporal-decay.js"; + +const MS_PER_DAY = 1000 * 60 * 60 * 24; + +function params(overrides: Partial = {}): TemporalDecayParams { + return { ...DEFAULT_TEMPORAL_DECAY, enabled: true, ...overrides }; +} + +describe("recencyFactor", () => { + it("is 1.0 at age zero", () => { + expect(recencyFactor(0, 14)).toBe(1); + }); + + it("is 0.5 at exactly one half-life", () => { + expect(recencyFactor(14 * MS_PER_DAY, 14)).toBeCloseTo(0.5, 10); + }); + + it("is 0.25 at two half-lives", () => { + expect(recencyFactor(28 * MS_PER_DAY, 14)).toBeCloseTo(0.25, 10); + }); + + it("decreases monotonically with age", () => { + const a = recencyFactor(1 * MS_PER_DAY, 14); + const b = recencyFactor(5 * MS_PER_DAY, 14); + const c = recencyFactor(30 * MS_PER_DAY, 14); + expect(a).toBeGreaterThan(b); + expect(b).toBeGreaterThan(c); + }); + + it("treats negative age (clock skew) as fresh", () => { + expect(recencyFactor(-1000, 14)).toBe(1); + }); + + it("disables decay for a non-positive half-life", () => { + expect(recencyFactor(100 * MS_PER_DAY, 0)).toBe(1); + expect(recencyFactor(100 * MS_PER_DAY, -5)).toBe(1); + }); + + it("never returns a value outside (0, 1]", () => { + for (const days of [0.5, 1, 7, 90, 365, 3650]) { + const f = recencyFactor(days * MS_PER_DAY, 14); + expect(f).toBeGreaterThan(0); + expect(f).toBeLessThanOrEqual(1); + } + }); +}); + +describe("decayMultiplier", () => { + it("is bounded in [floor, 1]", () => { + const p = params({ floor: 0.2 }); + for (const days of [0, 1, 14, 100, 1000]) { + for (const imp of [0, 0.5, 1]) { + const m = decayMultiplier(days * MS_PER_DAY, imp, p); + expect(m).toBeGreaterThanOrEqual(0.2 - 1e-9); + expect(m).toBeLessThanOrEqual(1 + 1e-9); + } + } + }); + + it("reaches the floor asymptotically for an ancient unimportant memory", () => { + const p = params({ floor: 0.2, importanceWeight: 0.2 }); + // 1000 half-lives: recency ~0, importance 0 -> multiplier -> floor + + // (1-floor)*baseWeight. baseWeight = 1 - 0.5 - 0.2 = 0.3. + const m = decayMultiplier(14000 * MS_PER_DAY, 0, p); + expect(m).toBeCloseTo(0.2 + 0.8 * 0.3, 6); + }); + + it("is 1.0 for a fresh maximally-important memory", () => { + const p = params({ floor: 0.2, recencyWeight: 0.5, importanceWeight: 0.2 }); + const m = decayMultiplier(0, 1, p); + expect(m).toBeCloseTo(1, 6); + }); + + it("ranks a fresh memory above an old one of equal importance", () => { + const p = params(); + const fresh = decayMultiplier(0, 0.5, p); + const old = decayMultiplier(60 * MS_PER_DAY, 0.5, p); + expect(fresh).toBeGreaterThan(old); + }); + + it("lets importance slow decay (important old > unimportant old)", () => { + const p = params({ importanceWeight: 0.4 }); + const important = decayMultiplier(60 * MS_PER_DAY, 1, p); + const trivial = decayMultiplier(60 * MS_PER_DAY, 0, p); + expect(important).toBeGreaterThan(trivial); + }); + + it("normalizes weights that sum above 1 without going below floor", () => { + const p = params({ recencyWeight: 0.9, importanceWeight: 0.9, floor: 0.1 }); + const m = decayMultiplier(1000 * MS_PER_DAY, 0, p); + expect(m).toBeGreaterThanOrEqual(0.1 - 1e-9); + }); +}); + +describe("applyTemporalDecay", () => { + const now = Date.parse("2026-06-13T00:00:00.000Z"); + + it("is a pass-through when disabled", () => { + const p = params({ enabled: false }); + const out = applyTemporalDecay( + 0.05, + { effectiveTimestampMs: 0, importance: 0 }, + p, + now, + ); + expect(out).toBe(0.05); + }); + + it("scales relevance down for stale memories", () => { + const p = params(); + const old = now - 90 * MS_PER_DAY; + const out = applyTemporalDecay( + 0.05, + { effectiveTimestampMs: old, importance: 0.3 }, + p, + now, + ); + expect(out).toBeLessThan(0.05); + expect(out).toBeGreaterThan(0); + }); + + it("preserves relevance ordering it cannot invert within the floor", () => { + // A vastly more relevant but old hit should still be able to beat a + // marginally relevant fresh hit, thanks to the floor. + const p = params({ floor: 0.3 }); + const oldButRelevant = applyTemporalDecay( + 0.1, + { effectiveTimestampMs: now - 120 * MS_PER_DAY, importance: 0.5 }, + p, + now, + ); + const freshButWeak = applyTemporalDecay( + 0.02, + { effectiveTimestampMs: now, importance: 0.5 }, + p, + now, + ); + expect(oldButRelevant).toBeGreaterThan(freshButWeak); + }); +}); + +describe("effectiveTimestampMs", () => { + it("uses the observation timestamp when there is no access", () => { + const ts = "2026-06-01T00:00:00.000Z"; + expect(effectiveTimestampMs(ts)).toBe(Date.parse(ts)); + }); + + it("uses last access when it is more recent (reinforcement)", () => { + const created = "2026-01-01T00:00:00.000Z"; + const accessed = "2026-06-10T00:00:00.000Z"; + expect(effectiveTimestampMs(created, accessed)).toBe(Date.parse(accessed)); + }); + + it("keeps creation time when it is more recent than a stale access row", () => { + const created = "2026-06-10T00:00:00.000Z"; + const accessed = "2026-01-01T00:00:00.000Z"; + expect(effectiveTimestampMs(created, accessed)).toBe(Date.parse(created)); + }); + + it("handles missing/invalid timestamps without throwing", () => { + expect(effectiveTimestampMs(undefined)).toBe(0); + expect(effectiveTimestampMs("not-a-date")).toBe(0); + expect(effectiveTimestampMs("not-a-date", "also-bad")).toBe(0); + }); +}); From 62e52ad1266bb5f4c06d56700c524bb76329d1c9 Mon Sep 17 00:00:00 2001 From: Srinath Date: Sat, 13 Jun 2026 20:12:04 -0400 Subject: [PATCH 2/2] fix(config): strict float parsing for temporal-decay env vars parseFloat accepts trailing non-numeric text ("0.2oops" -> 0.2), so a malformed env value was silently honored instead of falling back to the default. Switch to Number() for strict parsing and guard the empty-after-trim case (Number("") is 0, not NaN). Addresses CodeRabbit review feedback. Co-Authored-By: Claude Opus 4.8 Signed-off-by: Srinath --- src/config.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/config.ts b/src/config.ts index e91fed629..768307c44 100644 --- a/src/config.ts +++ b/src/config.ts @@ -18,7 +18,13 @@ function safeParseInt(value: string | undefined, fallback: number): number { function safeParseFloat(value: string | undefined, fallback: number): number { if (!value) return fallback; - const parsed = parseFloat(value); + // Strict parse: parseFloat would accept trailing junk ("0.2oops" -> 0.2), + // silently honoring a malformed env value instead of falling back. Number() + // rejects partial matches. Guard the empty-after-trim case too, since + // Number("") is 0 (not NaN) and a whitespace-only value should fall back. + const normalized = value.trim(); + if (!normalized) return fallback; + const parsed = Number(normalized); return Number.isFinite(parsed) ? parsed : fallback; }