diff --git a/src/backends/native/orchestrator.test.ts b/src/backends/native/orchestrator.test.ts index a0834b7..d657d28 100644 --- a/src/backends/native/orchestrator.test.ts +++ b/src/backends/native/orchestrator.test.ts @@ -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()', () => { diff --git a/src/backends/native/orchestrator.ts b/src/backends/native/orchestrator.ts index 50779ae..691d22b 100644 --- a/src/backends/native/orchestrator.ts +++ b/src/backends/native/orchestrator.ts @@ -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 = 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 { 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;