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:
@@ -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
@@ -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
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -161,9 +161,9 @@ export class CronScheduler implements ChannelAdapter {
|
||||
}
|
||||
|
||||
this.jobs.delete(name);
|
||||
|
||||
|
||||
auditLogger?.cronRemove(name);
|
||||
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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()', () => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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');
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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({});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 () => {});
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
@@ -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)}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user