Skip to content

Commit 18e5259

Browse files
committed
feat(memory): add relational type, CoT reasoning, personality bias to MemoryReflector
MemoryReflector now produces all 5 Tulving-extended memory types including relational (trust signals, boundary events, emotional bonds). Chain-of-thought reasoning via <thinking> blocks improves classification accuracy. HEXACO personality traits modulate relational sensitivity (emotionality, agreeableness, extraversion). Reasoning field preserved on traces for devtools.
1 parent 74289f4 commit 18e5259

2 files changed

Lines changed: 226 additions & 9 deletions

File tree

src/memory/pipeline/observation/MemoryReflector.ts

Lines changed: 96 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,23 @@ import type { ObservationNote } from './MemoryObserver.js';
2121
// Types
2222
// ---------------------------------------------------------------------------
2323

24+
/**
25+
* Result of a reflection cycle.
26+
*
27+
* Contains the consolidated long-term traces (typed as episodic, semantic,
28+
* procedural, prospective, or relational), any superseded trace IDs, the
29+
* consumed note IDs, and the compression ratio achieved.
30+
*/
2431
export interface MemoryReflectionResult {
2532
/** New long-term memory traces to store. */
26-
traces: Omit<MemoryTrace, 'id' | 'encodingStrength' | 'stability' | 'retrievalCount' | 'lastAccessedAt' | 'accessCount' | 'reinforcementInterval' | 'createdAt' | 'updatedAt'>[];
33+
traces: (Omit<MemoryTrace, 'id' | 'encodingStrength' | 'stability' | 'retrievalCount' | 'lastAccessedAt' | 'accessCount' | 'reinforcementInterval' | 'createdAt' | 'updatedAt'> & {
34+
/**
35+
* Reflector's chain-of-thought reasoning for why this trace matters.
36+
* Available for devtools/debugging; stripped before the trace is
37+
* passed to `CognitiveMemoryManager.encode()` for storage.
38+
*/
39+
reasoning?: string;
40+
})[];
2741
/** IDs of existing traces that should be superseded. */
2842
supersededTraceIds: string[];
2943
/** IDs of observation notes that were consumed. */
@@ -36,34 +50,81 @@ export interface MemoryReflectionResult {
3650
// Personality-aware system prompt
3751
// ---------------------------------------------------------------------------
3852

53+
/**
54+
* Build a personality-biased system prompt for the memory reflector.
55+
*
56+
* Personality influences (grounded in HEXACO model of personality):
57+
* - High honesty → prefer newer info over old on contradiction (source monitoring)
58+
* - High agreeableness → keep both versions on contradiction (cognitive flexibility)
59+
* - High conscientiousness → structured, categorized output (organizational encoding)
60+
* - High openness → rich, associative output (spreading activation style)
61+
* - High emotionality → heightened sensitivity to relational/emotional signals
62+
* - High extraversion → captures social dynamics and group interactions
63+
*
64+
* The chain-of-thought `<thinking>` block asks the LLM to reason about
65+
* each memory type before extraction, improving classification accuracy
66+
* and ensuring relational signals are not overlooked.
67+
*
68+
* @param traits - HEXACO personality traits of the agent
69+
* @returns System prompt string for the LLM reflector call
70+
*/
3971
function buildReflectorSystemPrompt(traits: HexacoTraits): string {
4072
const clamp = (v: number | undefined): number => v == null ? 0.5 : Math.max(0, Math.min(1, v));
4173

74+
// Conflict resolution strategy — mirrors source monitoring in cognitive psychology.
75+
// High honesty agents trust newer information; high agreeableness agents preserve both.
4276
const conflictStrategy = clamp(traits.honesty) > 0.6
4377
? 'When you detect a contradiction with existing knowledge, prefer the newer information and flag the old memory for supersession.'
4478
: clamp(traits.agreeableness) > 0.6
4579
? 'When you detect a contradiction, keep both versions and note the discrepancy.'
4680
: 'When you detect a contradiction, keep the version with higher confidence.';
4781

82+
// Memory organization style — mirrors encoding specificity principle.
4883
const memoryStyle = clamp(traits.conscientiousness) > 0.6
4984
? 'Produce structured, well-organized memory traces with clear categories.'
5085
: clamp(traits.openness) > 0.6
5186
? 'Produce rich, associative memory traces that capture connections and context.'
5287
: 'Produce concise, factual memory traces focused on key information.';
5388

89+
// Relational sensitivity — emotionality and agreeableness heighten
90+
// detection of trust signals, boundary events, and social cues.
91+
const relationalEmphases: string[] = [];
92+
if (clamp(traits.emotionality) > 0.6) {
93+
relationalEmphases.push('Pay special attention to emotional subtleties, vulnerability signals, and shifts in emotional tone — these are important relational memories.');
94+
}
95+
if (clamp(traits.agreeableness) > 0.6) {
96+
relationalEmphases.push('Notice rapport cues, harmony signals, and moments of mutual understanding.');
97+
}
98+
if (clamp(traits.extraversion) > 0.6) {
99+
relationalEmphases.push('Capture social dynamics, group interactions, and interpersonal energy shifts.');
100+
}
101+
const relationalBlock = relationalEmphases.length > 0
102+
? `\n\nRelational sensitivity:\n${relationalEmphases.map((e) => `- ${e}`).join('\n')}`
103+
: '';
104+
54105
return `You are a memory reflector. Your job is to consolidate observation notes into long-term memory traces.
55106
107+
Before producing traces, reason step by step inside <thinking> tags:
108+
1. What new FACTS did the user reveal? (semantic)
109+
2. What EVENTS happened worth remembering? (episodic)
110+
3. What PATTERNS or PREFERENCES emerged? (procedural)
111+
4. What FUTURE INTENTIONS were expressed? (prospective)
112+
5. What RELATIONSHIP SIGNALS appeared — vulnerability, trust, conflict, warmth? (relational)
113+
6. Do any of these CONTRADICT existing memories? If so, which is more reliable?
114+
7. What can be MERGED from multiple notes into a single trace?
115+
56116
Rules:
57117
1. Merge redundant or overlapping observations into single traces
58-
2. Assign each trace a type: "episodic" (events), "semantic" (facts/knowledge), "procedural" (how-to), or "prospective" (future goals/intentions)
118+
2. Assign each trace a type: "episodic" (events/experiences), "semantic" (facts/knowledge), "procedural" (how-to/patterns), "prospective" (future intentions/reminders), or "relational" (trust signals, boundary events, emotional bonds, relationship shifts)
59119
3. Assign a scope: "user" (about the user), "thread" (conversation-specific), "persona" (about the agent), or "organization" (shared)
60120
4. ${conflictStrategy}
61121
5. ${memoryStyle}
62-
6. Target 5-40x compression: many notes → few high-quality traces
122+
6. Target 5-40x compression: many notes → few high-quality traces${relationalBlock}
63123
64-
For each trace, output a JSON object on its own line:
124+
After your <thinking> block, output JSON objects, one per line:
65125
{
66-
"type": "episodic|semantic|procedural|prospective",
126+
"reasoning": "brief explanation of why this trace matters",
127+
"type": "episodic|semantic|procedural|prospective|relational",
67128
"scope": "user|thread|persona|organization",
68129
"scopeId": "relevant_id",
69130
"content": "consolidated memory content",
@@ -75,7 +136,7 @@ For each trace, output a JSON object on its own line:
75136
"consumedNotes": ["note_id1", "note_id2"]
76137
}
77138
78-
Output ONLY valid JSON objects, one per line.`;
139+
Output your <thinking> block first, then ONLY valid JSON objects, one per line.`;
79140
}
80141

81142
// ---------------------------------------------------------------------------
@@ -172,23 +233,44 @@ export class MemoryReflector {
172233

173234
// --- Internal ---
174235

236+
/**
237+
* Parse the LLM's reflection response into structured trace data.
238+
*
239+
* Handles:
240+
* - Stripping `<thinking>...</thinking>` blocks (chain-of-thought reasoning)
241+
* - Parsing one JSON object per line
242+
* - Validating and normalizing type/scope enums
243+
* - Preserving the optional `reasoning` field for devtools
244+
* - Collecting superseded and consumed note IDs
245+
*
246+
* @param llmResponse - Raw LLM output containing optional thinking block + JSON lines
247+
* @returns Parsed reflection result with typed traces
248+
*/
175249
private parseReflection(llmResponse: string): MemoryReflectionResult {
176250
const traces: MemoryReflectionResult['traces'] = [];
177251
const supersededTraceIds: string[] = [];
178252
const consumedNoteIds: string[] = [];
179253

180-
const lines = llmResponse.split('\n').filter((l) => l.trim());
254+
// Strip <thinking>...</thinking> blocks before parsing JSON lines.
255+
// The thinking block contains the reflector's chain-of-thought reasoning
256+
// which is useful for debugging but not part of the trace data.
257+
const cleaned = llmResponse.replace(/<thinking>[\s\S]*?<\/thinking>/gi, '');
258+
const lines = cleaned.split('\n').filter((l) => l.trim());
181259
const now = Date.now();
182260

183261
for (const line of lines) {
184262
try {
185263
const parsed = JSON.parse(line.trim());
186264
if (!parsed.content) continue;
187265

266+
// Validate memory type — defaults to 'semantic' for unrecognized types.
267+
// All 5 Tulving-extended types are accepted: episodic, semantic,
268+
// procedural, prospective, and relational.
188269
const type = (['episodic', 'semantic', 'procedural', 'prospective', 'relational'].includes(parsed.type)
189270
? parsed.type
190271
: 'semantic') as MemoryType;
191272

273+
// Validate memory scope — defaults to 'user' for unrecognized scopes.
192274
const scope = (['user', 'thread', 'persona', 'organization'].includes(parsed.scope)
193275
? parsed.scope
194276
: 'user') as MemoryScope;
@@ -215,6 +297,9 @@ export class MemoryReflector {
215297
},
216298
associatedTraceIds: [],
217299
isActive: true,
300+
// Preserve reasoning for devtools — CognitiveMemoryManager strips
301+
// this before passing to encode() since it's not part of MemoryTrace.
302+
reasoning: typeof parsed.reasoning === 'string' ? parsed.reasoning : undefined,
218303
});
219304

220305
if (Array.isArray(parsed.supersedes)) {
@@ -224,11 +309,13 @@ export class MemoryReflector {
224309
consumedNoteIds.push(...parsed.consumedNotes);
225310
}
226311
} catch {
227-
// Skip malformed lines
312+
// Skip malformed lines — common when LLM outputs markdown fences or commentary
228313
}
229314
}
230315

231-
// If no specific notes were claimed, consider all pending consumed
316+
// If no specific notes were claimed, consider all pending consumed.
317+
// This handles the case where the LLM omits consumedNotes fields
318+
// but still produces valid traces from the input notes.
232319
if (consumedNoteIds.length === 0 && traces.length > 0) {
233320
consumedNoteIds.push(...this.pendingNotes.map((n) => n.id));
234321
}
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import { describe, it, expect, vi } from 'vitest';
2+
import { MemoryReflector } from '../MemoryReflector.js';
3+
import type { ObservationNote } from '../MemoryObserver.js';
4+
5+
describe('MemoryReflector', () => {
6+
const defaultTraits = {
7+
honesty: 0.5,
8+
emotionality: 0.8,
9+
extraversion: 0.5,
10+
agreeableness: 0.5,
11+
conscientiousness: 0.5,
12+
openness: 0.5,
13+
};
14+
15+
function makeNote(overrides: Partial<ObservationNote> = {}): ObservationNote {
16+
return {
17+
id: `obs_${Date.now()}_${Math.random()}`,
18+
type: 'emotional',
19+
content: 'User shared they are feeling vulnerable about their job situation',
20+
importance: 0.8,
21+
entities: ['user'],
22+
timestamp: Date.now(),
23+
...overrides,
24+
};
25+
}
26+
27+
it('produces relational traces from emotional/trust signals', async () => {
28+
const llmResponse = JSON.stringify({
29+
reasoning: 'User shared vulnerability about job — relational trust signal',
30+
type: 'relational',
31+
scope: 'user',
32+
scopeId: '',
33+
content: 'User shared vulnerability about job insecurity — trust-building moment',
34+
entities: ['user'],
35+
tags: ['trust', 'vulnerability'],
36+
confidence: 0.85,
37+
sourceType: 'reflection',
38+
supersedes: [],
39+
consumedNotes: ['note-1'],
40+
});
41+
42+
const llmInvoker = vi.fn().mockResolvedValue(llmResponse);
43+
const reflector = new MemoryReflector(defaultTraits, {
44+
activationThresholdTokens: 1,
45+
llmInvoker,
46+
});
47+
48+
const notes = [makeNote({ id: 'note-1' })];
49+
const result = await reflector.addNotes(notes);
50+
51+
expect(result).not.toBeNull();
52+
expect(result!.traces.length).toBeGreaterThanOrEqual(1);
53+
expect(result!.traces[0].type).toBe('relational');
54+
});
55+
56+
it('includes chain-of-thought reasoning in the prompt', async () => {
57+
const llmInvoker = vi.fn().mockResolvedValue('');
58+
const reflector = new MemoryReflector(defaultTraits, {
59+
activationThresholdTokens: 1,
60+
llmInvoker,
61+
});
62+
63+
await reflector.addNotes([makeNote()]);
64+
65+
const systemPrompt = llmInvoker.mock.calls[0][0] as string;
66+
expect(systemPrompt).toContain('<thinking>');
67+
expect(systemPrompt).toContain('RELATIONSHIP SIGNALS');
68+
expect(systemPrompt).toContain('relational');
69+
});
70+
71+
it('includes personality-biased relational sensitivity for high emotionality', async () => {
72+
const llmInvoker = vi.fn().mockResolvedValue('');
73+
const highEmotionality = { ...defaultTraits, emotionality: 0.9 };
74+
const reflector = new MemoryReflector(highEmotionality, {
75+
activationThresholdTokens: 1,
76+
llmInvoker,
77+
});
78+
79+
await reflector.addNotes([makeNote()]);
80+
81+
const systemPrompt = llmInvoker.mock.calls[0][0] as string;
82+
expect(systemPrompt).toContain('emotional subtleties');
83+
});
84+
85+
it('includes personality-biased social dynamics for high extraversion', async () => {
86+
const llmInvoker = vi.fn().mockResolvedValue('');
87+
const highExtraversion = { ...defaultTraits, extraversion: 0.9 };
88+
const reflector = new MemoryReflector(highExtraversion, {
89+
activationThresholdTokens: 1,
90+
llmInvoker,
91+
});
92+
93+
await reflector.addNotes([makeNote()]);
94+
95+
const systemPrompt = llmInvoker.mock.calls[0][0] as string;
96+
expect(systemPrompt).toContain('social dynamics');
97+
});
98+
99+
it('strips <thinking> blocks from LLM response when parsing traces', async () => {
100+
const llmResponse = [
101+
'<thinking>User revealed a fact about being an engineer.</thinking>',
102+
'{"type":"semantic","scope":"user","scopeId":"","content":"User is an engineer","entities":[],"tags":[],"confidence":0.9,"sourceType":"reflection","supersedes":[],"consumedNotes":["n1"]}',
103+
].join('\n');
104+
105+
const llmInvoker = vi.fn().mockResolvedValue(llmResponse);
106+
const reflector = new MemoryReflector(defaultTraits, {
107+
activationThresholdTokens: 1,
108+
llmInvoker,
109+
});
110+
111+
const result = await reflector.addNotes([makeNote({ id: 'n1' })]);
112+
expect(result!.traces.length).toBe(1);
113+
expect(result!.traces[0].content).toBe('User is an engineer');
114+
// Thinking block should not appear as a trace
115+
expect(result!.traces.every(t => !t.content.includes('<thinking>'))).toBe(true);
116+
});
117+
118+
it('preserves reasoning field on trace output for devtools', async () => {
119+
const llmResponse = '{"reasoning":"test reason","type":"semantic","scope":"user","scopeId":"","content":"A fact","entities":[],"tags":[],"confidence":0.9,"sourceType":"reflection","supersedes":[],"consumedNotes":["n1"]}';
120+
121+
const llmInvoker = vi.fn().mockResolvedValue(llmResponse);
122+
const reflector = new MemoryReflector(defaultTraits, {
123+
activationThresholdTokens: 1,
124+
llmInvoker,
125+
});
126+
127+
const result = await reflector.addNotes([makeNote({ id: 'n1' })]);
128+
expect((result!.traces[0] as any).reasoning).toBe('test reason');
129+
});
130+
});

0 commit comments

Comments
 (0)