Skip to content

Commit b424f68

Browse files
committed
feat(memory): add observational compression, reflection, and temporal reasoning
- ObservationCompressor: LLM-based 3-10x compression with priority markers - ObservationReflector: higher-level pattern extraction from compressed observations - Three-date temporal model: observedAt, referencedAt, relativeLabel - Temporal filtering in Memory.recall() (after/before options) - relativeTimeLabel() utility for human-friendly time descriptions
1 parent d550c12 commit b424f68

8 files changed

Lines changed: 1461 additions & 4 deletions

File tree

src/memory/facade/Memory.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -396,6 +396,14 @@ export class Memory {
396396
conditions.push('t.strength >= ?');
397397
params.push(minStrength);
398398
}
399+
if (options?.after != null) {
400+
conditions.push('t.created_at > ?');
401+
params.push(options.after);
402+
}
403+
if (options?.before != null) {
404+
conditions.push('t.created_at < ?');
405+
params.push(options.before);
406+
}
399407

400408
const whereClause = conditions.length > 0
401409
? `WHERE ${conditions.join(' AND ')}`

src/memory/facade/types.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,18 @@ export interface RecallOptions {
329329
* @default 0
330330
*/
331331
minStrength?: number;
332+
333+
/**
334+
* Only return traces created after this Unix-ms timestamp.
335+
* Part of the three-date temporal model for time-ranged recall.
336+
*/
337+
after?: number;
338+
339+
/**
340+
* Only return traces created before this Unix-ms timestamp.
341+
* Part of the three-date temporal model for time-ranged recall.
342+
*/
343+
before?: number;
332344
}
333345

334346
// ---------------------------------------------------------------------------

src/memory/index.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,16 @@ export type { ObservationNote } from './observation/MemoryObserver.js';
149149
export { MemoryReflector } from './observation/MemoryReflector.js';
150150
export type { MemoryReflectionResult } from './observation/MemoryReflector.js';
151151

152+
// --- Observation Compression & Reflection (Mastra-style agentic compression) ---
153+
export { ObservationCompressor } from './observation/ObservationCompressor.js';
154+
export type { CompressedObservation, CompressionPriority } from './observation/ObservationCompressor.js';
155+
export { ObservationReflector } from './observation/ObservationReflector.js';
156+
export type { Reflection, ReflectionPatternType } from './observation/ObservationReflector.js';
157+
158+
// --- Temporal Reasoning ---
159+
export { relativeTimeLabel } from './observation/temporal.js';
160+
export type { TemporalMetadata } from './observation/temporal.js';
161+
152162
// --- Prospective Memory (Batch 2) ---
153163
export { ProspectiveMemoryManager } from './prospective/ProspectiveMemoryManager.js';
154164
export type { ProspectiveMemoryItem, ProspectiveTriggerType } from './prospective/ProspectiveMemoryManager.js';

src/memory/observation/MemoryObserver.ts

Lines changed: 152 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,18 @@
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
@@ -17,6 +25,9 @@
1725

1826
import type { HexacoTraits, PADState, ObserverConfig } from '../config.js';
1927
import { 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

7393
let 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+
75101
export 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

Comments
 (0)