@@ -32,6 +32,15 @@ function endpointUrl(): string | null {
3232
3333let reachable : boolean | null = null ;
3434
35+ /**
36+ * Test-only: clear the memoized reachability probe so cases that point
37+ * `AGENTMEMORY_URL` at different endpoints don't leak a cached verdict into each
38+ * other (the probe is intentionally memoized once per process at runtime).
39+ */
40+ export function _resetReachableCache ( ) : void {
41+ reachable = null ;
42+ }
43+
3544function requestAgentMemory (
3645 baseUrl : string ,
3746 path : string ,
@@ -142,15 +151,63 @@ export interface RecalledFact {
142151
143152interface SearchResult {
144153 score ?: number ;
154+ timestamp ?: unknown ;
155+ created_at ?: unknown ;
145156 observation ?: {
146157 narrative ?: unknown ;
147158 facts ?: unknown ;
148159 title ?: unknown ;
149160 type ?: unknown ;
161+ timestamp ?: unknown ;
162+ created_at ?: unknown ;
150163 } ;
151164}
152165
153- function parseSearchResults ( body : string , k : number ) : RecalledFact [ ] {
166+ /**
167+ * Recall TTL: facts older than this many days are dropped from the snapshot so
168+ * stale, long-resolved decisions stop rehydrating every boundary. Default 30
169+ * days; set `OMA_RECALL_MAX_AGE_DAYS=0` (or a non-positive value) to disable.
170+ * Returns the max age in ms, or null when disabled.
171+ */
172+ function recallMaxAgeMs ( ) : number | null {
173+ const raw = process . env . OMA_RECALL_MAX_AGE_DAYS ;
174+ const days = raw === undefined ? 30 : Number ( raw ) ;
175+ if ( ! Number . isFinite ( days ) || days <= 0 ) return null ;
176+ return days * 24 * 60 * 60 * 1000 ;
177+ }
178+
179+ /**
180+ * Best-effort timestamp extraction from a search result. AgentMemory's response
181+ * envelope is not contractually fixed across versions, so several candidate
182+ * field names / locations are probed. Numeric epoch seconds are normalised to
183+ * ms. Returns null when no parseable timestamp is present — callers then keep
184+ * the fact (TTL filtering is fail-open, never dropping facts of unknown age).
185+ */
186+ function extractTimestampMs ( entry : SearchResult ) : number | null {
187+ const obs = entry . observation ?? { } ;
188+ const candidates : unknown [ ] = [
189+ obs . timestamp ,
190+ obs . created_at ,
191+ entry . timestamp ,
192+ entry . created_at ,
193+ ] ;
194+ for ( const candidate of candidates ) {
195+ if ( typeof candidate === "number" && Number . isFinite ( candidate ) ) {
196+ return candidate < 1e12 ? candidate * 1000 : candidate ;
197+ }
198+ if ( typeof candidate === "string" && candidate . trim ( ) ) {
199+ const parsed = Date . parse ( candidate ) ;
200+ if ( Number . isFinite ( parsed ) ) return parsed ;
201+ }
202+ }
203+ return null ;
204+ }
205+
206+ export function parseSearchResults (
207+ body : string ,
208+ k : number ,
209+ nowMs : number = Date . now ( ) ,
210+ ) : RecalledFact [ ] {
154211 let parsed : { results ?: unknown } ;
155212 try {
156213 parsed = JSON . parse ( body ) as { results ?: unknown } ;
@@ -164,12 +221,20 @@ function parseSearchResults(body: string, k: number): RecalledFact[] {
164221 return Number . isFinite ( raw ) ? raw : 1 ;
165222 } ) ( ) ;
166223
224+ const maxAgeMs = recallMaxAgeMs ( ) ;
225+ const cutoffMs = maxAgeMs === null ? null : nowMs - maxAgeMs ;
226+
167227 const facts : RecalledFact [ ] = [ ] ;
168228 for ( const entry of parsed . results as SearchResult [ ] ) {
169229 const score = typeof entry . score === "number" ? entry . score : 0 ;
170230 // Raw `/observe` envelopes score near-zero (~0.006); enriched facts score
171231 // in the single digits. Drop the noise floor so the snapshot stays useful.
172232 if ( score < minScore ) continue ;
233+ // TTL: drop facts older than the cutoff (fail-open on unknown age).
234+ if ( cutoffMs !== null ) {
235+ const tsMs = extractTimestampMs ( entry ) ;
236+ if ( tsMs !== null && tsMs < cutoffMs ) continue ;
237+ }
173238 const obs = entry . observation ?? { } ;
174239 const narrative =
175240 typeof obs . narrative === "string" && obs . narrative . trim ( )
@@ -198,11 +263,14 @@ export async function recallFacts(
198263 k = 5 ,
199264) : Promise < RecalledFact [ ] > {
200265 if ( ! query . trim ( ) ) return [ ] ;
201- if ( ! ( await isAgentMemoryReachable ( ) ) ) return [ ] ;
202- const url = endpointUrl ( ) ;
203- if ( ! url ) return [ ] ;
204-
266+ // The whole body is guarded so this honors its "never throws" contract: the
267+ // reachability probe and endpoint resolution can throw under load (e.g. a
268+ // socket error from the shared daemon), and an unguarded throw here blanks the
269+ // boundary snapshot the hook would otherwise emit. Degrade to local-only.
205270 try {
271+ if ( ! ( await isAgentMemoryReachable ( ) ) ) return [ ] ;
272+ const url = endpointUrl ( ) ;
273+ if ( ! url ) return [ ] ;
206274 const response = await requestAgentMemory ( url , "/agentmemory/search" , {
207275 method : "POST" ,
208276 headers : { "content-type" : "application/json" } ,
@@ -222,11 +290,13 @@ export async function observeWithTimeout(payload: {
222290 source : string ;
223291 projectDir ?: string ;
224292} ) : Promise < boolean > {
225- if ( ! ( await isAgentMemoryReachable ( ) ) ) return false ;
226- const url = endpointUrl ( ) ;
227- if ( ! url ) return false ;
228-
293+ // Fully guarded (best-effort, never throws): the reachability probe and
294+ // endpoint resolution can throw under load, and a throw here must not abort
295+ // the hook that fired the observe.
229296 try {
297+ if ( ! ( await isAgentMemoryReachable ( ) ) ) return false ;
298+ const url = endpointUrl ( ) ;
299+ if ( ! url ) return false ;
230300 // AgentMemory's /observe expects a hook-event envelope
231301 // (hookType, sessionId, project, cwd, timestamp) carrying the content.
232302 const cwd = payload . projectDir ?? process . cwd ( ) ;
0 commit comments