@@ -142,15 +142,63 @@ export interface RecalledFact {
142142
143143interface SearchResult {
144144 score ?: number ;
145+ timestamp ?: unknown ;
146+ created_at ?: unknown ;
145147 observation ?: {
146148 narrative ?: unknown ;
147149 facts ?: unknown ;
148150 title ?: unknown ;
149151 type ?: unknown ;
152+ timestamp ?: unknown ;
153+ created_at ?: unknown ;
150154 } ;
151155}
152156
153- function parseSearchResults ( body : string , k : number ) : RecalledFact [ ] {
157+ /**
158+ * Recall TTL: facts older than this many days are dropped from the snapshot so
159+ * stale, long-resolved decisions stop rehydrating every boundary. Default 30
160+ * days; set `OMA_RECALL_MAX_AGE_DAYS=0` (or a non-positive value) to disable.
161+ * Returns the max age in ms, or null when disabled.
162+ */
163+ function recallMaxAgeMs ( ) : number | null {
164+ const raw = process . env . OMA_RECALL_MAX_AGE_DAYS ;
165+ const days = raw === undefined ? 30 : Number ( raw ) ;
166+ if ( ! Number . isFinite ( days ) || days <= 0 ) return null ;
167+ return days * 24 * 60 * 60 * 1000 ;
168+ }
169+
170+ /**
171+ * Best-effort timestamp extraction from a search result. AgentMemory's response
172+ * envelope is not contractually fixed across versions, so several candidate
173+ * field names / locations are probed. Numeric epoch seconds are normalised to
174+ * ms. Returns null when no parseable timestamp is present — callers then keep
175+ * the fact (TTL filtering is fail-open, never dropping facts of unknown age).
176+ */
177+ function extractTimestampMs ( entry : SearchResult ) : number | null {
178+ const obs = entry . observation ?? { } ;
179+ const candidates : unknown [ ] = [
180+ obs . timestamp ,
181+ obs . created_at ,
182+ entry . timestamp ,
183+ entry . created_at ,
184+ ] ;
185+ for ( const candidate of candidates ) {
186+ if ( typeof candidate === "number" && Number . isFinite ( candidate ) ) {
187+ return candidate < 1e12 ? candidate * 1000 : candidate ;
188+ }
189+ if ( typeof candidate === "string" && candidate . trim ( ) ) {
190+ const parsed = Date . parse ( candidate ) ;
191+ if ( Number . isFinite ( parsed ) ) return parsed ;
192+ }
193+ }
194+ return null ;
195+ }
196+
197+ export function parseSearchResults (
198+ body : string ,
199+ k : number ,
200+ nowMs : number = Date . now ( ) ,
201+ ) : RecalledFact [ ] {
154202 let parsed : { results ?: unknown } ;
155203 try {
156204 parsed = JSON . parse ( body ) as { results ?: unknown } ;
@@ -164,12 +212,20 @@ function parseSearchResults(body: string, k: number): RecalledFact[] {
164212 return Number . isFinite ( raw ) ? raw : 1 ;
165213 } ) ( ) ;
166214
215+ const maxAgeMs = recallMaxAgeMs ( ) ;
216+ const cutoffMs = maxAgeMs === null ? null : nowMs - maxAgeMs ;
217+
167218 const facts : RecalledFact [ ] = [ ] ;
168219 for ( const entry of parsed . results as SearchResult [ ] ) {
169220 const score = typeof entry . score === "number" ? entry . score : 0 ;
170221 // Raw `/observe` envelopes score near-zero (~0.006); enriched facts score
171222 // in the single digits. Drop the noise floor so the snapshot stays useful.
172223 if ( score < minScore ) continue ;
224+ // TTL: drop facts older than the cutoff (fail-open on unknown age).
225+ if ( cutoffMs !== null ) {
226+ const tsMs = extractTimestampMs ( entry ) ;
227+ if ( tsMs !== null && tsMs < cutoffMs ) continue ;
228+ }
173229 const obs = entry . observation ?? { } ;
174230 const narrative =
175231 typeof obs . narrative === "string" && obs . narrative . trim ( )
0 commit comments