feat(memory): inject session context and write working memory after compaction

This commit is contained in:
William Valentin
2026-02-25 12:58:01 -08:00
parent 2d3ac30d3c
commit c658660a91
2 changed files with 173 additions and 2 deletions
+92
View File
@@ -5,6 +5,7 @@ import type { ChatResponse, ModelClient } from '../../models/types.js';
import { ToolRegistry, ToolExecutor } from '../../tools/index.js';
import { HookEngine } from '../../hooks/engine.js';
import { MemoryStore } from '../../memory/store.js';
import { writeWorkingMemory } from '../../memory/workingMemory.js';
import type { Session } from '../../session/index.js';
import { mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os';
@@ -453,6 +454,49 @@ describe('AgentOrchestrator', () => {
rmSync(tempDir, { recursive: true, force: true });
});
it('injects session context from user profile and working memory', async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'flynn-orchestrator-session-context-'));
const memoryStore = new MemoryStore({ dir: tempDir, maxContextTokens: 2000 });
memoryStore.write('user/profile', 'Name: Will', 'replace');
writeWorkingMemory(memoryStore, 'user/working', 'Recent task: build feature X', 14, 1000);
const mockDefaultChatClient = requireClient('default');
const mockDefaultChatFn = vi.fn().mockResolvedValue({
content: 'Agent response',
stopReason: 'end_turn',
usage: { inputTokens: 50, outputTokens: 25 },
} as ChatResponse);
Object.assign(mockDefaultChatClient, { chat: mockDefaultChatFn });
const orchestrator = new AgentOrchestrator({
modelRouter: mockRouter,
systemPrompt: 'You are a helpful agent.',
primaryTier: 'default',
delegation: {
compaction: 'fast',
memory_extraction: 'default',
classification: 'complex',
tool_summarisation: 'default',
complex_reasoning: 'complex',
},
maxDelegationDepth: 10,
memoryStore,
userNamespace: 'user',
workingMemoryTtlDays: 14,
workingMemoryMaxTokens: 1000,
});
await orchestrator.process('Hello');
const callArgs = mockDefaultChatFn.mock.calls[0][0];
expect(callArgs.system).toContain('--- Who you\'re talking to ---');
expect(callArgs.system).toContain('Name: Will');
expect(callArgs.system).toContain('--- Recent context ---');
expect(callArgs.system).toContain('Recent task: build feature X');
rmSync(tempDir, { recursive: true, force: true });
});
it('appends daily memory log entries when enabled', async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'flynn-orchestrator-daily-log-'));
const memoryStore = new MemoryStore({ dir: tempDir, maxContextTokens: 2000 });
@@ -640,6 +684,54 @@ describe('AgentOrchestrator', () => {
initAuditLogger(previousAuditLogger as unknown as AuditLogger);
}
});
it('writes working memory after compaction when user namespace is set', async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'flynn-orchestrator-working-memory-'));
const memoryStore = new MemoryStore({ dir: tempDir, maxContextTokens: 2000 });
const mockFastChatClient = requireClient('fast');
const mockFastChatFn = vi.fn().mockResolvedValue({
content: 'Working summary content',
stopReason: 'end_turn',
usage: { inputTokens: 50, outputTokens: 25 },
} as ChatResponse);
Object.assign(mockFastChatClient, { chat: mockFastChatFn });
const orchestrator = new AgentOrchestrator({
modelRouter: mockRouter,
systemPrompt: 'You are helpful.',
primaryTier: 'default',
delegation: {
compaction: 'fast',
memory_extraction: 'default',
classification: 'complex',
tool_summarisation: 'default',
complex_reasoning: 'complex',
},
maxDelegationDepth: 10,
compaction: {
thresholdPct: 80,
keepTurns: 1,
summaryMaxTokens: 128,
importanceThreshold: 1,
},
memoryStore,
memoryAutoExtract: false,
userNamespace: 'user',
workingMemoryTtlDays: 14,
workingMemoryMaxTokens: 1000,
});
await orchestrator.process('First message');
await orchestrator.process('Second message');
await orchestrator.compact();
const working = memoryStore.read('user/working');
expect(working).toContain('# Working Memory');
expect(working).toContain('Working summary content');
rmSync(tempDir, { recursive: true, force: true });
});
});
describe('reset()', () => {
+81 -2
View File
@@ -15,6 +15,7 @@ import { compactHistory, type CompactionConfig, type CompactionResult, DEFAULT_C
import { estimateCost } from '../../models/costs.js';
import { auditLogger } from '../../audit/index.js';
import { buildAdaptiveMemoryContext, buildRecentMemoryContext } from '../../memory/adaptive.js';
import { readWorkingMemory, writeWorkingMemory } from '../../memory/workingMemory.js';
import { buildUserMessage } from '../../models/media.js';
import { CONTEXT_CHECKPOINT_PROMPT, MEMORY_EXTRACTION_PROMPT } from './prompts.js';
@@ -157,6 +158,14 @@ export interface OrchestratorConfig {
toolPolicyContext?: ToolPolicyContext;
/** Collector for outbound attachments queued by tools (e.g. media.send). */
attachmentCollector?: OutboundAttachmentCollector;
/** Shared identity namespace for cross-channel memory (e.g. 'user'). Absent = session-scoped. */
userNamespace?: string;
/** TTL in days for working memory. Defaults to 14. */
workingMemoryTtlDays?: number;
/** Token budget for working memory injection. Defaults to 1000. */
workingMemoryMaxTokens?: number;
/** When true, instruct the model to acknowledge prior context on session start. */
proactiveSessionGreeting?: boolean;
}
// ── AgentOrchestrator ─────────────────────────────────────────────────
@@ -196,9 +205,14 @@ export class AgentOrchestrator {
private _memoryDailyLogIncludeSessionMetadata: boolean;
private _memoryDailyLogMaxUserChars: number;
private _memoryDailyLogMaxAssistantChars: number;
private _userNamespace?: string;
private _workingMemoryTtlDays: number;
private _workingMemoryMaxTokens: number;
private _proactiveSessionGreeting: boolean;
private _autoEscalate: boolean;
private _autoEscalateTier: ModelTier;
private _systemPromptBase: string;
private _sessionContext: string | null = null;
private _externalOnToolUse?: (event: ToolUseEvent) => void;
private _activeRunToolStarts = 0;
private _usageByTier: Map<string, TierUsageStats> = new Map();
@@ -228,6 +242,10 @@ export class AgentOrchestrator {
this._memoryDailyLogIncludeSessionMetadata = config.memoryDailyLogIncludeSessionMetadata ?? true;
this._memoryDailyLogMaxUserChars = Math.max(100, config.memoryDailyLogMaxUserChars ?? 2000);
this._memoryDailyLogMaxAssistantChars = Math.max(100, config.memoryDailyLogMaxAssistantChars ?? 4000);
this._userNamespace = config.userNamespace;
this._workingMemoryTtlDays = config.workingMemoryTtlDays ?? 14;
this._workingMemoryMaxTokens = config.workingMemoryMaxTokens ?? 1000;
this._proactiveSessionGreeting = config.proactiveSessionGreeting ?? false;
this._autoEscalate = config.autoEscalate ?? false;
this._autoEscalateTier = config.autoEscalateTier ?? 'complex';
this._systemPromptBase = config.systemPrompt;
@@ -346,6 +364,7 @@ export class AgentOrchestrator {
turnAudioInput?: NativeAgentTurnAudioInput,
): Promise<string> {
this._activeRunToolStarts = 0;
this._buildSessionContext();
this._injectMemoryContext(userMessage);
await this._runProactiveContextMaintenance();
await this.compactIfNeeded();
@@ -473,6 +492,8 @@ export class AgentOrchestrator {
config,
memoryStore: this._memoryStore,
autoExtract: this._memoryAutoExtract,
usePersonalAssistantPrompt: Boolean(this._userNamespace),
memoryExtractionNamespace: this._userNamespace ? `${this._userNamespace}/facts` : undefined,
});
// If nothing was actually compacted, skip the replace
@@ -500,6 +521,19 @@ export class AgentOrchestrator {
});
}
// Write working memory when user namespace is configured
if (result.summary && this._userNamespace && this._memoryStore) {
const workingNs = `${this._userNamespace}/working`;
writeWorkingMemory(
this._memoryStore,
workingNs,
result.summary,
this._workingMemoryTtlDays,
this._workingMemoryMaxTokens,
);
console.log(`[Flynn:working-memory] Updated ${workingNs} after compaction`);
}
return result;
}
@@ -510,6 +544,7 @@ export class AgentOrchestrator {
this._lastContextAlertLevel = null;
this._pendingContextAlert = undefined;
this._lastCheckpointAt = 0;
this._sessionContext = null;
}
/** Get the primary agent's conversation history. */
@@ -649,6 +684,9 @@ export class AgentOrchestrator {
return;
}
const effectiveBase = this._sessionContext
? `${this._systemPromptBase}\n\n${this._sessionContext}`
: this._systemPromptBase;
let memoryContext = '';
try {
if (this._memoryInjectionStrategy === 'recent') {
@@ -673,14 +711,55 @@ export class AgentOrchestrator {
memoryContext = this._clipMemoryContext(memoryContext);
if (!memoryContext) {
this._agent.setSystemPrompt(this._systemPromptBase);
this._agent.setSystemPrompt(effectiveBase);
return;
}
const enrichedPrompt = `${this._systemPromptBase}\n\n# Memory Context\n\nThe following is your persistent memory. Use it to maintain continuity across sessions.\n\n${memoryContext}`;
const enrichedPrompt = `${effectiveBase}\n\n# Memory Context\n\nThe following is your persistent memory. Use it to maintain continuity across sessions.\n\n${memoryContext}`;
this._agent.setSystemPrompt(enrichedPrompt);
}
/**
* Build session context from user/profile and user/working memory.
* Called once on first process() call. Returns null if no context available.
* Does NOT mutate _systemPromptBase — the result is stored in _sessionContext
* and composed into the system prompt by _injectMemoryContext().
*/
private _buildSessionContext(): void {
if (this._sessionContext !== null || !this._memoryStore || !this._userNamespace) {
return;
}
const sections: string[] = [];
// User profile block
const profile = this._memoryStore.read(`${this._userNamespace}/profile`);
if (profile.length > 0) {
sections.push(`--- Who you're talking to ---\n${profile}`);
}
// Working memory block
const working = readWorkingMemory(this._memoryStore, `${this._userNamespace}/working`);
if (working) {
sections.push(`--- Recent context ---\n${working.content}`);
}
if (sections.length === 0) {
// Set to empty string (not null) to indicate we've run but found nothing.
// Null means "not yet computed".
this._sessionContext = '';
return;
}
let ctx = sections.join('\n\n');
if (this._proactiveSessionGreeting) {
ctx += '\n\n[If relevant, briefly acknowledge what the user was last working on before responding to their first message.]';
}
this._sessionContext = ctx;
}
private _clipMemoryContext(context: string): string {
if (!context) {
return context;