diff --git a/config/default.yaml b/config/default.yaml index 11c1dfe..96c6bc0 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -80,6 +80,30 @@ hooks: silent: - notify +# ── Prompt Assembly ─────────────────────────────────────────────────── +# Tune how much context Flynn loads into the system prompt. +# +# prompt: +# search_dirs: [] +# extra_sections: [] +# context_level: normal # minimal | normal | detailed | debug + +# skills: +# # Global installer execution policy. +# # disabled: never run installer commands (default) +# # enabled: allow command execution only with --execute --confirm +# installation_execution: disabled +# # Allow shell-based installer runner when --runner shell is requested. +# allow_shell_runner: false +# # Allowlist command patterns for shell runner (`*` wildcard supported). +# # Empty list means no shell commands are allowed. +# shell_runner_allowlist: [] +# # Governance metadata for shell-runner allowlist and rollout decisions. +# shell_runner_governance: +# owner: "skills-team" # Required when allow_shell_runner is true +# review_cadence_days: 7 # Review `skills rollout-status` at this cadence +# promotion_min_success_rate: 0.9 # Rollout threshold for broader enablement + # ── Automation ────────────────────────────────────────────────────── # Uncomment and configure any automation sources you need. diff --git a/docs/plans/2026-02-11-model-persistence-per-session.md b/docs/plans/2026-02-11-model-persistence-per-session.md index bfb34be..5f461f8 100644 --- a/docs/plans/2026-02-11-model-persistence-per-session.md +++ b/docs/plans/2026-02-11-model-persistence-per-session.md @@ -1,5 +1,7 @@ # Plan: Model Persistence with Per-Session Overrides +Status: implemented (2026-02-13) + ## Summary This plan fixes model tier persistence so it works correctly across TUI, WebChat, and Telegram channels, and adds per-session model overrides that survive daemon restarts. Currently the model tier is a single global preference stored in `preferences.json` and propagated via `ModelRouter.setTier()`. This creates three problems: @@ -823,18 +825,20 @@ The `ToolUseEvent` type is the same for both. ## Testing +Validation run on 2026-02-13: `pnpm typecheck`, targeted model-persistence tests, full `pnpm test:run` (1586/1586), and `pnpm build` all passing. + ### Unit Tests -- [ ] **`src/session/store.test.ts`**: Test `session_config` CRUD — set, get, getAll, delete, clearAll -- [ ] **`src/session/store.test.ts`**: Test `clearSession()` also clears config -- [ ] **`src/session/store.test.ts`**: Test `pruneStale()` also cleans config -- [ ] **`src/session/manager.test.ts`**: Test `ManagedSession.getConfig/setConfig/deleteConfig` -- [ ] **`src/daemon/routing.test.ts`**: Test model resolution chain (session → agent → global) -- [ ] **`src/daemon/routing.test.ts`**: Test `/model` command sets session config and updates agent tier -- [ ] **`src/daemon/routing.test.ts`**: Test `/reset` command clears session config -- [ ] **`src/gateway/session-bridge.test.ts`**: Test that tier changes in TUI do NOT affect WebChat sessions -- [ ] **`src/gateway/session-bridge.test.ts`**: Test that new agents load tier from session config -- [ ] **`src/gateway/handlers/handlers.test.ts`**: Test `agent.send` with model command +- [x] **`src/session/store.test.ts`**: Test `session_config` CRUD — set, get, getAll, delete, clearAll +- [x] **`src/session/store.test.ts`**: Test `clearSession()` also clears config +- [x] **`src/session/store.test.ts`**: Test `pruneStale()` also cleans config +- [x] **`src/session/manager.test.ts`**: Test `ManagedSession.getConfig/setConfig/deleteConfig` +- [x] **`src/daemon/routing.test.ts`**: Test model resolution chain (session → agent → global) +- [x] **`src/daemon/routing.test.ts`**: Test `/model` command sets session config and updates agent tier +- [x] **`src/daemon/routing.test.ts`**: Test `/reset` command clears session config +- [x] **`src/gateway/session-bridge.test.ts`**: Test that tier changes in TUI do NOT affect WebChat sessions +- [x] **`src/gateway/session-bridge.test.ts`**: Test that new agents load tier from session config +- [x] **`src/gateway/handlers/agent.test.ts`**: Test `agent.send` with model command ### Integration Tests diff --git a/docs/plans/state.json b/docs/plans/state.json index dfbbad6..f1453f1 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -1,6 +1,6 @@ { "version": "1.0", - "updated_at": "2026-02-12", + "updated_at": "2026-02-13", "description": "Tracks the status of all Flynn plans and implementation phases", "plans": { @@ -1195,6 +1195,51 @@ ], "test_status": "typecheck + targeted guardrails/autonomy/executor/engine/schema/template tests + full suite passing (1490/1490); lint passing baseline (394 warnings, 0 errors); build passing" }, + "model-persistence-per-session": { + "file": "2026-02-11-model-persistence-per-session.md", + "status": "completed", + "date": "2026-02-13", + "summary": "Implemented per-session model tier persistence across routing, gateway, and Telegram by adding SQLite session config storage and wiring /model and /reset command fast-paths to persist/clear model overrides.", + "files_modified": [ + "src/session/store.ts", + "src/session/store.test.ts", + "src/session/manager.ts", + "src/session/manager.test.ts", + "src/session/index.ts", + "src/daemon/routing.ts", + "src/daemon/routing.test.ts", + "src/gateway/session-bridge.ts", + "src/gateway/session-bridge.test.ts", + "src/gateway/handlers/agent.ts", + "src/gateway/handlers/agent.test.ts", + "src/gateway/server.ts", + "src/gateway/handlers/index.ts", + "src/channels/telegram/adapter.ts", + "src/daemon/index.ts", + "src/daemon/services.ts", + "src/config/schema.ts", + "src/config/schema.test.ts", + "src/backends/native/orchestrator.ts", + "src/backends/native/orchestrator.test.ts", + "src/context/compaction.ts", + "src/context/compaction.test.ts", + "src/memory/store.ts", + "src/memory/store.test.ts", + "src/memory/index.ts", + "src/tools/builtin/memory-read.ts", + "src/tools/builtin/memory-search.ts", + "src/tools/builtin/memory-write.ts", + "src/models/capabilities.ts", + "src/automation/cron.ts", + "src/automation/heartbeat.ts", + "src/cli/tui.ts", + "src/audit/types.ts", + "src/audit/logger.ts", + "src/audit/rotation.ts", + "config/default.yaml" + ], + "test_status": "pnpm typecheck + pnpm test:run (1586/1586) + pnpm build passing" + }, "skills_infrastructure": { "file": "2026-02-11-skills-infrastructure-plan.md", "status": "planned", diff --git a/src/audit/logger.ts b/src/audit/logger.ts index 8753525..4adbcd8 100644 --- a/src/audit/logger.ts +++ b/src/audit/logger.ts @@ -7,6 +7,8 @@ import type { ToolSuccessEvent, ToolErrorEvent, ToolDeniedEvent, + SkillsInstallerExecutionBlockedEvent, + SkillsInstallerCommandResultEvent, SessionCreateEvent, SessionMessageEvent, SessionDeleteEvent, @@ -30,7 +32,7 @@ export class AuditLogger { constructor(config: AuditConfig) { this.config = config; this.rotator = new AuditRotator(config); - + if (!this.config.enabled) { return; } @@ -53,7 +55,7 @@ export class AuditLogger { } this.rotator.checkRotation(); - + const fullEvent: AuditEvent = { ...event, timestamp: Date.now() }; this.writeStream!.write(JSON.stringify(fullEvent) + '\n'); } @@ -67,49 +69,68 @@ export class AuditLogger { // ── Tool Events ─────────────────────────────────────────────── toolStart(event: ToolStartEvent): void { - if (!this.shouldLog('tools', 'debug')) return; + if (!this.shouldLog('tools', 'debug')) {return;} this.write({ level: 'debug', event_type: 'tool.start', event: event as unknown as Record }); } toolSuccess(event: ToolSuccessEvent): void { - if (!this.shouldLog('tools', 'debug')) return; + if (!this.shouldLog('tools', 'debug')) {return;} this.write({ level: 'debug', event_type: 'tool.success', event: event as unknown as Record }); } toolError(event: ToolErrorEvent): void { - if (!this.shouldLog('tools', 'error')) return; + if (!this.shouldLog('tools', 'error')) {return;} this.write({ level: 'error', event_type: 'tool.error', event: event as unknown as Record }); } toolDenied(event: ToolDeniedEvent): void { - if (!this.shouldLog('tools', 'warn')) return; + if (!this.shouldLog('tools', 'warn')) {return;} this.write({ level: 'warn', event_type: 'tool.denied', event: event as unknown as Record }); } + skillsInstallerExecutionBlocked(event: SkillsInstallerExecutionBlockedEvent): void { + if (!this.shouldLog('tools', 'warn')) {return;} + this.write({ + level: 'warn', + event_type: 'skills.installer.execution_blocked', + event: event as unknown as Record, + }); + } + + skillsInstallerCommandResult(event: SkillsInstallerCommandResultEvent): void { + const level = event.status === 'succeeded' ? 'debug' : event.reason === 'allowlist_blocked' ? 'warn' : 'error'; + if (!this.shouldLog('tools', level)) {return;} + this.write({ + level, + event_type: 'skills.installer.command_result', + event: event as unknown as Record, + }); + } + // ── Session Events ─────────────────────────────────────────── sessionCreate(event: SessionCreateEvent): void { - if (!this.shouldLog('sessions', 'debug')) return; + if (!this.shouldLog('sessions', 'debug')) {return;} this.write({ level: 'debug', event_type: 'session.create', event: event as unknown as Record }); } sessionMessage(event: SessionMessageEvent): void { - if (!this.shouldLog('sessions', 'debug')) return; + if (!this.shouldLog('sessions', 'debug')) {return;} this.write({ level: 'debug', event_type: 'session.message', event: event as unknown as Record }); } sessionDelete(event: SessionDeleteEvent): void { - if (!this.shouldLog('sessions', 'debug')) return; + if (!this.shouldLog('sessions', 'debug')) {return;} this.write({ level: 'debug', event_type: 'session.delete', event: event as unknown as Record }); } sessionCompact(event: SessionCompactEvent): void { - if (!this.shouldLog('sessions', 'debug')) return; + if (!this.shouldLog('sessions', 'debug')) {return;} this.write({ level: 'debug', event_type: 'session.compact', event: event as unknown as Record }); } sessionTransfer(from: string, to: string, messageCount: number): void { - if (!this.shouldLog('sessions', 'debug')) return; + if (!this.shouldLog('sessions', 'debug')) {return;} this.write({ level: 'debug', event_type: 'session.transfer', @@ -121,12 +142,12 @@ export class AuditLogger { // Cron cronTrigger(event: CronTriggerEvent): void { - if (!this.shouldLog('automation', 'debug')) return; + if (!this.shouldLog('automation', 'debug')) {return;} this.write({ level: 'debug', event_type: 'cron.trigger', event: event as unknown as Record }); } cronAdd(jobName: string, schedule: string): void { - if (!this.shouldLog('automation', 'info')) return; + if (!this.shouldLog('automation', 'info')) {return;} this.write({ level: 'info', event_type: 'cron.add', @@ -135,7 +156,7 @@ export class AuditLogger { } cronRemove(jobName: string): void { - if (!this.shouldLog('automation', 'info')) return; + if (!this.shouldLog('automation', 'info')) {return;} this.write({ level: 'info', event_type: 'cron.remove', @@ -145,12 +166,12 @@ export class AuditLogger { // Webhook webhookReceive(event: WebhookReceiveEvent): void { - if (!this.shouldLog('automation', 'debug')) return; + if (!this.shouldLog('automation', 'debug')) {return;} this.write({ level: 'debug', event_type: 'webhook.receive', event: event as unknown as Record }); } webhookNotFound(webhookName: string): void { - if (!this.shouldLog('automation', 'warn')) return; + if (!this.shouldLog('automation', 'warn')) {return;} this.write({ level: 'warn', event_type: 'webhook.not_found', @@ -159,7 +180,7 @@ export class AuditLogger { } webhookDenied(webhookName: string, reason: string): void { - if (!this.shouldLog('automation', 'warn')) return; + if (!this.shouldLog('automation', 'warn')) {return;} this.write({ level: 'warn', event_type: 'webhook.denied', @@ -169,38 +190,38 @@ export class AuditLogger { // Heartbeat heartbeatCycle(event: HeartbeatCycleEvent): void { - if (!this.shouldLog('automation', 'debug')) return; + if (!this.shouldLog('automation', 'debug')) {return;} this.write({ level: 'debug', event_type: 'heartbeat.cycle', event: event as unknown as Record }); } heartbeatCheck(event: HeartbeatCheckEvent): void { - if (!this.shouldLog('automation', 'debug')) return; + if (!this.shouldLog('automation', 'debug')) {return;} this.write({ level: 'debug', event_type: 'heartbeat.check', event: event as unknown as Record }); } heartbeatFail(event: HeartbeatFailEvent): void { - if (!this.shouldLog('automation', 'warn')) return; + if (!this.shouldLog('automation', 'warn')) {return;} this.write({ level: 'warn', event_type: 'heartbeat.fail', event: event as unknown as Record }); } heartbeatRecover(event: HeartbeatRecoverEvent): void { - if (!this.shouldLog('automation', 'info')) return; + if (!this.shouldLog('automation', 'info')) {return;} this.write({ level: 'info', event_type: 'heartbeat.recover', event: event as unknown as Record }); } // Gmail gmailPoll(event: GmailPollEvent): void { - if (!this.shouldLog('automation', 'debug')) return; + if (!this.shouldLog('automation', 'debug')) {return;} this.write({ level: 'debug', event_type: 'gmail.poll', event: event as unknown as Record }); } gmailNewEmail(event: GmailNewEmailEvent): void { - if (!this.shouldLog('automation', 'debug')) return; + if (!this.shouldLog('automation', 'debug')) {return;} this.write({ level: 'debug', event_type: 'gmail.new_email', event: event as unknown as Record }); } gmailError(error: string, context?: string): void { - if (!this.shouldLog('automation', 'error')) return; + if (!this.shouldLog('automation', 'error')) {return;} this.write({ level: 'error', event_type: 'gmail.error', @@ -211,7 +232,7 @@ export class AuditLogger { // ── System Events ──────────────────────────────────────────── systemStart(component: string, config?: Record): void { - if (!this.config.enabled) return; + if (!this.config.enabled) {return;} this.write({ level: 'info', event_type: 'system.start', @@ -220,7 +241,7 @@ export class AuditLogger { } systemStop(component: string, reason?: string): void { - if (!this.config.enabled) return; + if (!this.config.enabled) {return;} this.write({ level: 'info', event_type: 'system.stop', @@ -229,7 +250,7 @@ export class AuditLogger { } systemConfig(component: string, action: string, config: Record): void { - if (!this.config.enabled) return; + if (!this.config.enabled) {return;} this.write({ level: 'info', event_type: 'system.config', diff --git a/src/audit/rotation.ts b/src/audit/rotation.ts index 2ac302b..893a1dc 100644 --- a/src/audit/rotation.ts +++ b/src/audit/rotation.ts @@ -55,14 +55,14 @@ export class AuditRotator { if (existsSync(compressedPath)) { fs.unlink(compressedPath); } - + fs.rename(basePath, rotatedPath); // Compress the rotated file const gzip = createGzip(); const input = createReadStream(rotatedPath); const output = createWriteStream(compressedPath); - + pipeline(input, gzip, output).then(() => { fs.unlink(rotatedPath); }).catch((err) => { @@ -84,9 +84,9 @@ export class AuditRotator { try { const files = await fs.readdir(dir); - + for (const file of files) { - if (!file.startsWith(baseName)) continue; + if (!file.startsWith(baseName)) {continue;} const filePath = `${dir}/${file}`; const stats = await fs.stat(filePath); diff --git a/src/audit/types.ts b/src/audit/types.ts index 446820a..8c4228c 100644 --- a/src/audit/types.ts +++ b/src/audit/types.ts @@ -3,6 +3,8 @@ export type AuditLevel = 'debug' | 'info' | 'warn' | 'error'; export type AuditEventType = // Tool execution | 'tool.start' | 'tool.success' | 'tool.error' | 'tool.denied' + // Skills installer + | 'skills.installer.execution_blocked' | 'skills.installer.command_result' // Session lifecycle | 'session.create' | 'session.message' | 'session.delete' | 'session.transfer' | 'session.compact' // Automation - Cron @@ -75,6 +77,24 @@ export interface ToolDeniedEvent { denial_type: 'policy' | 'hook' | 'not_found' | 'autonomy_override'; } +export interface SkillsInstallerExecutionBlockedEvent { + skill_name: string; + phase: 'install' | 'execute'; + execution_requested: boolean; + execution_enabled: boolean; + reason: string; + attempted_command_count: number; +} + +export interface SkillsInstallerCommandResultEvent { + skill_name: string; + phase: 'install' | 'execute'; + installer_type: string; + command: string; + status: 'blocked' | 'skipped' | 'succeeded' | 'failed'; + reason: string; +} + export interface SessionCreateEvent { session_id: string; frontend: string; diff --git a/src/automation/cron.ts b/src/automation/cron.ts index 8097956..4b33754 100644 --- a/src/automation/cron.ts +++ b/src/automation/cron.ts @@ -161,9 +161,9 @@ export class CronScheduler implements ChannelAdapter { } this.jobs.delete(name); - + auditLogger?.cronRemove(name); - + return true; } } diff --git a/src/automation/heartbeat.ts b/src/automation/heartbeat.ts index 9084273..e3d0973 100644 --- a/src/automation/heartbeat.ts +++ b/src/automation/heartbeat.ts @@ -163,7 +163,7 @@ export class HeartbeatMonitor { this.notifiedFailure = true; const failedChecks = checks.filter((c) => !c.healthy).map((c) => `${c.name}: ${c.message}`); await this.notify(`Heartbeat FAILING (${this.consecutiveFailures} consecutive failures):\n${failedChecks.join('\n')}`); - + auditLogger?.heartbeatFail({ checks_failed: failedChecks, consecutive_failures: this.consecutiveFailures, @@ -174,7 +174,7 @@ export class HeartbeatMonitor { if (this.notifiedFailure) { // Recovery notification await this.notify(`Heartbeat RECOVERED after ${this.consecutiveFailures} consecutive failure(s). All checks passing.`); - + auditLogger?.heartbeatRecover({ consecutive_failures_before: this.consecutiveFailures, }); diff --git a/src/backends/native/orchestrator.test.ts b/src/backends/native/orchestrator.test.ts index ff046da..6db3be7 100644 --- a/src/backends/native/orchestrator.test.ts +++ b/src/backends/native/orchestrator.test.ts @@ -4,7 +4,10 @@ import { ModelRouter } from '../../models/router.js'; import type { ChatResponse, ModelClient } from '../../models/types.js'; import { ToolRegistry, ToolExecutor } from '../../tools/index.js'; import { HookEngine } from '../../hooks/engine.js'; -import type { SubAgentRequest } from './orchestrator.js'; +import { MemoryStore } from '../../memory/store.js'; +import { mkdtempSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; describe('AgentOrchestrator', () => { let mockDefaultClient: ModelClient; @@ -33,6 +36,14 @@ describe('AgentOrchestrator', () => { }); }); + const requireClient = (tier: 'default' | 'fast' | 'complex'): ModelClient => { + const client = mockRouter.getClient(tier); + if (!client) { + throw new Error(`Expected ${tier} model client to exist in test router`); + } + return client; + }; + describe('delegate()', () => { it('routes to the correct tier when specified', async () => { const orchestrator = new AgentOrchestrator({ @@ -69,7 +80,7 @@ describe('AgentOrchestrator', () => { }); const mockToolExecutor = new ToolExecutor(mockToolRegistry, hooks); - const mockFastChatClient = mockRouter.getClient('fast')!; + const mockFastChatClient = requireClient('fast'); const mockFastChatFn = vi.fn().mockResolvedValue({ content: 'response with tools', stopReason: 'end_turn', @@ -298,7 +309,7 @@ describe('AgentOrchestrator', () => { describe('process()', () => { it('proxies to NativeAgent for user messages', async () => { - const mockDefaultChatClient = mockRouter.getClient('default')!; + const mockDefaultChatClient = requireClient('default'); const mockDefaultChatFn = vi.fn().mockResolvedValue({ content: 'Agent response', stopReason: 'end_turn', @@ -355,6 +366,88 @@ describe('AgentOrchestrator', () => { expect(history[4]).toEqual({ role: 'user', content: 'Tell me about yourself' }); expect(history[5]).toEqual({ role: 'assistant', content: 'default response' }); }); + + it('uses adaptive memory injection strategy when configured', async () => { + const tempDir = mkdtempSync(join(tmpdir(), 'flynn-orchestrator-memory-')); + const memoryStore = new MemoryStore({ dir: tempDir, maxContextTokens: 2000 }); + memoryStore.writeCategory('user', 'preferences', 'User prefers concise output.', 'replace'); + + 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, + memoryInjectionStrategy: 'adaptive', + memoryMaxInjectionTokens: 100, + }); + + await orchestrator.process('Keep this concise please'); + + expect(mockDefaultChatFn).toHaveBeenCalled(); + const callArgs = mockDefaultChatFn.mock.calls[0][0]; + expect(callArgs.system).toContain('# Memory Context'); + expect(callArgs.system).toContain('concise'); + + rmSync(tempDir, { recursive: true, force: true }); + }); + + it('falls back to default memory context when adaptive injection errors', async () => { + const tempDir = mkdtempSync(join(tmpdir(), 'flynn-orchestrator-memory-fallback-')); + const memoryStore = new MemoryStore({ dir: tempDir, maxContextTokens: 2000 }); + memoryStore.write('user', 'Fallback memory content', 'replace'); + const getPromptSectionsSpy = vi.spyOn(memoryStore, 'getPromptSections').mockImplementationOnce(() => { + throw new Error('boom'); + }); + + 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, + memoryInjectionStrategy: 'adaptive', + memoryMaxInjectionTokens: 100, + }); + + await orchestrator.process('test message'); + + const callArgs = mockDefaultChatFn.mock.calls[0][0]; + expect(callArgs.system).toContain('Fallback memory content'); + + getPromptSectionsSpy.mockRestore(); + rmSync(tempDir, { recursive: true, force: true }); + }); }); describe('reset()', () => { diff --git a/src/backends/native/orchestrator.ts b/src/backends/native/orchestrator.ts index 7108c81..dd3e5e1 100644 --- a/src/backends/native/orchestrator.ts +++ b/src/backends/native/orchestrator.ts @@ -13,6 +13,7 @@ import { shouldCompact } from '../../context/tokens.js'; import { compactHistory, type CompactionConfig, type CompactionResult, DEFAULT_COMPACTION_CONFIG } from '../../context/compaction.js'; import { estimateCost } from '../../models/costs.js'; import { auditLogger } from '../../audit/index.js'; +import { buildAdaptiveMemoryContext, buildRecentMemoryContext } from '../../memory/adaptive.js'; // ── Public types ────────────────────────────────────────────────────── @@ -91,6 +92,10 @@ export interface OrchestratorConfig { contextWindow?: number; /** Optional memory store for injecting persistent memory into the system prompt. */ memoryStore?: MemoryStore; + /** Strategy for memory prompt injection. */ + memoryInjectionStrategy?: 'all' | 'recent' | 'adaptive'; + /** Maximum tokens allowed for injected memory context. */ + memoryMaxInjectionTokens?: number; /** Policy context for tool filtering (agent tier, provider). */ toolPolicyContext?: ToolPolicyContext; /** Collector for outbound attachments queued by tools (e.g. media.send). */ @@ -118,6 +123,8 @@ export class AgentOrchestrator { private _modelName?: string; private _contextWindow?: number; private _memoryStore?: MemoryStore; + private _memoryInjectionStrategy: 'all' | 'recent' | 'adaptive'; + private _memoryMaxInjectionTokens: number; private _systemPromptBase: string; private _usageByTier: Map = new Map(); @@ -131,6 +138,8 @@ export class AgentOrchestrator { this._modelName = config.modelName; this._contextWindow = config.contextWindow; this._memoryStore = config.memoryStore; + this._memoryInjectionStrategy = config.memoryInjectionStrategy ?? 'all'; + this._memoryMaxInjectionTokens = config.memoryMaxInjectionTokens ?? 2000; this._systemPromptBase = config.systemPrompt; // Create the primary NativeAgent for user-facing conversation @@ -216,7 +225,7 @@ export class AgentOrchestrator { * exceeds the context window threshold and compacts it before processing. */ async process(userMessage: string, attachments?: Attachment[]): Promise { - this._injectMemoryContext(); + this._injectMemoryContext(userMessage); await this.compactIfNeeded(); return this._agent.process(userMessage, attachments); } @@ -355,12 +364,34 @@ export class AgentOrchestrator { * system prompt. If no memory store is configured or no memory content * exists, restores the original base prompt. */ - private _injectMemoryContext(): void { + private _injectMemoryContext(userMessage: string): void { if (!this._memoryStore) { return; } - const memoryContext = this._memoryStore.getContextForPrompt(); + let memoryContext = ''; + try { + if (this._memoryInjectionStrategy === 'recent') { + memoryContext = buildRecentMemoryContext(this._memoryStore, this._memoryMaxInjectionTokens); + } else if (this._memoryInjectionStrategy === 'adaptive') { + memoryContext = buildAdaptiveMemoryContext({ + store: this._memoryStore, + userMessage, + recentMessages: this.getHistory(), + config: { + maxTokens: this._memoryMaxInjectionTokens, + }, + }); + } else { + memoryContext = this._memoryStore.getContextForPrompt(); + } + } catch (error) { + console.warn('[Flynn:memory] Adaptive memory injection failed, falling back to default context:', error); + memoryContext = this._memoryStore.getContextForPrompt(); + } + + memoryContext = this._clipMemoryContext(memoryContext); + if (!memoryContext) { this._agent.setSystemPrompt(this._systemPromptBase); return; @@ -370,6 +401,17 @@ export class AgentOrchestrator { this._agent.setSystemPrompt(enrichedPrompt); } + private _clipMemoryContext(context: string): string { + if (!context) { + return context; + } + const maxChars = this._memoryMaxInjectionTokens * 4; + if (context.length <= maxChars) { + return context; + } + return context.slice(0, maxChars); + } + /** * Check whether automatic compaction should run, and if so, compact. * Called before each `process()` call when compaction is configured. diff --git a/src/channels/telegram/adapter.ts b/src/channels/telegram/adapter.ts index 7d5e119..32a556a 100644 --- a/src/channels/telegram/adapter.ts +++ b/src/channels/telegram/adapter.ts @@ -164,7 +164,7 @@ export class TelegramAdapter implements ChannelAdapter { }); this.bot.command('model', async (ctx) => { - if (!this.messageHandler) return; + if (!this.messageHandler) {return;} const args = ctx.message?.text?.replace(/^\/model\s*/, '').trim() ?? ''; @@ -184,7 +184,7 @@ export class TelegramAdapter implements ChannelAdapter { }); this.bot.command('local', async (ctx) => { - if (!this.messageHandler) return; + if (!this.messageHandler) {return;} this.messageHandler({ id: String(ctx.message?.message_id ?? Date.now()), channel: 'telegram', @@ -197,7 +197,7 @@ export class TelegramAdapter implements ChannelAdapter { }); this.bot.command('cloud', async (ctx) => { - if (!this.messageHandler) return; + if (!this.messageHandler) {return;} this.messageHandler({ id: String(ctx.message?.message_id ?? Date.now()), channel: 'telegram', diff --git a/src/cli/tui.ts b/src/cli/tui.ts index 3013f98..4805f10 100644 --- a/src/cli/tui.ts +++ b/src/cli/tui.ts @@ -82,7 +82,7 @@ export function registerTuiCommand(program: Command): void { setLogLevel(tuiLogLevel); const { MinimalTui, startFullscreenTui } = await import('../frontends/tui/index.js'); const { NativeAgent } = await import('../backends/index.js'); - const { ToolRegistry, ToolExecutor, allBuiltinTools, createWebSearchTools, createProcessTools, ProcessManager, createGmailTools, createGcalTools, createGdocsTools, createGdriveTools, createGtasksTools } = await import('../tools/index.js'); + const { ToolRegistry, ToolExecutor, ToolPolicy, allBuiltinTools, createWebSearchTools, createProcessTools, ProcessManager, createGmailTools, createGcalTools, createGdocsTools, createGdriveTools, createGtasksTools } = await import('../tools/index.js'); const { HookEngine } = await import('../hooks/index.js'); const { createModelRouter } = await import('../daemon/index.js'); @@ -174,6 +174,8 @@ export function registerTuiCommand(program: Command): void { } } + toolRegistry.setPolicy(new ToolPolicy(config.tools)); + const toolExecutor = new ToolExecutor(toolRegistry, hookEngine); const session = sessionManager.getSession('tui', 'local'); diff --git a/src/config/schema.test.ts b/src/config/schema.test.ts index 42e45f8..c1e84b9 100644 --- a/src/config/schema.test.ts +++ b/src/config/schema.test.ts @@ -150,6 +150,35 @@ describe('configSchema — skills watcher', () => { const result = configSchema.parse(minimalConfig); expect(result.skills.load.watch).toBe(false); expect(result.skills.load.watch_debounce_ms).toBe(250); + expect(result.skills.installation_execution).toBe('disabled'); + expect(result.skills.allow_shell_runner).toBe(false); + expect(result.skills.shell_runner_allowlist).toEqual([]); + expect(result.skills.shell_runner_governance.owner).toBeUndefined(); + expect(result.skills.shell_runner_governance.review_cadence_days).toBe(7); + expect(result.skills.shell_runner_governance.promotion_min_success_rate).toBe(0.9); + }); + + it('accepts explicit installation execution policy', () => { + const enabled = configSchema.parse({ + ...minimalConfig, + skills: { + installation_execution: 'enabled', + allow_shell_runner: true, + shell_runner_allowlist: ['npm install*'], + shell_runner_governance: { + owner: 'skills-team', + review_cadence_days: 14, + promotion_min_success_rate: 0.95, + }, + }, + }); + + expect(enabled.skills.installation_execution).toBe('enabled'); + expect(enabled.skills.allow_shell_runner).toBe(true); + expect(enabled.skills.shell_runner_allowlist).toEqual(['npm install*']); + expect(enabled.skills.shell_runner_governance.owner).toBe('skills-team'); + expect(enabled.skills.shell_runner_governance.review_cadence_days).toBe(14); + expect(enabled.skills.shell_runner_governance.promotion_min_success_rate).toBe(0.95); }); it('accepts explicit watcher settings', () => { diff --git a/src/config/schema.ts b/src/config/schema.ts index fdd5853..ebcd00b 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -108,6 +108,15 @@ const skillsLoadSchema = z.object({ watch_debounce_ms: z.number().min(10).max(10_000).default(250), }).default({}); +const skillsShellRunnerGovernanceSchema = z.object({ + /** Responsible owner for shell-runner allowlist decisions. */ + owner: z.string().min(1).optional(), + /** Review cadence for allowlist + rollout status checks. */ + review_cadence_days: z.number().min(1).max(90).default(7), + /** Minimum success rate required before broader rollout. */ + promotion_min_success_rate: z.number().min(0).max(1).default(0.9), +}).default({}); + const skillsSchema = z.object({ /** Directory for user-created workspace skills. */ workspace_dir: z.string().optional(), @@ -115,6 +124,14 @@ const skillsSchema = z.object({ managed_dir: z.string().optional(), /** Directory for bundled skills shipped with Flynn. */ bundled_dir: z.string().optional(), + /** Global policy gate for installer command execution. */ + installation_execution: z.enum(['disabled', 'enabled']).default('disabled'), + /** Allow use of the shell runner for installer commands. */ + allow_shell_runner: z.boolean().default(false), + /** Allowlist patterns for shell runner commands (supports '*' wildcard). */ + shell_runner_allowlist: z.array(z.string()).default([]), + /** Governance controls for shell-runner rollout decisions. */ + shell_runner_governance: skillsShellRunnerGovernanceSchema, /** Skills watcher settings. */ load: skillsLoadSchema, }).default({}); diff --git a/src/context/compaction.test.ts b/src/context/compaction.test.ts index da797da..98693d9 100644 --- a/src/context/compaction.test.ts +++ b/src/context/compaction.test.ts @@ -31,6 +31,7 @@ describe('compactHistory', () => { thresholdPct: 80, keepTurns: 2, // keeps last 4 messages summaryMaxTokens: 1024, + importanceThreshold: 1, }; it('returns no-op when messages count is at or below keepTurns threshold', async () => { @@ -100,6 +101,7 @@ describe('compactHistory', () => { expect(DEFAULT_COMPACTION_CONFIG.thresholdPct).toBe(80); expect(DEFAULT_COMPACTION_CONFIG.keepTurns).toBe(4); expect(DEFAULT_COMPACTION_CONFIG.summaryMaxTokens).toBe(1024); + expect(DEFAULT_COMPACTION_CONFIG.importanceThreshold).toBe(1); }); it('shifts leading assistant messages from toKeep into toCompact to ensure user-first', async () => { @@ -120,4 +122,28 @@ describe('compactHistory', () => { expect(result.messages[1].role).toBe('user'); expect(result.messages[1].content).toBe('Message 6'); }); + + it('preserves high-importance older turns instead of compacting them', async () => { + const messages: Message[] = [ + { role: 'user', content: 'hello' }, + { role: 'assistant', content: 'hi' }, + { role: 'user', content: 'I prefer concise responses and markdown tables.' }, + { role: 'assistant', content: 'noted' }, + { role: 'user', content: 'Message 4' }, + { role: 'assistant', content: 'Message 5' }, + { role: 'user', content: 'Message 6' }, + { role: 'assistant', content: 'Message 7' }, + ]; + + const orchestrator = makeMockOrchestrator(); + const result = await compactHistory({ + messages, + orchestrator, + config: { ...config, importanceThreshold: 0.45 }, + }); + + expect(result.messages.some(msg => typeof msg.content === 'string' && msg.content.includes('I prefer concise responses'))).toBe(true); + expect(result.messages.some(msg => typeof msg.content === 'string' && msg.content.includes('[Summary of earlier conversation]'))).toBe(true); + expect(result.messages.length).toBeGreaterThan(5); + }); }); diff --git a/src/context/compaction.ts b/src/context/compaction.ts index cd867d7..9173786 100644 --- a/src/context/compaction.ts +++ b/src/context/compaction.ts @@ -4,6 +4,7 @@ import type { MemoryStore } from '../memory/store.js'; import { COMPACTION_SYSTEM_PROMPT, MEMORY_EXTRACTION_PROMPT } from '../backends/native/prompts.js'; import { estimateMessageTokens } from './tokens.js'; import { getMessageText } from '../models/media.js'; +import { selectImportantMessages } from './weighting.js'; export interface CompactionConfig { /** Percentage of context window that triggers compaction (default: 80). */ @@ -12,6 +13,8 @@ export interface CompactionConfig { keepTurns: number; /** Maximum tokens for the compaction summary response. */ summaryMaxTokens: number; + /** Preserve messages at or above this importance score from compaction. */ + importanceThreshold: number; } export interface CompactionResult { @@ -29,6 +32,7 @@ export const DEFAULT_COMPACTION_CONFIG: CompactionConfig = { thresholdPct: 80, keepTurns: 4, summaryMaxTokens: 1024, + importanceThreshold: 1, }; export async function compactHistory(opts: { @@ -56,10 +60,34 @@ export async function compactHistory(opts: { // Ensure toKeep starts with a user message to avoid assistant→assistant // after the compaction summary (which has role 'assistant'). while (toKeep.length > 0 && toKeep[0].role === 'assistant') { - toCompact.push(toKeep.shift()!); + const shifted = toKeep.shift(); + if (!shifted) { + break; + } + toCompact.push(shifted); } - const formattedConversation = toCompact.map((msg) => `${msg.role}: ${getMessageText(msg)}`).join('\n\n'); + const preservedImportant = selectImportantMessages(toCompact, { + threshold: config.importanceThreshold, + maxMessages: Math.max(1, config.keepTurns), + }); + + const preservedSet = new Set(preservedImportant.map(item => item.index)); + const toSummarize = toCompact.filter((_, index) => !preservedSet.has(index)); + + const formattedConversation = toSummarize.map((msg) => `${msg.role}: ${getMessageText(msg)}`).join('\n\n'); + + const preservedMessages = preservedImportant.map(item => item.message); + + if (formattedConversation.trim().length === 0) { + const compactedMessages = [...preservedMessages, ...toKeep]; + return { + messages: compactedMessages, + compactedCount: messages.length - compactedMessages.length, + tokensBefore: estimateMessageTokens(messages), + tokensAfter: estimateMessageTokens(compactedMessages), + }; + } const tier = orchestrator.getDelegationTier('compaction'); @@ -99,9 +127,9 @@ export async function compactHistory(opts: { } return { - messages: [summaryMessage, ...toKeep], - compactedCount: toCompact.length, + messages: [...preservedMessages, summaryMessage, ...toKeep], + compactedCount: toSummarize.length, tokensBefore: estimateMessageTokens(messages), - tokensAfter: estimateMessageTokens([summaryMessage, ...toKeep]), + tokensAfter: estimateMessageTokens([...preservedMessages, summaryMessage, ...toKeep]), }; } diff --git a/src/daemon/routing.test.ts b/src/daemon/routing.test.ts index 80b3278..b0c5c01 100644 --- a/src/daemon/routing.test.ts +++ b/src/daemon/routing.test.ts @@ -1,7 +1,12 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi, afterEach } from 'vitest'; import { AgentRouter } from '../agents/router.js'; import { AgentConfigRegistry } from '../agents/registry.js'; import type { ModelTier } from '../models/router.js'; +import { createMessageRouter } from './routing.js'; +import { AgentOrchestrator } from '../backends/index.js'; +import { CommandRegistry, registerBuiltinCommands } from '../commands/index.js'; +import { ComponentRegistry } from '../intents/index.js'; +import { RoutingPolicy } from '../routing/index.js'; describe('daemon agent routing integration', () => { it('resolves agent config for channel messages', () => { @@ -61,3 +66,317 @@ describe('daemon agent routing integration', () => { expect(resolveTier(undefined, undefined, undefined)).toBe('default'); }); }); + +describe('daemon command fast-path integration', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('handles known reset command without calling agent.process', async () => { + const processSpy = vi.spyOn(AgentOrchestrator.prototype, 'process'); + const session = { + id: 'telegram:user-1', + addMessage: vi.fn(), + getHistory: vi.fn(() => []), + clear: vi.fn(), + replaceHistory: vi.fn(), + getConfig: vi.fn(() => undefined), + setConfig: vi.fn(), + deleteConfig: vi.fn(), + }; + + const commandRegistry = new CommandRegistry(); + registerBuiltinCommands(commandRegistry); + + const router = createMessageRouter({ + sessionManager: { + getSession: vi.fn(() => session), + } as any, + modelRouter: { + getAvailableTiers: () => ['fast', 'default', 'complex', 'local'], + getAllLabels: () => ({ fast: 'fast', default: 'default', complex: 'complex', local: 'local' }), + getLabel: (tier: string) => tier, + } as any, + systemPrompt: 'test prompt', + toolRegistry: { + clone() { return this; }, + register: vi.fn(), + } as any, + toolExecutor: {} as any, + config: { + agents: { + primary_tier: 'default', + delegation: { + compaction: 'fast', + memory_extraction: 'fast', + classification: 'fast', + tool_summarisation: 'fast', + complex_reasoning: 'complex', + }, + max_delegation_depth: 3, + max_iterations: 10, + }, + compaction: { enabled: false }, + models: { default: { provider: 'anthropic', model: 'claude' } }, + } as any, + commandRegistry, + }); + + const reply = vi.fn(async () => {}); + await router.handler({ + id: 'm1', + channel: 'telegram', + senderId: 'user-1', + text: '/reset', + metadata: { isCommand: true, command: 'reset' }, + } as any, reply); + + expect(processSpy).not.toHaveBeenCalled(); + expect(session.deleteConfig).toHaveBeenCalledWith('modelTier'); + }); + + it('handles model command via fast-path and persists tier override', async () => { + const processSpy = vi.spyOn(AgentOrchestrator.prototype, 'process'); + const setModelTierSpy = vi.spyOn(AgentOrchestrator.prototype, 'setModelTier'); + const session = { + id: 'telegram:user-4', + addMessage: vi.fn(), + getHistory: vi.fn(() => []), + clear: vi.fn(), + replaceHistory: vi.fn(), + getConfig: vi.fn(() => undefined), + setConfig: vi.fn(), + deleteConfig: vi.fn(), + }; + + const commandRegistry = new CommandRegistry(); + registerBuiltinCommands(commandRegistry); + + const router = createMessageRouter({ + sessionManager: { + getSession: vi.fn(() => session), + } as any, + modelRouter: { + getAvailableTiers: () => ['fast', 'default', 'complex', 'local'], + getAllLabels: () => ({ fast: 'fast', default: 'default', complex: 'complex', local: 'local' }), + getLabel: (tier: string) => tier, + } as any, + systemPrompt: 'test prompt', + toolRegistry: { + clone() { return this; }, + register: vi.fn(), + } as any, + toolExecutor: {} as any, + config: { + agents: { + primary_tier: 'default', + delegation: { + compaction: 'fast', + memory_extraction: 'fast', + classification: 'fast', + tool_summarisation: 'fast', + complex_reasoning: 'complex', + }, + max_delegation_depth: 3, + max_iterations: 10, + }, + compaction: { enabled: false }, + models: { default: { provider: 'anthropic', model: 'claude' } }, + } as any, + commandRegistry, + }); + + const reply = vi.fn(async () => {}); + await router.handler({ + id: 'm4', + channel: 'telegram', + senderId: 'user-4', + text: '/model fast', + metadata: { isCommand: true, command: 'model', commandArgs: 'fast' }, + } as any, reply); + + expect(processSpy).not.toHaveBeenCalled(); + expect(setModelTierSpy).toHaveBeenCalledWith('fast'); + expect(session.setConfig).toHaveBeenCalledWith('modelTier', 'fast'); + }); + + it('uses intent match to override agent target', async () => { + const session = { + id: 'telegram:user-2', + addMessage: vi.fn(), + getHistory: vi.fn(() => []), + clear: vi.fn(), + replaceHistory: vi.fn(), + getConfig: vi.fn(() => undefined), + setConfig: vi.fn(), + deleteConfig: vi.fn(), + }; + + const commandRegistry = new CommandRegistry(); + registerBuiltinCommands(commandRegistry); + + const intentRegistry = new ComponentRegistry({ matchThreshold: 0.5 }); + intentRegistry.register({ + name: 'deploy-route', + patterns: ['deploy *'], + target: { type: 'agent', name: 'coder' }, + priority: 10, + enabled: true, + }); + + const agentConfigRegistry = new AgentConfigRegistry(); + agentConfigRegistry.loadFromConfig({ + assistant: { model_tier: 'default', sandbox: false }, + coder: { model_tier: 'complex', sandbox: false }, + }); + + const agentRouter = new AgentRouter({ + default_agent: 'assistant', + channels: {}, + senders: {}, + }); + + const router = createMessageRouter({ + sessionManager: { + getSession: vi.fn(() => session), + } as any, + modelRouter: { + getAvailableTiers: () => ['fast', 'default', 'complex', 'local'], + getAllLabels: () => ({ fast: 'fast', default: 'default', complex: 'complex', local: 'local' }), + getLabel: (tier: string) => tier, + } as any, + systemPrompt: 'test prompt', + toolRegistry: { + clone() { return this; }, + register: vi.fn(), + } as any, + toolExecutor: {} as any, + config: { + intents: { enabled: true }, + agents: { + primary_tier: 'default', + delegation: { + compaction: 'fast', + memory_extraction: 'fast', + classification: 'fast', + tool_summarisation: 'fast', + complex_reasoning: 'complex', + }, + max_delegation_depth: 3, + max_iterations: 10, + }, + compaction: { enabled: false }, + models: { default: { provider: 'anthropic', model: 'claude' } }, + } as any, + commandRegistry, + intentRegistry, + agentConfigRegistry, + agentRouter, + }); + + await router.handler({ + id: 'm2', + channel: 'telegram', + senderId: 'user-2', + text: 'deploy backend now', + metadata: { isCommand: true, command: 'reset' }, + } as any, vi.fn(async () => {})); + + const keys = Array.from(router.agents.keys()); + expect(keys.some(key => key.includes(':coder'))).toBe(true); + }); + + it('falls back to llm path when confidence is below fast threshold', async () => { + const session = { + id: 'telegram:user-3', + addMessage: vi.fn(), + getHistory: vi.fn(() => []), + clear: vi.fn(), + replaceHistory: vi.fn(), + getConfig: vi.fn(() => undefined), + setConfig: vi.fn(), + deleteConfig: vi.fn(), + }; + + const commandRegistry = new CommandRegistry(); + registerBuiltinCommands(commandRegistry); + + const intentRegistry = new ComponentRegistry({ matchThreshold: 0.5 }); + intentRegistry.register({ + name: 'deploy-route', + patterns: ['deploy *'], + target: { type: 'agent', name: 'coder' }, + priority: 10, + enabled: true, + }); + + const routingPolicy = new RoutingPolicy({ + enabled: true, + fastPathThreshold: 0.99, + llmThreshold: 0.2, + defaultPath: 'llm', + }); + + const agentConfigRegistry = new AgentConfigRegistry(); + agentConfigRegistry.loadFromConfig({ + assistant: { model_tier: 'default', sandbox: false }, + coder: { model_tier: 'complex', sandbox: false }, + }); + + const agentRouter = new AgentRouter({ + default_agent: 'assistant', + channels: {}, + senders: {}, + }); + + const router = createMessageRouter({ + sessionManager: { + getSession: vi.fn(() => session), + } as any, + modelRouter: { + getAvailableTiers: () => ['fast', 'default', 'complex', 'local'], + getAllLabels: () => ({ fast: 'fast', default: 'default', complex: 'complex', local: 'local' }), + getLabel: (tier: string) => tier, + } as any, + systemPrompt: 'test prompt', + toolRegistry: { + clone() { return this; }, + register: vi.fn(), + } as any, + toolExecutor: {} as any, + config: { + intents: { enabled: true }, + agents: { + primary_tier: 'default', + delegation: { + compaction: 'fast', + memory_extraction: 'fast', + classification: 'fast', + tool_summarisation: 'fast', + complex_reasoning: 'complex', + }, + max_delegation_depth: 3, + max_iterations: 10, + }, + compaction: { enabled: false }, + models: { default: { provider: 'anthropic', model: 'claude' } }, + } as any, + commandRegistry, + intentRegistry, + routingPolicy, + agentConfigRegistry, + agentRouter, + }); + + await router.handler({ + id: 'm3', + channel: 'telegram', + senderId: 'user-3', + text: 'deploy backend now', + metadata: { isCommand: true, command: 'reset' }, + } as any, vi.fn(async () => {})); + + const keys = Array.from(router.agents.keys()); + expect(keys.some(key => key.includes(':assistant'))).toBe(true); + }); +}); diff --git a/src/gateway/handlers/agent.test.ts b/src/gateway/handlers/agent.test.ts index f22d991..c755c66 100644 --- a/src/gateway/handlers/agent.test.ts +++ b/src/gateway/handlers/agent.test.ts @@ -86,6 +86,27 @@ describe('createAgentHandlers command fast-path', () => { expect(((sent[0] as GatewayEvent).data as { content: string }).content).toContain('Session reset.'); }); + it('handles /model command via fast-path and persists session tier', async () => { + const sent: OutboundMessage[] = []; + const send = vi.fn((msg: OutboundMessage) => sent.push(msg)); + const req: GatewayRequest = { + id: 4, + method: 'agent.send', + params: { + message: '/model fast', + connectionId: 'conn-1', + metadata: { isCommand: true, command: 'model', commandArgs: 'fast' }, + }, + }; + + await handlers['agent.send'](req, send); + + expect(mockAgent.setModelTier).toHaveBeenCalledWith('fast'); + expect(sessionManager.setSessionConfig).toHaveBeenCalledWith('ws', 'ws:conn-1', 'modelTier', 'fast'); + expect(mockAgent.process).not.toHaveBeenCalled(); + expect(((sent[0] as GatewayEvent).data as { content: string }).content).toContain('Switched to model tier: fast'); + }); + it('falls through to agent.process for unknown commands', async () => { const sent: OutboundMessage[] = []; const send = vi.fn((msg: OutboundMessage) => sent.push(msg)); diff --git a/src/gateway/handlers/agent.ts b/src/gateway/handlers/agent.ts index ee26243..ed7ebe3 100644 --- a/src/gateway/handlers/agent.ts +++ b/src/gateway/handlers/agent.ts @@ -7,12 +7,14 @@ import type { MetricsCollector } from '../metrics.js'; import type { Attachment } from '../../channels/types.js'; import type { SessionManager } from '../../session/manager.js'; import type { ModelTier } from '../../models/router.js'; +import type { CommandRegistry } from '../../commands/index.js'; export interface AgentHandlerDeps { sessionBridge: SessionBridge; laneQueue: LaneQueue; metrics?: MetricsCollector; sessionManager?: SessionManager; + commandRegistry?: CommandRegistry; } export function createAgentHandlers(deps: AgentHandlerDeps) { @@ -46,59 +48,78 @@ export function createAgentHandlers(deps: AgentHandlerDeps) { return deps.laneQueue.enqueue(laneId, async () => { deps.sessionBridge.setBusy(connectionId, true); - // Handle slash commands via metadata (mirrors daemon/routing.ts pattern) - if (params.metadata?.isCommand) { - try { - if (params.metadata.command === 'reset') { - agent.reset(); - // Clear session config - const sessionId = deps.sessionBridge.getSessionId(connectionId); - if (sessionId && deps.sessionManager) { - deps.sessionManager.deleteSessionConfig('ws', sessionId, 'modelTier'); - } - send(makeEvent(request.id, 'done', { content: 'Session reset.' })); - return; - } + const commandInput = params.metadata?.isCommand && typeof params.metadata.command === 'string' + ? `/${params.metadata.command}${params.metadata.commandArgs ? ` ${params.metadata.commandArgs}` : ''}` + : params.message; - if (params.metadata.command === 'model') { - const modelArg = params.metadata.commandArgs as string | undefined; - const sessionId = deps.sessionBridge.getSessionId(connectionId); + if (commandInput && deps.commandRegistry?.isCommand(commandInput)) { + const sessionId = deps.sessionBridge.getSessionId(connectionId); + const commandResult = await deps.commandRegistry.execute(commandInput, { + channel: 'ws', + senderId: connectionId, + sessionId: sessionId ?? `ws:${connectionId}`, + rawInput: commandInput, + services: { + getStatus: () => `Gateway session active. Current model tier: ${agent.getModelTier()}`, + getUsage: () => { + const usage = agent.getUsage(); + const lines = [ + '**Token Usage**', + '', + `Primary: ${usage.primary.inputTokens.toLocaleString()} in / ${usage.primary.outputTokens.toLocaleString()} out (${usage.primary.calls} calls)`, + ]; - if (!modelArg) { - // Show current tier info - const currentTier = agent.getModelTier(); - send(makeEvent(request.id, 'done', { - content: `Current model tier: ${currentTier}`, - })); - return; - } + const delegationEntries = Object.entries(usage.delegation); + if (delegationEntries.length > 0) { + lines.push(''); + lines.push('Delegation:'); + for (const [tier, stats] of delegationEntries) { + lines.push(` ${tier}: ${stats.inputTokens.toLocaleString()} in / ${stats.outputTokens.toLocaleString()} out (${stats.calls} calls)`); + } + } - // Validate tier - const validTiers: ModelTier[] = ['fast', 'default', 'complex', 'local']; - const tier = modelArg as ModelTier; - if (!validTiers.includes(tier)) { - send(makeEvent(request.id, 'done', { - content: `Invalid tier: ${modelArg}. Available: ${validTiers.join(', ')}`, - })); - return; - } + lines.push(''); + lines.push(`**Total:** ${usage.total.inputTokens.toLocaleString()} in / ${usage.total.outputTokens.toLocaleString()} out (${usage.total.calls} calls)`); - // Update agent tier - agent.setModelTier(tier); + if (usage.total.estimatedCost > 0) { + lines.push(`**Estimated cost:** $${usage.total.estimatedCost.toFixed(4)}`); + } - // Persist to session config - if (sessionId && deps.sessionManager) { - deps.sessionManager.setSessionConfig('ws', sessionId, 'modelTier', tier); - } + return lines.join('\n'); + }, + getModel: () => `Current model tier: ${agent.getModelTier()}`, + setModel: (tier) => { + const validTiers: ModelTier[] = ['fast', 'default', 'complex', 'local']; + const modelTier = tier as ModelTier; + if (!validTiers.includes(modelTier)) { + return `Invalid tier: ${tier}. Available: ${validTiers.join(', ')}`; + } + agent.setModelTier(modelTier); + if (sessionId && deps.sessionManager) { + deps.sessionManager.setSessionConfig('ws', sessionId, 'modelTier', modelTier); + } + return `Switched to model tier: ${modelTier}`; + }, + compact: async () => { + const result = await agent.compact(); + if (result && result.compactedCount > 0) { + return `Compacted ${result.compactedCount} messages: ${result.tokensBefore} → ${result.tokensAfter} tokens`; + } + return 'Nothing to compact.'; + }, + reset: () => { + agent.reset(); + if (sessionId && deps.sessionManager) { + deps.sessionManager.deleteSessionConfig('ws', sessionId, 'modelTier'); + } + return 'Session reset.'; + }, + }, + }); - send(makeEvent(request.id, 'done', { - content: `Switched to model tier: ${tier}`, - })); - return; - } - } finally { - deps.sessionBridge.setBusy(connectionId, false); - deps.metrics?.endRequest(requestId); + if (commandResult.handled) { + send(makeEvent(request.id, 'done', { content: commandResult.text })); + return; } } diff --git a/src/gateway/handlers/handlers.test.ts b/src/gateway/handlers/handlers.test.ts index 93685fc..800bf7d 100644 --- a/src/gateway/handlers/handlers.test.ts +++ b/src/gateway/handlers/handlers.test.ts @@ -4,12 +4,17 @@ import type { TokenUsageEntry } from './system.js'; import { createSessionHandlers } from './sessions.js'; import { createToolHandlers } from './tools.js'; import { createAgentHandlers } from './agent.js'; +import { createIntentHandlers } from './intents.js'; +import { createRoutingHandlers } from './routing.js'; +import { createHistoryHandlers } from './history.js'; import { createConfigHandlers, redactConfig } from './config.js'; import { createPairingHandlers } from './pairing.js'; import { PairingManager } from '../../channels/pairing.js'; import { LaneQueue } from '../lane-queue.js'; import { ErrorCode } from '../protocol.js'; import type { GatewayRequest, GatewayResponse, GatewayError, GatewayEvent, OutboundMessage } from '../protocol.js'; +import { ComponentRegistry } from '../../intents/index.js'; +import { RoutingPolicy } from '../../routing/index.js'; describe('system handlers', () => { const deps = { @@ -402,6 +407,124 @@ describe('agent handlers', () => { }); }); +describe('intent handlers', () => { + it('intents.list returns configured rules', async () => { + const registry = new ComponentRegistry({ matchThreshold: 0.6 }); + registry.register({ + name: 'deploy-route', + patterns: ['deploy *'], + target: { type: 'agent', name: 'coder' }, + priority: 5, + enabled: true, + }); + + const handlers = createIntentHandlers({ + intentRegistry: registry, + enabled: true, + }); + + const req: GatewayRequest = { id: 10, method: 'intents.list' }; + const result = await handlers['intents.list'](req) as GatewayResponse; + const payload = result.result as { enabled: boolean; rules: Array<{ name: string }> }; + + expect(payload.enabled).toBe(true); + expect(payload.rules).toHaveLength(1); + expect(payload.rules[0].name).toBe('deploy-route'); + }); + + it('intents.match returns best rule match', async () => { + const registry = new ComponentRegistry({ matchThreshold: 0.5 }); + registry.register({ + name: 'deploy-route', + patterns: ['deploy *'], + target: { type: 'agent', name: 'coder' }, + priority: 5, + enabled: true, + }); + + const handlers = createIntentHandlers({ + intentRegistry: registry, + enabled: true, + }); + + const req: GatewayRequest = { + id: 11, + method: 'intents.match', + params: { input: 'deploy backend service' }, + }; + const result = await handlers['intents.match'](req) as GatewayResponse; + const payload = result.result as { match: { rule: { name: string } } }; + + expect(payload.match.rule.name).toBe('deploy-route'); + }); +}); + +describe('routing handlers', () => { + it('routing.decide returns match and policy decision', async () => { + const registry = new ComponentRegistry({ matchThreshold: 0.5 }); + registry.register({ + name: 'deploy-route', + patterns: ['deploy *'], + target: { type: 'agent', name: 'coder' }, + priority: 5, + enabled: true, + }); + const policy = new RoutingPolicy({ + enabled: true, + fastPathThreshold: 0.7, + llmThreshold: 0.3, + defaultPath: 'llm', + }); + const handlers = createRoutingHandlers({ + intentRegistry: registry, + routingPolicy: policy, + }); + + const req: GatewayRequest = { + id: 12, + method: 'routing.decide', + params: { input: 'deploy service' }, + }; + const result = await handlers['routing.decide'](req) as GatewayResponse; + const payload = result.result as { + match: { rule: { name: string } }; + decision: { path: string }; + }; + + expect(payload.match.rule.name).toBe('deploy-route'); + expect(payload.decision.path).toBe('fast'); + }); +}); + +describe('history handlers', () => { + it('history.search returns ranked results', async () => { + const handlers = createHistoryHandlers({ + sessionManager: { + searchHistory: () => [{ sessionId: 'ws:test', messageId: 1, role: 'user', content: 'deploy', score: 0.9, createdAt: 123 }], + reindexHistory: () => 0, + } as any, + }); + + const req: GatewayRequest = { id: 13, method: 'history.search', params: { query: 'deploy' } }; + const result = await handlers['history.search'](req) as GatewayResponse; + const payload = result.result as { results: Array<{ sessionId: string }> }; + expect(payload.results[0].sessionId).toBe('ws:test'); + }); + + it('history.reindex returns count', async () => { + const handlers = createHistoryHandlers({ + sessionManager: { + searchHistory: () => [], + reindexHistory: () => 42, + } as any, + }); + + const req: GatewayRequest = { id: 14, method: 'history.reindex' }; + const result = await handlers['history.reindex'](req) as GatewayResponse; + expect((result.result as { reindexed: number }).reindexed).toBe(42); + }); +}); + describe('system.restart handler', () => { it('returns restarting:true and calls restart callback', async () => { const restartFn = vi.fn(async () => {}); diff --git a/src/gateway/handlers/index.ts b/src/gateway/handlers/index.ts index a4c2f93..fbf178d 100644 --- a/src/gateway/handlers/index.ts +++ b/src/gateway/handlers/index.ts @@ -10,3 +10,9 @@ export { createConfigHandlers } from './config.js'; export type { ConfigHandlerDeps } from './config.js'; export { createPairingHandlers } from './pairing.js'; export type { PairingHandlerDeps } from './pairing.js'; +export { createIntentHandlers } from './intents.js'; +export type { IntentHandlerDeps } from './intents.js'; +export { createRoutingHandlers } from './routing.js'; +export type { RoutingHandlerDeps } from './routing.js'; +export { createHistoryHandlers } from './history.js'; +export type { HistoryHandlerDeps } from './history.js'; diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 5427200..4dfcf84 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -23,6 +23,9 @@ import { createAgentHandlers, createConfigHandlers, createPairingHandlers, + createIntentHandlers, + createRoutingHandlers, + createHistoryHandlers, } from './handlers/index.js'; import type { TokenUsageEntry } from './handlers/system.js'; import type { SessionManager } from '../session/manager.js'; @@ -33,6 +36,9 @@ import type { WebhookHandler } from '../automation/webhooks.js'; import type { GmailWatcher } from '../automation/gmail.js'; import type { PairingManager } from '../channels/pairing.js'; import type { MemoryStore } from '../memory/store.js'; +import type { CommandRegistry } from '../commands/index.js'; +import type { ComponentRegistry } from '../intents/index.js'; +import type { RoutingPolicy } from '../routing/index.js'; export interface GatewayServerConfig { port: number; @@ -62,6 +68,9 @@ export interface GatewayServerConfig { /** Optional pairing manager for DM pairing code management via gateway. */ pairingManager?: PairingManager; memoryStore?: MemoryStore; + commandRegistry?: CommandRegistry; + intentRegistry?: ComponentRegistry; + routingPolicy?: RoutingPolicy; } export class GatewayServer { @@ -122,6 +131,10 @@ export class GatewayServer { sessionBridge: this.sessionBridge, }); + const historyHandlers = createHistoryHandlers({ + sessionManager: this.config.sessionManager, + }); + const toolHandlers = createToolHandlers({ toolRegistry: this.config.toolRegistry, toolExecutor: this.config.toolExecutor, @@ -132,6 +145,17 @@ export class GatewayServer { laneQueue: this.laneQueue, metrics: this.metrics, sessionManager: this.config.sessionManager, + commandRegistry: this.config.commandRegistry, + }); + + const intentHandlers = createIntentHandlers({ + intentRegistry: this.config.intentRegistry, + enabled: this.config.config?.intents.enabled ?? false, + }); + + const routingHandlers = createRoutingHandlers({ + intentRegistry: this.config.intentRegistry, + routingPolicy: this.config.routingPolicy, }); // Config handlers (only if config object is provided) @@ -157,12 +181,21 @@ export class GatewayServer { for (const [method, handler] of Object.entries(sessionHandlers)) { this.router.register(method, handler); } + for (const [method, handler] of Object.entries(historyHandlers)) { + this.router.register(method, handler); + } for (const [method, handler] of Object.entries(toolHandlers)) { this.router.register(method, handler); } for (const [method, handler] of Object.entries(agentHandlers)) { this.router.register(method, handler); } + for (const [method, handler] of Object.entries(intentHandlers)) { + this.router.register(method, handler); + } + for (const [method, handler] of Object.entries(routingHandlers)) { + this.router.register(method, handler); + } } async start(): Promise { diff --git a/src/gateway/session-bridge.test.ts b/src/gateway/session-bridge.test.ts index 8dfbb02..b89e2cb 100644 --- a/src/gateway/session-bridge.test.ts +++ b/src/gateway/session-bridge.test.ts @@ -9,6 +9,9 @@ const mockSession = { getHistory: vi.fn(() => []), clear: vi.fn(), replaceHistory: vi.fn(), + getConfig: vi.fn((_key: string) => undefined as string | undefined), + setConfig: vi.fn(), + deleteConfig: vi.fn(), }; const mockSessionManager = { @@ -48,9 +51,21 @@ function createBridge(): SessionBridge { }); } +function createBridgeWithConfig(config: SessionBridgeConfig['config']): SessionBridge { + return new SessionBridge({ + sessionManager: mockSessionManager as unknown as SessionBridgeConfig['sessionManager'], + modelClient: mockModelClient, + systemPrompt: 'test prompt', + toolRegistry: mockToolRegistry as unknown as SessionBridgeConfig['toolRegistry'], + toolExecutor: mockToolExecutor as unknown as SessionBridgeConfig['toolExecutor'], + config, + }); +} + describe('SessionBridge', () => { beforeEach(() => { vi.clearAllMocks(); + mockSession.getConfig.mockImplementation((_key: string) => undefined); }); it('connect assigns a connection ID', () => { @@ -142,4 +157,78 @@ describe('SessionBridge', () => { expect(bridge.getAgent('conn-2')).toBeDefined(); expect(bridge.connectionCount).toBe(1); }); + + it('loads model tier from per-session config when creating a session agent', () => { + mockSession.getConfig.mockImplementation((key: string) => (key === 'modelTier' ? 'local' : undefined)); + + const bridge = createBridgeWithConfig({ + agents: { + primary_tier: 'default', + delegation: { + compaction: 'fast', + memory_extraction: 'fast', + classification: 'fast', + tool_summarisation: 'fast', + complex_reasoning: 'complex', + }, + max_delegation_depth: 3, + }, + compaction: { enabled: false }, + models: { default: { provider: 'anthropic', model: 'claude-3-haiku' } }, + } as any); + + bridge.connect('conn-tier'); + const agent = bridge.getAgent('conn-tier'); + expect(agent?.getModelTier()).toBe('local'); + }); + + it('keeps different sessions isolated by persisted model tier', () => { + const sessionById: Record = {}; + const localSessionManager = { + ...mockSessionManager, + getSession: vi.fn((frontend: string, sessionId: string) => { + const fullId = `${frontend}:${sessionId}`; + if (!sessionById[fullId]) { + const tier = fullId === 'ws:ws:conn-a' ? 'fast' : 'complex'; + sessionById[fullId] = { + ...mockSession, + id: fullId, + getConfig: vi.fn((key: string) => (key === 'modelTier' ? tier : undefined)), + }; + } + return sessionById[fullId]; + }), + }; + + const bridge = new SessionBridge({ + sessionManager: localSessionManager as unknown as SessionBridgeConfig['sessionManager'], + modelClient: mockModelClient, + systemPrompt: 'test prompt', + toolRegistry: mockToolRegistry as unknown as SessionBridgeConfig['toolRegistry'], + toolExecutor: mockToolExecutor as unknown as SessionBridgeConfig['toolExecutor'], + config: { + agents: { + primary_tier: 'default', + delegation: { + compaction: 'fast', + memory_extraction: 'fast', + classification: 'fast', + tool_summarisation: 'fast', + complex_reasoning: 'complex', + }, + max_delegation_depth: 3, + }, + compaction: { enabled: false }, + models: { default: { provider: 'anthropic', model: 'claude-3-haiku' } }, + } as any, + }); + + bridge.connect('conn-a'); + bridge.connect('conn-b'); + const agentA = bridge.getAgent('conn-a'); + const agentB = bridge.getAgent('conn-b'); + + expect(agentA?.getModelTier()).toBe('fast'); + expect(agentB?.getModelTier()).toBe('complex'); + }); }); diff --git a/src/memory/index.ts b/src/memory/index.ts index 5c4625e..9611337 100644 --- a/src/memory/index.ts +++ b/src/memory/index.ts @@ -8,3 +8,6 @@ export { VectorStore, cosineSimilarity, contentHash } from './vector-store.js'; export type { VectorSearchResult, EmbeddingRow } from './vector-store.js'; export { HybridSearch } from './hybrid-search.js'; export type { HybridSearchResult } from './hybrid-search.js'; +export * from './categories.js'; +export { buildAdaptiveMemoryContext, buildRecentMemoryContext } from './adaptive.js'; +export type { AdaptiveMemoryConfig } from './adaptive.js'; diff --git a/src/memory/store.test.ts b/src/memory/store.test.ts index 3d75bb2..db46570 100644 --- a/src/memory/store.test.ts +++ b/src/memory/store.test.ts @@ -82,6 +82,45 @@ describe('MemoryStore', () => { }); }); + describe('category APIs', () => { + it('reads and writes category namespaces', () => { + store.writeCategory('user', 'facts', 'User lives in Berlin', 'replace'); + expect(store.readCategory('user', 'facts')).toBe('User lives in Berlin'); + }); + + it('supports append and replace modes in category writes', () => { + store.writeCategory('user', 'preferences', 'Prefers short answers', 'replace'); + store.writeCategory('user', 'preferences', 'Likes numbered lists', 'append'); + expect(store.readCategory('user', 'preferences')).toContain('Prefers short answers'); + expect(store.readCategory('user', 'preferences')).toContain('Likes numbered lists'); + + store.writeCategory('user', 'preferences', 'Only this remains', 'replace'); + const content = store.readCategory('user', 'preferences'); + expect(content).toContain('Only this remains'); + expect(content).not.toContain('Prefers short answers'); + }); + + it('lists only categories that exist under a base namespace', () => { + store.writeCategory('user', 'facts', 'Fact', 'replace'); + store.writeCategory('user', 'projects', 'Project', 'replace'); + store.writeCategory('global', 'decisions', 'Decision', 'replace'); + + expect(store.listCategories('user')).toEqual(['facts', 'projects']); + expect(store.listCategories('global')).toEqual(['decisions']); + expect(store.listCategories('sessions/abc')).toEqual([]); + }); + + it('reads all existing categories under a base namespace', () => { + store.writeCategory('user', 'facts', 'Fact content', 'replace'); + store.writeCategory('user', 'decisions', 'Decision content', 'replace'); + + expect(store.readAllCategories('user')).toEqual({ + facts: 'Fact content', + decisions: 'Decision content', + }); + }); + }); + describe('search', () => { beforeEach(() => { store.write('notes', 'The quick brown fox jumps over the lazy dog\nAnother line of text\nFox sightings are common here', 'replace'); @@ -123,6 +162,24 @@ describe('MemoryStore', () => { const results = store.search('xyznonexistent'); expect(results).toEqual([]); }); + + it('supports filtering by category', () => { + store.writeCategory('user', 'facts', 'fox factual statement', 'replace'); + store.writeCategory('user', 'preferences', 'prefers fox metaphors', 'replace'); + + const factOnly = store.search('fox', { categories: ['facts'] }); + expect(factOnly.length).toBeGreaterThan(0); + expect(factOnly.every(result => result.namespace.endsWith('/facts'))).toBe(true); + }); + + it('supports filtering by base namespace prefix', () => { + store.writeCategory('user', 'facts', 'fox in user facts', 'replace'); + store.writeCategory('global', 'facts', 'fox in global facts', 'replace'); + + const userOnly = store.search('fox', { baseNamespacePrefix: 'user/' }); + expect(userOnly.length).toBeGreaterThan(0); + expect(userOnly.every(result => result.namespace.startsWith('user/'))).toBe(true); + }); }); describe('listNamespaces', () => { @@ -166,6 +223,8 @@ describe('MemoryStore', () => { it('includes user and global memory under headings', () => { store.write('user', 'User prefers concise answers', 'replace'); store.write('global', 'System-wide knowledge base', 'replace'); + store.writeCategory('user', 'facts', 'User timezone is UTC', 'replace'); + store.writeCategory('global', 'decisions', 'Adopt pnpm workspace', 'replace'); const context = store.getContextForPrompt(); @@ -174,6 +233,10 @@ describe('MemoryStore', () => { expect(context).toContain('User prefers concise answers'); expect(context).toContain('Global Memory'); expect(context).toContain('System-wide knowledge base'); + expect(context).toContain('User Facts'); + expect(context).toContain('User timezone is UTC'); + expect(context).toContain('Global Decisions'); + expect(context).toContain('Adopt pnpm workspace'); }); it('truncates content to stay within maxContextTokens', () => { diff --git a/src/memory/store.ts b/src/memory/store.ts index 781dfedf..781dfed5 100644 --- a/src/memory/store.ts +++ b/src/memory/store.ts @@ -1,5 +1,6 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync } from 'fs'; import { join, relative, dirname } from 'path'; +import { MEMORY_CATEGORIES, categoryNamespace, isMemoryCategory, type MemoryCategory } from './categories.js'; /** * Configuration for the MemoryStore. @@ -11,6 +12,11 @@ export interface MemoryStoreConfig { maxContextTokens: number; } +export interface PromptMemorySection { + title: string; + content: string; +} + /** * A single search result from scanning memory files. */ @@ -25,6 +31,11 @@ export interface SearchResult { context: string; } +export interface SearchOptions { + categories?: MemoryCategory[]; + baseNamespacePrefix?: string; +} + /** * Manages persistent markdown memory files on disk. * @@ -94,14 +105,72 @@ export class MemoryStore { this._dirtyNamespaces.add(namespace); } + /** Read content for a category under a base namespace. */ + readCategory(baseNamespace: string, category: MemoryCategory): string { + return this.read(categoryNamespace(baseNamespace, category)); + } + + /** Write content for a category under a base namespace. */ + writeCategory(baseNamespace: string, category: MemoryCategory, content: string, mode: 'append' | 'replace'): void { + this.write(categoryNamespace(baseNamespace, category), content, mode); + } + + /** List categories that currently exist under a base namespace. */ + listCategories(baseNamespace: string): MemoryCategory[] { + const categorySet = new Set(); + const prefix = `${baseNamespace}/`; + + for (const namespace of this.listNamespaces()) { + if (!namespace.startsWith(prefix)) { + continue; + } + + const suffix = namespace.slice(prefix.length); + if (suffix.includes('/')) { + continue; + } + + if (isMemoryCategory(suffix)) { + categorySet.add(suffix); + } + } + + return MEMORY_CATEGORIES.filter(category => categorySet.has(category)); + } + + /** Read all category files under a base namespace. */ + readAllCategories(baseNamespace: string): Partial> { + const result: Partial> = {}; + + for (const category of this.listCategories(baseNamespace)) { + const content = this.readCategory(baseNamespace, category); + if (content.length > 0) { + result[category] = content; + } + } + + return result; + } + /** * Search across all memory files for a keyword or phrase. * Performs case-insensitive line-by-line matching. * Returns matching lines with 1 line of context above and below. */ - search(query: string): SearchResult[] { + search(query: string, opts?: SearchOptions): SearchResult[] { const results: SearchResult[] = []; - const namespaces = this.listNamespaces(); + const namespaces = this.listNamespaces().filter((namespace) => { + if (opts?.baseNamespacePrefix && !namespace.startsWith(opts.baseNamespacePrefix)) { + return false; + } + + if (opts?.categories && opts.categories.length > 0) { + const suffix = namespace.split('/').pop() ?? ''; + return isMemoryCategory(suffix) && opts.categories.includes(suffix); + } + + return true; + }); const lowerQuery = query.toLowerCase(); for (const namespace of namespaces) { @@ -178,23 +247,13 @@ export class MemoryStore { * (estimated at 4 characters per token). */ getContextForPrompt(): string { - const userMemory = this.read('user'); - const globalMemory = this.read('global'); + const sections = this.getPromptSections().map((section) => `## ${section.title}\n\n${section.content}`); // Nothing to inject - if (userMemory.length === 0 && globalMemory.length === 0) { + if (sections.length === 0) { return ''; } - const sections: string[] = []; - - if (userMemory.length > 0) { - sections.push(`## User Memory\n\n${userMemory}`); - } - if (globalMemory.length > 0) { - sections.push(`## Global Memory\n\n${globalMemory}`); - } - const full = sections.join('\n\n'); // Truncate to fit within the token budget (estimate: 4 chars ≈ 1 token) @@ -205,6 +264,45 @@ export class MemoryStore { return full.slice(0, maxChars); } + /** Build memory sections used by prompt injectors. */ + getPromptSections(): PromptMemorySection[] { + const userMemory = this.read('user'); + const globalMemory = this.read('global'); + const userCategoryMemory = this.readAllCategories('user'); + const globalCategoryMemory = this.readAllCategories('global'); + + const sections: PromptMemorySection[] = []; + + if (userMemory.length > 0) { + sections.push({ title: 'User Memory', content: userMemory }); + } + if (globalMemory.length > 0) { + sections.push({ title: 'Global Memory', content: globalMemory }); + } + + for (const category of MEMORY_CATEGORIES) { + const content = userCategoryMemory[category]; + if (content) { + sections.push({ + title: `User ${this._categoryLabel(category)}`, + content, + }); + } + } + + for (const category of MEMORY_CATEGORIES) { + const content = globalCategoryMemory[category]; + if (content) { + sections.push({ + title: `Global ${this._categoryLabel(category)}`, + content, + }); + } + } + + return sections; + } + // --------------------------------------------------------------------------- // Private helpers // --------------------------------------------------------------------------- @@ -236,4 +334,8 @@ export class MemoryStore { return namespaces; } + + private _categoryLabel(category: MemoryCategory): string { + return `${category.charAt(0).toUpperCase()}${category.slice(1)}`; + } } diff --git a/src/models/capabilities.ts b/src/models/capabilities.ts index 70dc91f..adc35fe 100644 --- a/src/models/capabilities.ts +++ b/src/models/capabilities.ts @@ -1,6 +1,6 @@ /** * Model capability detection for native audio input support. - * + * * Models that support native audio will receive raw audio data directly. * Models that don't will receive a Whisper transcript as text instead. */ @@ -27,12 +27,12 @@ const AUDIO_INCAPABLE_MODELS = new Set([ /** * Check whether a provider+model combination supports native audio input. - * + * * Returns true if the model can receive raw audio data directly via its API, * false if audio must be transcribed to text before sending. */ export function supportsAudioInput(provider: string, model: string, override?: boolean): boolean { - if (override !== undefined) return override; + if (override !== undefined) {return override;} // Provider must be in the capable set if (!AUDIO_CAPABLE_PROVIDERS.has(provider)) { diff --git a/src/session/index.ts b/src/session/index.ts index c792131..afe3225 100644 --- a/src/session/index.ts +++ b/src/session/index.ts @@ -1,2 +1,6 @@ export { SessionStore, parseDuration } from './store.js'; export { SessionManager, ManagedSession, type Session } from './manager.js'; +export { SessionIndexer, tokenize } from './indexer.js'; +export type { HistoryMetadata, HistoryIndexerConfig } from './indexer.js'; +export { SessionSearch } from './search.js'; +export type { HistorySearchResult, HistorySearchConfig } from './search.js'; diff --git a/src/session/manager.test.ts b/src/session/manager.test.ts index f0296ea..d4351f1 100644 --- a/src/session/manager.test.ts +++ b/src/session/manager.test.ts @@ -58,4 +58,37 @@ describe('SessionManager', () => { expect(sessions).toContain('telegram:user-123'); expect(sessions).toContain('tui:local'); }); + + it('indexes and searches history when enabled', () => { + manager = new SessionManager(store, { + enabled: true, + maxKeywords: 8, + searchLimit: 10, + minScore: 0.1, + }); + + const session = manager.getSession('telegram', 'user-123'); + session.addMessage({ role: 'user', content: 'deploy backend api' }); + + const results = manager.searchHistory('deploy backend'); + expect(results.length).toBeGreaterThan(0); + expect(results[0].sessionId).toBe('telegram:user-123'); + }); + + it('reindexHistory is safe and idempotent', () => { + manager = new SessionManager(store, { + enabled: true, + maxKeywords: 8, + searchLimit: 10, + minScore: 0.1, + }); + + const session = manager.getSession('telegram', 'user-abc'); + session.addMessage({ role: 'user', content: 'history indexing test' }); + + const first = manager.reindexHistory(); + const second = manager.reindexHistory(); + expect(first).toBeGreaterThan(0); + expect(second).toBe(first); + }); }); diff --git a/src/session/manager.ts b/src/session/manager.ts index 2c25899..090d655 100644 --- a/src/session/manager.ts +++ b/src/session/manager.ts @@ -1,6 +1,8 @@ import type { Message } from '../models/types.js'; import type { SessionStore } from './store.js'; import { auditLogger } from '../audit/index.js'; +import { SessionIndexer } from './indexer.js'; +import { SessionSearch, type HistorySearchResult } from './search.js'; export interface Session { id: string; @@ -18,6 +20,7 @@ export class ManagedSession implements Session { public readonly id: string, private store: SessionStore, private history: Message[] = [], + private indexer?: SessionIndexer, ) {} addMessage(message: Message): Message { @@ -26,16 +29,20 @@ export class ManagedSession implements Session { timestamp: Date.now(), }; this.history.push(messageWithTimestamp); - this.store.addMessage(this.id, messageWithTimestamp); - + const content = typeof message.content === 'string' + ? message.content + : JSON.stringify(message.content); + const metadata = this.indexer?.indexText(content); + this.store.addMessage(this.id, messageWithTimestamp, metadata); + auditLogger?.sessionMessage({ session_id: this.id, role: message.role, - content_length: typeof message.content === 'string' - ? message.content.length + content_length: typeof message.content === 'string' + ? message.content.length : JSON.stringify(message.content).length, }); - + return messageWithTimestamp; } @@ -47,7 +54,7 @@ export class ManagedSession implements Session { const messageCount = this.history.length; this.history = []; this.store.clearSession(this.id); - + auditLogger?.sessionDelete({ session_id: this.id, message_count: messageCount, @@ -83,8 +90,25 @@ export class ManagedSession implements Session { export class SessionManager { private sessions: Map = new Map(); + private indexer?: SessionIndexer; + private search?: SessionSearch; - constructor(private store: SessionStore) {} + constructor(private store: SessionStore, historyIndexConfig?: { + enabled: boolean; + maxKeywords: number; + searchLimit: number; + minScore: number; + }) { + if (historyIndexConfig?.enabled) { + this.indexer = new SessionIndexer({ + maxKeywords: historyIndexConfig.maxKeywords, + }); + this.search = new SessionSearch(store, { + limit: historyIndexConfig.searchLimit, + minScore: historyIndexConfig.minScore, + }); + } + } private makeSessionId(frontend: string, userId: string): string { return `${frontend}:${userId}`; @@ -96,9 +120,9 @@ export class SessionManager { let session = this.sessions.get(id); if (!session) { const history = this.store.getMessages(id); - session = new ManagedSession(id, this.store, history); + session = new ManagedSession(id, this.store, history, this.indexer); this.sessions.set(id, session); - + auditLogger?.sessionCreate({ session_id: id, frontend, @@ -125,7 +149,7 @@ export class SessionManager { for (const message of history) { toSession.addMessage(message); } - + auditLogger?.sessionTransfer(fromSession.id, toSession.id, history.length); } @@ -162,4 +186,24 @@ export class SessionManager { const session = this.getSession(frontend, userId); session.deleteConfig(key); } + + searchHistory(query: string, opts?: { limit?: number; sessionId?: string }): HistorySearchResult[] { + if (!this.search) { + return []; + } + return this.search.search(query, opts); + } + + reindexHistory(): number { + if (!this.indexer) { + return 0; + } + + const rows = this.store.getAllMessagesWithMetadata(); + for (const row of rows) { + const metadata = this.indexer.indexText(row.content); + this.store.updateMessageMetadata(row.id, metadata); + } + return rows.length; + } } diff --git a/src/session/store.test.ts b/src/session/store.test.ts index 5e18912..f50d914 100644 --- a/src/session/store.test.ts +++ b/src/session/store.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { SessionStore } from './store.js'; +import { SessionIndexer } from './indexer.js'; import { unlinkSync, existsSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; @@ -63,6 +64,16 @@ describe('SessionStore', () => { expect(sessions).toContain('session-b'); }); + it('stores and retrieves message metadata for indexed history', () => { + const indexer = new SessionIndexer({ maxKeywords: 5 }); + const metadata = indexer.indexText('deploy backend release'); + store.addMessage('session-meta', { role: 'user', content: 'deploy backend release' }, metadata); + + const rows = store.getMessagesWithMetadata('session-meta'); + expect(rows).toHaveLength(1); + expect(rows[0].metadata?.keywords).toContain('deploy'); + }); + describe('pairing persistence', () => { it('getPairingStore returns a PairingStore', () => { const pairingStore = store.getPairingStore(); diff --git a/src/session/store.ts b/src/session/store.ts index 30949aa..05fa65f 100644 --- a/src/session/store.ts +++ b/src/session/store.ts @@ -1,6 +1,7 @@ import Database from 'better-sqlite3'; import type { Message } from '../models/types.js'; import type { PairingStore, ApprovedSender } from '../channels/pairing.js'; +import type { HistoryMetadata } from './indexer.js'; /** Parse a duration string like '30d', '7d', '12h' to milliseconds. Returns null if invalid or '0'. */ export function parseDuration(s: string): number | null { @@ -44,13 +45,18 @@ export class SessionStore { ); CREATE INDEX IF NOT EXISTS idx_session_config_session ON session_config(session_id); `); + + const messageColumns = this.db.prepare('PRAGMA table_info(messages)').all() as Array<{ name: string }>; + if (!messageColumns.some(column => column.name === 'metadata')) { + this.db.exec('ALTER TABLE messages ADD COLUMN metadata TEXT'); + } } - addMessage(sessionId: string, message: Message): void { + addMessage(sessionId: string, message: Message, metadata?: HistoryMetadata): void { const stmt = this.db.prepare( - 'INSERT INTO messages (session_id, role, content) VALUES (?, ?, ?)', + 'INSERT INTO messages (session_id, role, content, metadata) VALUES (?, ?, ?, ?)', ); - stmt.run(sessionId, message.role, message.content); + stmt.run(sessionId, message.role, message.content, metadata ? JSON.stringify(metadata) : null); } getMessages(sessionId: string): Message[] { @@ -75,10 +81,10 @@ export class SessionStore { this.db.prepare('DELETE FROM messages WHERE session_id = ?').run(sessionId); // Re-insert in order const insert = this.db.prepare( - 'INSERT INTO messages (session_id, role, content) VALUES (?, ?, ?)', + 'INSERT INTO messages (session_id, role, content, metadata) VALUES (?, ?, ?, ?)', ); for (const msg of messages) { - insert.run(sessionId, msg.role, msg.content); + insert.run(sessionId, msg.role, msg.content, null); } }); transaction(); @@ -194,4 +200,68 @@ export class SessionStore { close(): void { this.db.close(); } + + getMessagesWithMetadata(sessionId: string): Array<{ + id: number; + sessionId: string; + role: 'user' | 'assistant'; + content: string; + createdAt: number; + metadata: HistoryMetadata | null; + }> { + const stmt = this.db.prepare( + 'SELECT id, session_id, role, content, created_at, metadata FROM messages WHERE session_id = ? ORDER BY id ASC', + ); + const rows = stmt.all(sessionId) as Array<{ + id: number; + session_id: string; + role: string; + content: string; + created_at: number; + metadata: string | null; + }>; + + return rows.map(row => ({ + id: row.id, + sessionId: row.session_id, + role: row.role as 'user' | 'assistant', + content: row.content, + createdAt: row.created_at, + metadata: row.metadata ? JSON.parse(row.metadata) as HistoryMetadata : null, + })); + } + + getAllMessagesWithMetadata(): Array<{ + id: number; + sessionId: string; + role: 'user' | 'assistant'; + content: string; + createdAt: number; + metadata: HistoryMetadata | null; + }> { + const stmt = this.db.prepare( + 'SELECT id, session_id, role, content, created_at, metadata FROM messages ORDER BY id ASC', + ); + const rows = stmt.all() as Array<{ + id: number; + session_id: string; + role: string; + content: string; + created_at: number; + metadata: string | null; + }>; + + return rows.map(row => ({ + id: row.id, + sessionId: row.session_id, + role: row.role as 'user' | 'assistant', + content: row.content, + createdAt: row.created_at, + metadata: row.metadata ? JSON.parse(row.metadata) as HistoryMetadata : null, + })); + } + + updateMessageMetadata(messageId: number, metadata: HistoryMetadata): void { + this.db.prepare('UPDATE messages SET metadata = ? WHERE id = ?').run(JSON.stringify(metadata), messageId); + } } diff --git a/src/tools/builtin/memory-read.ts b/src/tools/builtin/memory-read.ts index 46c90ca..24df4a0 100644 --- a/src/tools/builtin/memory-read.ts +++ b/src/tools/builtin/memory-read.ts @@ -13,7 +13,7 @@ export function createMemoryReadTool(store: MemoryStore): Tool { return { name: 'memory.read', description: - 'Read a persistent memory file by namespace. Available namespaces include "user" (user preferences and facts), "global" (cross-session knowledge), and session-specific namespaces. Returns the full contents of the memory file.', + 'Read a persistent memory file by namespace. Available namespaces include "user" (user preferences and facts), "global" (cross-session knowledge), and session-specific namespaces. Supports structured categories by appending /facts, /preferences, /decisions, or /projects (for example: "user/facts"). Returns the full contents of the memory file.', inputSchema: { type: 'object', properties: { diff --git a/src/tools/builtin/memory-search.ts b/src/tools/builtin/memory-search.ts index 0c22d41..db84bd2 100644 --- a/src/tools/builtin/memory-search.ts +++ b/src/tools/builtin/memory-search.ts @@ -16,7 +16,8 @@ export function createMemorySearchTool(store: MemoryStore, hybridSearch?: Hybrid name: 'memory.search', description: 'Search across all memory files for a keyword or phrase. Returns matching lines with surrounding context from every namespace.' + - (hybridSearch ? ' Uses semantic vector search combined with keyword matching for better results.' : ''), + (hybridSearch ? ' Uses semantic vector search combined with keyword matching for better results.' : '') + + ' Category namespaces (facts/preferences/decisions/projects) are searchable through the namespace path.', inputSchema: { type: 'object', properties: { diff --git a/src/tools/builtin/memory-write.ts b/src/tools/builtin/memory-write.ts index 7915c9b..d646d50 100644 --- a/src/tools/builtin/memory-write.ts +++ b/src/tools/builtin/memory-write.ts @@ -15,7 +15,7 @@ export function createMemoryWriteTool(store: MemoryStore): Tool { return { name: 'memory.write', description: - 'Write to a persistent memory file. Use mode="append" to add new information without overwriting existing content, or mode="replace" to overwrite the entire namespace.', + 'Write to a persistent memory file. Use mode="append" to add new information without overwriting existing content, or mode="replace" to overwrite the entire namespace. Supports structured category namespaces like "user/facts", "user/preferences", "user/decisions", and "user/projects".', inputSchema: { type: 'object', properties: {