11/**
2- * @fileoverview Memory Observer — personality-biased background note extraction.
2+ * @fileoverview Memory Observer — personality-biased background note extraction
3+ * with LLM-based compression and reflection tiers.
34 *
45 * Monitors accumulated conversation tokens via ObservationBuffer.
56 * When the threshold is reached, extracts concise observation notes
67 * via a persona-configured LLM (defaults to cheap model).
78 *
9+ * Three-tier agentic memory pipeline (Mastra-style):
10+ * 1. Raw notes — extracted per-turn when token threshold is reached.
11+ * 2. Compressed observations — produced by ObservationCompressor when
12+ * accumulated notes exceed the compression threshold (default: 50 notes).
13+ * 3. Reflections — produced by ObservationReflector when compressed
14+ * observations exceed the reflection token threshold (default: 40,000 tokens).
15+ *
816 * Personality bias:
917 * - High emotionality → notes emotional shifts
1018 * - High conscientiousness → notes commitments/deadlines
1725
1826import type { HexacoTraits , PADState , ObserverConfig } from '../config.js' ;
1927import { ObservationBuffer , type BufferedMessage } from './ObservationBuffer.js' ;
28+ import { ObservationCompressor , type CompressedObservation } from './ObservationCompressor.js' ;
29+ import { ObservationReflector , type Reflection } from './ObservationReflector.js' ;
30+ import { relativeTimeLabel } from './temporal.js' ;
2031
2132// ---------------------------------------------------------------------------
2233// Types
@@ -35,6 +46,15 @@ export interface ObservationNote {
3546 /** Emotional context at observation time. */
3647 emotionalContext ?: { valence : number ; arousal : number } ;
3748 timestamp : number ;
49+ /** Three-date temporal metadata. */
50+ temporal ?: {
51+ /** When this observation was made (Unix ms). Same as timestamp. */
52+ observedAt : number ;
53+ /** When the referenced event actually occurred (Unix ms). */
54+ referencedAt : number ;
55+ /** Human-friendly relative time label. */
56+ relativeLabel : string ;
57+ } ;
3858}
3959
4060// ---------------------------------------------------------------------------
@@ -72,12 +92,26 @@ Output ONLY valid JSON objects, one per line. No markdown, no explanation.${emph
7292
7393let noteIdCounter = 0 ;
7494
95+ /** Default number of accumulated notes before compression triggers. */
96+ const DEFAULT_COMPRESSION_THRESHOLD = 50 ;
97+
98+ /** Default token count of compressed observations before reflection triggers. */
99+ const DEFAULT_REFLECTION_THRESHOLD_TOKENS = 40_000 ;
100+
75101export class MemoryObserver {
76102 private buffer : ObservationBuffer ;
77103 private traits : HexacoTraits ;
78104 private llmInvoker ?: ( systemPrompt : string , userPrompt : string ) => Promise < string > ;
79105 private config : ObserverConfig ;
80106
107+ // --- Compression / reflection tier state ---
108+ private accumulatedNotes : ObservationNote [ ] = [ ] ;
109+ private accumulatedCompressed : CompressedObservation [ ] = [ ] ;
110+ private compressor : ObservationCompressor | null = null ;
111+ private reflector : ObservationReflector | null = null ;
112+ private compressionThreshold : number ;
113+ private reflectionThresholdTokens : number ;
114+
81115 constructor (
82116 traits : HexacoTraits ,
83117 config ?: Partial < ObserverConfig > ,
@@ -92,6 +126,16 @@ export class MemoryObserver {
92126 this . buffer = new ObservationBuffer ( {
93127 activationThresholdTokens : this . config . activationThresholdTokens ,
94128 } ) ;
129+
130+ // Default thresholds for compression and reflection tiers.
131+ this . compressionThreshold = DEFAULT_COMPRESSION_THRESHOLD ;
132+ this . reflectionThresholdTokens = DEFAULT_REFLECTION_THRESHOLD_TOKENS ;
133+
134+ // Initialize compressor and reflector if LLM invoker is provided.
135+ if ( this . llmInvoker ) {
136+ this . compressor = new ObservationCompressor ( this . llmInvoker , this . traits ) ;
137+ this . reflector = new ObservationReflector ( this . llmInvoker ) ;
138+ }
95139 }
96140
97141 /**
@@ -129,12 +173,65 @@ export class MemoryObserver {
129173
130174 try {
131175 const response = await this . llmInvoker ( systemPrompt , conversationText ) ;
132- return this . parseNotes ( response , mood ) ;
176+ const notes = this . parseNotes ( response , mood , messages ) ;
177+
178+ // Accumulate notes for the compression tier.
179+ this . accumulatedNotes . push ( ...notes ) ;
180+
181+ return notes ;
133182 } catch {
134183 return [ ] ;
135184 }
136185 }
137186
187+ /**
188+ * Run compression if accumulated notes exceed the compression threshold.
189+ *
190+ * When the number of accumulated raw notes exceeds the configured threshold
191+ * (default: 50), the ObservationCompressor is invoked to produce denser
192+ * compressed observations. The raw notes are then cleared.
193+ *
194+ * @returns Compressed observations if threshold was met, null otherwise.
195+ */
196+ async compressIfNeeded ( ) : Promise < CompressedObservation [ ] | null > {
197+ if ( ! this . compressor ) return null ;
198+ if ( this . accumulatedNotes . length < this . compressionThreshold ) return null ;
199+
200+ const compressed = await this . compressor . compress ( this . accumulatedNotes ) ;
201+
202+ // Clear consumed notes and accumulate compressed observations.
203+ this . accumulatedNotes = [ ] ;
204+ this . accumulatedCompressed . push ( ...compressed ) ;
205+
206+ return compressed ;
207+ }
208+
209+ /**
210+ * Run reflection if accumulated compressed observations exceed the token threshold.
211+ *
212+ * When the total estimated tokens of accumulated compressed observations
213+ * exceeds the configured threshold (default: 40,000 tokens), the
214+ * ObservationReflector is invoked to extract higher-level patterns.
215+ *
216+ * @returns Reflections if threshold was met, null otherwise.
217+ */
218+ async reflectIfNeeded ( ) : Promise < Reflection [ ] | null > {
219+ if ( ! this . reflector ) return null ;
220+
221+ const totalTokens = this . accumulatedCompressed . reduce (
222+ ( sum , o ) => sum + Math . ceil ( o . summary . length / 4 ) ,
223+ 0 ,
224+ ) ;
225+ if ( totalTokens < this . reflectionThresholdTokens ) return null ;
226+
227+ const reflections = await this . reflector . reflect ( this . accumulatedCompressed ) ;
228+
229+ // Clear consumed compressed observations.
230+ this . accumulatedCompressed = [ ] ;
231+
232+ return reflections ;
233+ }
234+
138235 /** Get the underlying buffer for inspection. */
139236 getBuffer ( ) : ObservationBuffer {
140237 return this . buffer ;
@@ -145,29 +242,80 @@ export class MemoryObserver {
145242 return this . buffer . shouldActivate ( ) ;
146243 }
147244
245+ /** Get the count of accumulated raw notes awaiting compression. */
246+ getAccumulatedNoteCount ( ) : number {
247+ return this . accumulatedNotes . length ;
248+ }
249+
250+ /** Get the count of accumulated compressed observations awaiting reflection. */
251+ getAccumulatedCompressedCount ( ) : number {
252+ return this . accumulatedCompressed . length ;
253+ }
254+
255+ /** Get the accumulated compressed observations (read-only snapshot). */
256+ getAccumulatedCompressed ( ) : readonly CompressedObservation [ ] {
257+ return this . accumulatedCompressed ;
258+ }
259+
260+ /** Set the compression threshold (number of notes before compression triggers). */
261+ setCompressionThreshold ( threshold : number ) : void {
262+ this . compressionThreshold = threshold ;
263+ }
264+
265+ /** Set the reflection token threshold (estimated tokens before reflection triggers). */
266+ setReflectionThresholdTokens ( threshold : number ) : void {
267+ this . reflectionThresholdTokens = threshold ;
268+ }
269+
148270 /** Reset the observer. */
149271 clear ( ) : void {
150272 this . buffer . clear ( ) ;
273+ this . accumulatedNotes = [ ] ;
274+ this . accumulatedCompressed = [ ] ;
151275 }
152276
153277 // --- Internal ---
154278
155- private parseNotes ( llmResponse : string , mood ?: PADState ) : ObservationNote [ ] {
279+ /**
280+ * Parse LLM response into ObservationNote objects.
281+ *
282+ * Attaches three-date temporal metadata from conversation message timestamps
283+ * when available, using the earliest message timestamp as `referencedAt`
284+ * and the current time as `observedAt`.
285+ */
286+ private parseNotes (
287+ llmResponse : string ,
288+ mood ?: PADState ,
289+ messages ?: BufferedMessage [ ] ,
290+ ) : ObservationNote [ ] {
156291 const notes : ObservationNote [ ] = [ ] ;
157292 const lines = llmResponse . split ( '\n' ) . filter ( ( l ) => l . trim ( ) ) ;
158293
294+ // Determine the earliest message timestamp for the referencedAt field.
295+ const earliestMessageTime = messages && messages . length > 0
296+ ? Math . min ( ...messages . map ( ( m ) => m . timestamp ) )
297+ : undefined ;
298+
159299 for ( const line of lines ) {
160300 try {
161301 const parsed = JSON . parse ( line . trim ( ) ) ;
162302 if ( parsed . type && parsed . content ) {
303+ const now = Date . now ( ) ;
304+ const referencedAt = earliestMessageTime ?? now ;
305+
163306 notes . push ( {
164307 id : `obs_${ Date . now ( ) } _${ ++ noteIdCounter } ` ,
165308 type : parsed . type ,
166309 content : parsed . content ,
167310 importance : typeof parsed . importance === 'number' ? parsed . importance : 0.5 ,
168311 entities : Array . isArray ( parsed . entities ) ? parsed . entities : [ ] ,
169312 emotionalContext : mood ? { valence : mood . valence , arousal : mood . arousal } : undefined ,
170- timestamp : Date . now ( ) ,
313+ timestamp : now ,
314+ temporal : {
315+ observedAt : now ,
316+ referencedAt,
317+ relativeLabel : relativeTimeLabel ( referencedAt , now ) ,
318+ } ,
171319 } ) ;
172320 }
173321 } catch {
0 commit comments