feat(session): persist model tier overrides per session

Store per-session config in SQLite and route /model and /reset through command fast-paths so channel sessions keep independent model selection across reconnects and restarts.
This commit is contained in:
William Valentin
2026-02-13 01:04:26 -08:00
parent 3472a0b926
commit 9f81c01603
35 changed files with 1438 additions and 144 deletions
+24
View File
@@ -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.
@@ -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
+46 -1
View File
@@ -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",
+48 -27
View File
@@ -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<string, unknown> });
}
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<string, unknown> });
}
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<string, unknown> });
}
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<string, unknown> });
}
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<string, unknown>,
});
}
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<string, unknown>,
});
}
// ── 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<string, unknown> });
}
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<string, unknown> });
}
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<string, unknown> });
}
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<string, unknown> });
}
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<string, unknown> });
}
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<string, unknown> });
}
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<string, unknown> });
}
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<string, unknown> });
}
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<string, unknown> });
}
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<string, unknown> });
}
// 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<string, unknown> });
}
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<string, unknown> });
}
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<string, unknown>): 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<string, unknown>): void {
if (!this.config.enabled) return;
if (!this.config.enabled) {return;}
this.write({
level: 'info',
event_type: 'system.config',
+4 -4
View File
@@ -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);
+20
View File
@@ -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;
+2 -2
View File
@@ -161,9 +161,9 @@ export class CronScheduler implements ChannelAdapter {
}
this.jobs.delete(name);
auditLogger?.cronRemove(name);
return true;
}
}
+2 -2
View File
@@ -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,
});
+96 -3
View File
@@ -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()', () => {
+45 -3
View File
@@ -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<string, TierUsageStats> = 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<string> {
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.
+3 -3
View File
@@ -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',
+3 -1
View File
@@ -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');
+29
View File
@@ -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', () => {
+17
View File
@@ -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({});
+26
View File
@@ -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);
});
});
+33 -5
View File
@@ -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]),
};
}
+320 -1
View File
@@ -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);
});
});
+21
View File
@@ -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));
+68 -47
View File
@@ -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;
}
}
+123
View File
@@ -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 () => {});
+6
View File
@@ -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';
+33
View File
@@ -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<void> {
+89
View File
@@ -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<string, any> = {};
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');
});
});
+3
View File
@@ -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';
+63
View File
@@ -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', () => {
+116 -14
View File
@@ -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<MemoryCategory>();
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<Record<MemoryCategory, string>> {
const result: Partial<Record<MemoryCategory, string>> = {};
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)}`;
}
}
+3 -3
View File
@@ -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<string>([
/**
* 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)) {
+4
View File
@@ -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';
+33
View File
@@ -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);
});
});
+54 -10
View File
@@ -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<string, ManagedSession> = 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;
}
}
+11
View File
@@ -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();
+75 -5
View File
@@ -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);
}
}
+1 -1
View File
@@ -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: {
+2 -1
View File
@@ -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: {
+1 -1
View File
@@ -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: {