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:
|
silent:
|
||||||
- notify
|
- 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 ──────────────────────────────────────────────────────
|
# ── Automation ──────────────────────────────────────────────────────
|
||||||
# Uncomment and configure any automation sources you need.
|
# Uncomment and configure any automation sources you need.
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# Plan: Model Persistence with Per-Session Overrides
|
# Plan: Model Persistence with Per-Session Overrides
|
||||||
|
|
||||||
|
Status: implemented (2026-02-13)
|
||||||
|
|
||||||
## Summary
|
## 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:
|
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
|
## 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
|
### Unit Tests
|
||||||
|
|
||||||
- [ ] **`src/session/store.test.ts`**: Test `session_config` CRUD — set, get, getAll, delete, clearAll
|
- [x] **`src/session/store.test.ts`**: Test `session_config` CRUD — set, get, getAll, delete, clearAll
|
||||||
- [ ] **`src/session/store.test.ts`**: Test `clearSession()` also clears config
|
- [x] **`src/session/store.test.ts`**: Test `clearSession()` also clears config
|
||||||
- [ ] **`src/session/store.test.ts`**: Test `pruneStale()` also cleans config
|
- [x] **`src/session/store.test.ts`**: Test `pruneStale()` also cleans config
|
||||||
- [ ] **`src/session/manager.test.ts`**: Test `ManagedSession.getConfig/setConfig/deleteConfig`
|
- [x] **`src/session/manager.test.ts`**: Test `ManagedSession.getConfig/setConfig/deleteConfig`
|
||||||
- [ ] **`src/daemon/routing.test.ts`**: Test model resolution chain (session → agent → global)
|
- [x] **`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
|
- [x] **`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
|
- [x] **`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
|
- [x] **`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
|
- [x] **`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/gateway/handlers/agent.test.ts`**: Test `agent.send` with model command
|
||||||
|
|
||||||
### Integration Tests
|
### Integration Tests
|
||||||
|
|
||||||
|
|||||||
+46
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"version": "1.0",
|
"version": "1.0",
|
||||||
"updated_at": "2026-02-12",
|
"updated_at": "2026-02-13",
|
||||||
"description": "Tracks the status of all Flynn plans and implementation phases",
|
"description": "Tracks the status of all Flynn plans and implementation phases",
|
||||||
|
|
||||||
"plans": {
|
"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"
|
"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": {
|
"skills_infrastructure": {
|
||||||
"file": "2026-02-11-skills-infrastructure-plan.md",
|
"file": "2026-02-11-skills-infrastructure-plan.md",
|
||||||
"status": "planned",
|
"status": "planned",
|
||||||
|
|||||||
+46
-25
@@ -7,6 +7,8 @@ import type {
|
|||||||
ToolSuccessEvent,
|
ToolSuccessEvent,
|
||||||
ToolErrorEvent,
|
ToolErrorEvent,
|
||||||
ToolDeniedEvent,
|
ToolDeniedEvent,
|
||||||
|
SkillsInstallerExecutionBlockedEvent,
|
||||||
|
SkillsInstallerCommandResultEvent,
|
||||||
SessionCreateEvent,
|
SessionCreateEvent,
|
||||||
SessionMessageEvent,
|
SessionMessageEvent,
|
||||||
SessionDeleteEvent,
|
SessionDeleteEvent,
|
||||||
@@ -67,49 +69,68 @@ export class AuditLogger {
|
|||||||
// ── Tool Events ───────────────────────────────────────────────
|
// ── Tool Events ───────────────────────────────────────────────
|
||||||
|
|
||||||
toolStart(event: ToolStartEvent): void {
|
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> });
|
this.write({ level: 'debug', event_type: 'tool.start', event: event as unknown as Record<string, unknown> });
|
||||||
}
|
}
|
||||||
|
|
||||||
toolSuccess(event: ToolSuccessEvent): void {
|
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> });
|
this.write({ level: 'debug', event_type: 'tool.success', event: event as unknown as Record<string, unknown> });
|
||||||
}
|
}
|
||||||
|
|
||||||
toolError(event: ToolErrorEvent): void {
|
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> });
|
this.write({ level: 'error', event_type: 'tool.error', event: event as unknown as Record<string, unknown> });
|
||||||
}
|
}
|
||||||
|
|
||||||
toolDenied(event: ToolDeniedEvent): void {
|
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> });
|
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 ───────────────────────────────────────────
|
// ── Session Events ───────────────────────────────────────────
|
||||||
|
|
||||||
sessionCreate(event: SessionCreateEvent): void {
|
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> });
|
this.write({ level: 'debug', event_type: 'session.create', event: event as unknown as Record<string, unknown> });
|
||||||
}
|
}
|
||||||
|
|
||||||
sessionMessage(event: SessionMessageEvent): void {
|
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> });
|
this.write({ level: 'debug', event_type: 'session.message', event: event as unknown as Record<string, unknown> });
|
||||||
}
|
}
|
||||||
|
|
||||||
sessionDelete(event: SessionDeleteEvent): void {
|
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> });
|
this.write({ level: 'debug', event_type: 'session.delete', event: event as unknown as Record<string, unknown> });
|
||||||
}
|
}
|
||||||
|
|
||||||
sessionCompact(event: SessionCompactEvent): void {
|
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> });
|
this.write({ level: 'debug', event_type: 'session.compact', event: event as unknown as Record<string, unknown> });
|
||||||
}
|
}
|
||||||
|
|
||||||
sessionTransfer(from: string, to: string, messageCount: number): void {
|
sessionTransfer(from: string, to: string, messageCount: number): void {
|
||||||
if (!this.shouldLog('sessions', 'debug')) return;
|
if (!this.shouldLog('sessions', 'debug')) {return;}
|
||||||
this.write({
|
this.write({
|
||||||
level: 'debug',
|
level: 'debug',
|
||||||
event_type: 'session.transfer',
|
event_type: 'session.transfer',
|
||||||
@@ -121,12 +142,12 @@ export class AuditLogger {
|
|||||||
|
|
||||||
// Cron
|
// Cron
|
||||||
cronTrigger(event: CronTriggerEvent): void {
|
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> });
|
this.write({ level: 'debug', event_type: 'cron.trigger', event: event as unknown as Record<string, unknown> });
|
||||||
}
|
}
|
||||||
|
|
||||||
cronAdd(jobName: string, schedule: string): void {
|
cronAdd(jobName: string, schedule: string): void {
|
||||||
if (!this.shouldLog('automation', 'info')) return;
|
if (!this.shouldLog('automation', 'info')) {return;}
|
||||||
this.write({
|
this.write({
|
||||||
level: 'info',
|
level: 'info',
|
||||||
event_type: 'cron.add',
|
event_type: 'cron.add',
|
||||||
@@ -135,7 +156,7 @@ export class AuditLogger {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cronRemove(jobName: string): void {
|
cronRemove(jobName: string): void {
|
||||||
if (!this.shouldLog('automation', 'info')) return;
|
if (!this.shouldLog('automation', 'info')) {return;}
|
||||||
this.write({
|
this.write({
|
||||||
level: 'info',
|
level: 'info',
|
||||||
event_type: 'cron.remove',
|
event_type: 'cron.remove',
|
||||||
@@ -145,12 +166,12 @@ export class AuditLogger {
|
|||||||
|
|
||||||
// Webhook
|
// Webhook
|
||||||
webhookReceive(event: WebhookReceiveEvent): void {
|
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> });
|
this.write({ level: 'debug', event_type: 'webhook.receive', event: event as unknown as Record<string, unknown> });
|
||||||
}
|
}
|
||||||
|
|
||||||
webhookNotFound(webhookName: string): void {
|
webhookNotFound(webhookName: string): void {
|
||||||
if (!this.shouldLog('automation', 'warn')) return;
|
if (!this.shouldLog('automation', 'warn')) {return;}
|
||||||
this.write({
|
this.write({
|
||||||
level: 'warn',
|
level: 'warn',
|
||||||
event_type: 'webhook.not_found',
|
event_type: 'webhook.not_found',
|
||||||
@@ -159,7 +180,7 @@ export class AuditLogger {
|
|||||||
}
|
}
|
||||||
|
|
||||||
webhookDenied(webhookName: string, reason: string): void {
|
webhookDenied(webhookName: string, reason: string): void {
|
||||||
if (!this.shouldLog('automation', 'warn')) return;
|
if (!this.shouldLog('automation', 'warn')) {return;}
|
||||||
this.write({
|
this.write({
|
||||||
level: 'warn',
|
level: 'warn',
|
||||||
event_type: 'webhook.denied',
|
event_type: 'webhook.denied',
|
||||||
@@ -169,38 +190,38 @@ export class AuditLogger {
|
|||||||
|
|
||||||
// Heartbeat
|
// Heartbeat
|
||||||
heartbeatCycle(event: HeartbeatCycleEvent): void {
|
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> });
|
this.write({ level: 'debug', event_type: 'heartbeat.cycle', event: event as unknown as Record<string, unknown> });
|
||||||
}
|
}
|
||||||
|
|
||||||
heartbeatCheck(event: HeartbeatCheckEvent): void {
|
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> });
|
this.write({ level: 'debug', event_type: 'heartbeat.check', event: event as unknown as Record<string, unknown> });
|
||||||
}
|
}
|
||||||
|
|
||||||
heartbeatFail(event: HeartbeatFailEvent): void {
|
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> });
|
this.write({ level: 'warn', event_type: 'heartbeat.fail', event: event as unknown as Record<string, unknown> });
|
||||||
}
|
}
|
||||||
|
|
||||||
heartbeatRecover(event: HeartbeatRecoverEvent): void {
|
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> });
|
this.write({ level: 'info', event_type: 'heartbeat.recover', event: event as unknown as Record<string, unknown> });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gmail
|
// Gmail
|
||||||
gmailPoll(event: GmailPollEvent): void {
|
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> });
|
this.write({ level: 'debug', event_type: 'gmail.poll', event: event as unknown as Record<string, unknown> });
|
||||||
}
|
}
|
||||||
|
|
||||||
gmailNewEmail(event: GmailNewEmailEvent): void {
|
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> });
|
this.write({ level: 'debug', event_type: 'gmail.new_email', event: event as unknown as Record<string, unknown> });
|
||||||
}
|
}
|
||||||
|
|
||||||
gmailError(error: string, context?: string): void {
|
gmailError(error: string, context?: string): void {
|
||||||
if (!this.shouldLog('automation', 'error')) return;
|
if (!this.shouldLog('automation', 'error')) {return;}
|
||||||
this.write({
|
this.write({
|
||||||
level: 'error',
|
level: 'error',
|
||||||
event_type: 'gmail.error',
|
event_type: 'gmail.error',
|
||||||
@@ -211,7 +232,7 @@ export class AuditLogger {
|
|||||||
// ── System Events ────────────────────────────────────────────
|
// ── System Events ────────────────────────────────────────────
|
||||||
|
|
||||||
systemStart(component: string, config?: Record<string, unknown>): void {
|
systemStart(component: string, config?: Record<string, unknown>): void {
|
||||||
if (!this.config.enabled) return;
|
if (!this.config.enabled) {return;}
|
||||||
this.write({
|
this.write({
|
||||||
level: 'info',
|
level: 'info',
|
||||||
event_type: 'system.start',
|
event_type: 'system.start',
|
||||||
@@ -220,7 +241,7 @@ export class AuditLogger {
|
|||||||
}
|
}
|
||||||
|
|
||||||
systemStop(component: string, reason?: string): void {
|
systemStop(component: string, reason?: string): void {
|
||||||
if (!this.config.enabled) return;
|
if (!this.config.enabled) {return;}
|
||||||
this.write({
|
this.write({
|
||||||
level: 'info',
|
level: 'info',
|
||||||
event_type: 'system.stop',
|
event_type: 'system.stop',
|
||||||
@@ -229,7 +250,7 @@ export class AuditLogger {
|
|||||||
}
|
}
|
||||||
|
|
||||||
systemConfig(component: string, action: string, config: Record<string, unknown>): void {
|
systemConfig(component: string, action: string, config: Record<string, unknown>): void {
|
||||||
if (!this.config.enabled) return;
|
if (!this.config.enabled) {return;}
|
||||||
this.write({
|
this.write({
|
||||||
level: 'info',
|
level: 'info',
|
||||||
event_type: 'system.config',
|
event_type: 'system.config',
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ export class AuditRotator {
|
|||||||
const files = await fs.readdir(dir);
|
const files = await fs.readdir(dir);
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
if (!file.startsWith(baseName)) continue;
|
if (!file.startsWith(baseName)) {continue;}
|
||||||
|
|
||||||
const filePath = `${dir}/${file}`;
|
const filePath = `${dir}/${file}`;
|
||||||
const stats = await fs.stat(filePath);
|
const stats = await fs.stat(filePath);
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ export type AuditLevel = 'debug' | 'info' | 'warn' | 'error';
|
|||||||
export type AuditEventType =
|
export type AuditEventType =
|
||||||
// Tool execution
|
// Tool execution
|
||||||
| 'tool.start' | 'tool.success' | 'tool.error' | 'tool.denied'
|
| 'tool.start' | 'tool.success' | 'tool.error' | 'tool.denied'
|
||||||
|
// Skills installer
|
||||||
|
| 'skills.installer.execution_blocked' | 'skills.installer.command_result'
|
||||||
// Session lifecycle
|
// Session lifecycle
|
||||||
| 'session.create' | 'session.message' | 'session.delete' | 'session.transfer' | 'session.compact'
|
| 'session.create' | 'session.message' | 'session.delete' | 'session.transfer' | 'session.compact'
|
||||||
// Automation - Cron
|
// Automation - Cron
|
||||||
@@ -75,6 +77,24 @@ export interface ToolDeniedEvent {
|
|||||||
denial_type: 'policy' | 'hook' | 'not_found' | 'autonomy_override';
|
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 {
|
export interface SessionCreateEvent {
|
||||||
session_id: string;
|
session_id: string;
|
||||||
frontend: string;
|
frontend: string;
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ import { ModelRouter } from '../../models/router.js';
|
|||||||
import type { ChatResponse, ModelClient } from '../../models/types.js';
|
import type { ChatResponse, ModelClient } from '../../models/types.js';
|
||||||
import { ToolRegistry, ToolExecutor } from '../../tools/index.js';
|
import { ToolRegistry, ToolExecutor } from '../../tools/index.js';
|
||||||
import { HookEngine } from '../../hooks/engine.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', () => {
|
describe('AgentOrchestrator', () => {
|
||||||
let mockDefaultClient: ModelClient;
|
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()', () => {
|
describe('delegate()', () => {
|
||||||
it('routes to the correct tier when specified', async () => {
|
it('routes to the correct tier when specified', async () => {
|
||||||
const orchestrator = new AgentOrchestrator({
|
const orchestrator = new AgentOrchestrator({
|
||||||
@@ -69,7 +80,7 @@ describe('AgentOrchestrator', () => {
|
|||||||
});
|
});
|
||||||
const mockToolExecutor = new ToolExecutor(mockToolRegistry, hooks);
|
const mockToolExecutor = new ToolExecutor(mockToolRegistry, hooks);
|
||||||
|
|
||||||
const mockFastChatClient = mockRouter.getClient('fast')!;
|
const mockFastChatClient = requireClient('fast');
|
||||||
const mockFastChatFn = vi.fn().mockResolvedValue({
|
const mockFastChatFn = vi.fn().mockResolvedValue({
|
||||||
content: 'response with tools',
|
content: 'response with tools',
|
||||||
stopReason: 'end_turn',
|
stopReason: 'end_turn',
|
||||||
@@ -298,7 +309,7 @@ describe('AgentOrchestrator', () => {
|
|||||||
|
|
||||||
describe('process()', () => {
|
describe('process()', () => {
|
||||||
it('proxies to NativeAgent for user messages', async () => {
|
it('proxies to NativeAgent for user messages', async () => {
|
||||||
const mockDefaultChatClient = mockRouter.getClient('default')!;
|
const mockDefaultChatClient = requireClient('default');
|
||||||
const mockDefaultChatFn = vi.fn().mockResolvedValue({
|
const mockDefaultChatFn = vi.fn().mockResolvedValue({
|
||||||
content: 'Agent response',
|
content: 'Agent response',
|
||||||
stopReason: 'end_turn',
|
stopReason: 'end_turn',
|
||||||
@@ -355,6 +366,88 @@ describe('AgentOrchestrator', () => {
|
|||||||
expect(history[4]).toEqual({ role: 'user', content: 'Tell me about yourself' });
|
expect(history[4]).toEqual({ role: 'user', content: 'Tell me about yourself' });
|
||||||
expect(history[5]).toEqual({ role: 'assistant', content: 'default response' });
|
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()', () => {
|
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 { compactHistory, type CompactionConfig, type CompactionResult, DEFAULT_COMPACTION_CONFIG } from '../../context/compaction.js';
|
||||||
import { estimateCost } from '../../models/costs.js';
|
import { estimateCost } from '../../models/costs.js';
|
||||||
import { auditLogger } from '../../audit/index.js';
|
import { auditLogger } from '../../audit/index.js';
|
||||||
|
import { buildAdaptiveMemoryContext, buildRecentMemoryContext } from '../../memory/adaptive.js';
|
||||||
|
|
||||||
// ── Public types ──────────────────────────────────────────────────────
|
// ── Public types ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -91,6 +92,10 @@ export interface OrchestratorConfig {
|
|||||||
contextWindow?: number;
|
contextWindow?: number;
|
||||||
/** Optional memory store for injecting persistent memory into the system prompt. */
|
/** Optional memory store for injecting persistent memory into the system prompt. */
|
||||||
memoryStore?: MemoryStore;
|
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). */
|
/** Policy context for tool filtering (agent tier, provider). */
|
||||||
toolPolicyContext?: ToolPolicyContext;
|
toolPolicyContext?: ToolPolicyContext;
|
||||||
/** Collector for outbound attachments queued by tools (e.g. media.send). */
|
/** Collector for outbound attachments queued by tools (e.g. media.send). */
|
||||||
@@ -118,6 +123,8 @@ export class AgentOrchestrator {
|
|||||||
private _modelName?: string;
|
private _modelName?: string;
|
||||||
private _contextWindow?: number;
|
private _contextWindow?: number;
|
||||||
private _memoryStore?: MemoryStore;
|
private _memoryStore?: MemoryStore;
|
||||||
|
private _memoryInjectionStrategy: 'all' | 'recent' | 'adaptive';
|
||||||
|
private _memoryMaxInjectionTokens: number;
|
||||||
private _systemPromptBase: string;
|
private _systemPromptBase: string;
|
||||||
private _usageByTier: Map<string, TierUsageStats> = new Map();
|
private _usageByTier: Map<string, TierUsageStats> = new Map();
|
||||||
|
|
||||||
@@ -131,6 +138,8 @@ export class AgentOrchestrator {
|
|||||||
this._modelName = config.modelName;
|
this._modelName = config.modelName;
|
||||||
this._contextWindow = config.contextWindow;
|
this._contextWindow = config.contextWindow;
|
||||||
this._memoryStore = config.memoryStore;
|
this._memoryStore = config.memoryStore;
|
||||||
|
this._memoryInjectionStrategy = config.memoryInjectionStrategy ?? 'all';
|
||||||
|
this._memoryMaxInjectionTokens = config.memoryMaxInjectionTokens ?? 2000;
|
||||||
this._systemPromptBase = config.systemPrompt;
|
this._systemPromptBase = config.systemPrompt;
|
||||||
|
|
||||||
// Create the primary NativeAgent for user-facing conversation
|
// 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.
|
* exceeds the context window threshold and compacts it before processing.
|
||||||
*/
|
*/
|
||||||
async process(userMessage: string, attachments?: Attachment[]): Promise<string> {
|
async process(userMessage: string, attachments?: Attachment[]): Promise<string> {
|
||||||
this._injectMemoryContext();
|
this._injectMemoryContext(userMessage);
|
||||||
await this.compactIfNeeded();
|
await this.compactIfNeeded();
|
||||||
return this._agent.process(userMessage, attachments);
|
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
|
* system prompt. If no memory store is configured or no memory content
|
||||||
* exists, restores the original base prompt.
|
* exists, restores the original base prompt.
|
||||||
*/
|
*/
|
||||||
private _injectMemoryContext(): void {
|
private _injectMemoryContext(userMessage: string): void {
|
||||||
if (!this._memoryStore) {
|
if (!this._memoryStore) {
|
||||||
return;
|
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) {
|
if (!memoryContext) {
|
||||||
this._agent.setSystemPrompt(this._systemPromptBase);
|
this._agent.setSystemPrompt(this._systemPromptBase);
|
||||||
return;
|
return;
|
||||||
@@ -370,6 +401,17 @@ export class AgentOrchestrator {
|
|||||||
this._agent.setSystemPrompt(enrichedPrompt);
|
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.
|
* Check whether automatic compaction should run, and if so, compact.
|
||||||
* Called before each `process()` call when compaction is configured.
|
* Called before each `process()` call when compaction is configured.
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ export class TelegramAdapter implements ChannelAdapter {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.bot.command('model', async (ctx) => {
|
this.bot.command('model', async (ctx) => {
|
||||||
if (!this.messageHandler) return;
|
if (!this.messageHandler) {return;}
|
||||||
|
|
||||||
const args = ctx.message?.text?.replace(/^\/model\s*/, '').trim() ?? '';
|
const args = ctx.message?.text?.replace(/^\/model\s*/, '').trim() ?? '';
|
||||||
|
|
||||||
@@ -184,7 +184,7 @@ export class TelegramAdapter implements ChannelAdapter {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.bot.command('local', async (ctx) => {
|
this.bot.command('local', async (ctx) => {
|
||||||
if (!this.messageHandler) return;
|
if (!this.messageHandler) {return;}
|
||||||
this.messageHandler({
|
this.messageHandler({
|
||||||
id: String(ctx.message?.message_id ?? Date.now()),
|
id: String(ctx.message?.message_id ?? Date.now()),
|
||||||
channel: 'telegram',
|
channel: 'telegram',
|
||||||
@@ -197,7 +197,7 @@ export class TelegramAdapter implements ChannelAdapter {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.bot.command('cloud', async (ctx) => {
|
this.bot.command('cloud', async (ctx) => {
|
||||||
if (!this.messageHandler) return;
|
if (!this.messageHandler) {return;}
|
||||||
this.messageHandler({
|
this.messageHandler({
|
||||||
id: String(ctx.message?.message_id ?? Date.now()),
|
id: String(ctx.message?.message_id ?? Date.now()),
|
||||||
channel: 'telegram',
|
channel: 'telegram',
|
||||||
|
|||||||
+3
-1
@@ -82,7 +82,7 @@ export function registerTuiCommand(program: Command): void {
|
|||||||
setLogLevel(tuiLogLevel);
|
setLogLevel(tuiLogLevel);
|
||||||
const { MinimalTui, startFullscreenTui } = await import('../frontends/tui/index.js');
|
const { MinimalTui, startFullscreenTui } = await import('../frontends/tui/index.js');
|
||||||
const { NativeAgent } = await import('../backends/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 { HookEngine } = await import('../hooks/index.js');
|
||||||
const { createModelRouter } = await import('../daemon/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 toolExecutor = new ToolExecutor(toolRegistry, hookEngine);
|
||||||
|
|
||||||
const session = sessionManager.getSession('tui', 'local');
|
const session = sessionManager.getSession('tui', 'local');
|
||||||
|
|||||||
@@ -150,6 +150,35 @@ describe('configSchema — skills watcher', () => {
|
|||||||
const result = configSchema.parse(minimalConfig);
|
const result = configSchema.parse(minimalConfig);
|
||||||
expect(result.skills.load.watch).toBe(false);
|
expect(result.skills.load.watch).toBe(false);
|
||||||
expect(result.skills.load.watch_debounce_ms).toBe(250);
|
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', () => {
|
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),
|
watch_debounce_ms: z.number().min(10).max(10_000).default(250),
|
||||||
}).default({});
|
}).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({
|
const skillsSchema = z.object({
|
||||||
/** Directory for user-created workspace skills. */
|
/** Directory for user-created workspace skills. */
|
||||||
workspace_dir: z.string().optional(),
|
workspace_dir: z.string().optional(),
|
||||||
@@ -115,6 +124,14 @@ const skillsSchema = z.object({
|
|||||||
managed_dir: z.string().optional(),
|
managed_dir: z.string().optional(),
|
||||||
/** Directory for bundled skills shipped with Flynn. */
|
/** Directory for bundled skills shipped with Flynn. */
|
||||||
bundled_dir: z.string().optional(),
|
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. */
|
/** Skills watcher settings. */
|
||||||
load: skillsLoadSchema,
|
load: skillsLoadSchema,
|
||||||
}).default({});
|
}).default({});
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ describe('compactHistory', () => {
|
|||||||
thresholdPct: 80,
|
thresholdPct: 80,
|
||||||
keepTurns: 2, // keeps last 4 messages
|
keepTurns: 2, // keeps last 4 messages
|
||||||
summaryMaxTokens: 1024,
|
summaryMaxTokens: 1024,
|
||||||
|
importanceThreshold: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
it('returns no-op when messages count is at or below keepTurns threshold', async () => {
|
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.thresholdPct).toBe(80);
|
||||||
expect(DEFAULT_COMPACTION_CONFIG.keepTurns).toBe(4);
|
expect(DEFAULT_COMPACTION_CONFIG.keepTurns).toBe(4);
|
||||||
expect(DEFAULT_COMPACTION_CONFIG.summaryMaxTokens).toBe(1024);
|
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 () => {
|
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].role).toBe('user');
|
||||||
expect(result.messages[1].content).toBe('Message 6');
|
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 { COMPACTION_SYSTEM_PROMPT, MEMORY_EXTRACTION_PROMPT } from '../backends/native/prompts.js';
|
||||||
import { estimateMessageTokens } from './tokens.js';
|
import { estimateMessageTokens } from './tokens.js';
|
||||||
import { getMessageText } from '../models/media.js';
|
import { getMessageText } from '../models/media.js';
|
||||||
|
import { selectImportantMessages } from './weighting.js';
|
||||||
|
|
||||||
export interface CompactionConfig {
|
export interface CompactionConfig {
|
||||||
/** Percentage of context window that triggers compaction (default: 80). */
|
/** Percentage of context window that triggers compaction (default: 80). */
|
||||||
@@ -12,6 +13,8 @@ export interface CompactionConfig {
|
|||||||
keepTurns: number;
|
keepTurns: number;
|
||||||
/** Maximum tokens for the compaction summary response. */
|
/** Maximum tokens for the compaction summary response. */
|
||||||
summaryMaxTokens: number;
|
summaryMaxTokens: number;
|
||||||
|
/** Preserve messages at or above this importance score from compaction. */
|
||||||
|
importanceThreshold: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CompactionResult {
|
export interface CompactionResult {
|
||||||
@@ -29,6 +32,7 @@ export const DEFAULT_COMPACTION_CONFIG: CompactionConfig = {
|
|||||||
thresholdPct: 80,
|
thresholdPct: 80,
|
||||||
keepTurns: 4,
|
keepTurns: 4,
|
||||||
summaryMaxTokens: 1024,
|
summaryMaxTokens: 1024,
|
||||||
|
importanceThreshold: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function compactHistory(opts: {
|
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
|
// Ensure toKeep starts with a user message to avoid assistant→assistant
|
||||||
// after the compaction summary (which has role 'assistant').
|
// after the compaction summary (which has role 'assistant').
|
||||||
while (toKeep.length > 0 && toKeep[0].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');
|
const tier = orchestrator.getDelegationTier('compaction');
|
||||||
|
|
||||||
@@ -99,9 +127,9 @@ export async function compactHistory(opts: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
messages: [summaryMessage, ...toKeep],
|
messages: [...preservedMessages, summaryMessage, ...toKeep],
|
||||||
compactedCount: toCompact.length,
|
compactedCount: toSummarize.length,
|
||||||
tokensBefore: estimateMessageTokens(messages),
|
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 { AgentRouter } from '../agents/router.js';
|
||||||
import { AgentConfigRegistry } from '../agents/registry.js';
|
import { AgentConfigRegistry } from '../agents/registry.js';
|
||||||
import type { ModelTier } from '../models/router.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', () => {
|
describe('daemon agent routing integration', () => {
|
||||||
it('resolves agent config for channel messages', () => {
|
it('resolves agent config for channel messages', () => {
|
||||||
@@ -61,3 +66,317 @@ describe('daemon agent routing integration', () => {
|
|||||||
expect(resolveTier(undefined, undefined, undefined)).toBe('default');
|
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.');
|
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 () => {
|
it('falls through to agent.process for unknown commands', async () => {
|
||||||
const sent: OutboundMessage[] = [];
|
const sent: OutboundMessage[] = [];
|
||||||
const send = vi.fn((msg: OutboundMessage) => sent.push(msg));
|
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 { Attachment } from '../../channels/types.js';
|
||||||
import type { SessionManager } from '../../session/manager.js';
|
import type { SessionManager } from '../../session/manager.js';
|
||||||
import type { ModelTier } from '../../models/router.js';
|
import type { ModelTier } from '../../models/router.js';
|
||||||
|
import type { CommandRegistry } from '../../commands/index.js';
|
||||||
|
|
||||||
export interface AgentHandlerDeps {
|
export interface AgentHandlerDeps {
|
||||||
sessionBridge: SessionBridge;
|
sessionBridge: SessionBridge;
|
||||||
laneQueue: LaneQueue;
|
laneQueue: LaneQueue;
|
||||||
metrics?: MetricsCollector;
|
metrics?: MetricsCollector;
|
||||||
sessionManager?: SessionManager;
|
sessionManager?: SessionManager;
|
||||||
|
commandRegistry?: CommandRegistry;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createAgentHandlers(deps: AgentHandlerDeps) {
|
export function createAgentHandlers(deps: AgentHandlerDeps) {
|
||||||
@@ -46,59 +48,78 @@ export function createAgentHandlers(deps: AgentHandlerDeps) {
|
|||||||
return deps.laneQueue.enqueue(laneId, async () => {
|
return deps.laneQueue.enqueue(laneId, async () => {
|
||||||
deps.sessionBridge.setBusy(connectionId, true);
|
deps.sessionBridge.setBusy(connectionId, true);
|
||||||
|
|
||||||
// Handle slash commands via metadata (mirrors daemon/routing.ts pattern)
|
const commandInput = params.metadata?.isCommand && typeof params.metadata.command === 'string'
|
||||||
if (params.metadata?.isCommand) {
|
? `/${params.metadata.command}${params.metadata.commandArgs ? ` ${params.metadata.commandArgs}` : ''}`
|
||||||
try {
|
: params.message;
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (params.metadata.command === 'model') {
|
if (commandInput && deps.commandRegistry?.isCommand(commandInput)) {
|
||||||
const modelArg = params.metadata.commandArgs as string | undefined;
|
const sessionId = deps.sessionBridge.getSessionId(connectionId);
|
||||||
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) {
|
const delegationEntries = Object.entries(usage.delegation);
|
||||||
// Show current tier info
|
if (delegationEntries.length > 0) {
|
||||||
const currentTier = agent.getModelTier();
|
lines.push('');
|
||||||
send(makeEvent(request.id, 'done', {
|
lines.push('Delegation:');
|
||||||
content: `Current model tier: ${currentTier}`,
|
for (const [tier, stats] of delegationEntries) {
|
||||||
}));
|
lines.push(` ${tier}: ${stats.inputTokens.toLocaleString()} in / ${stats.outputTokens.toLocaleString()} out (${stats.calls} calls)`);
|
||||||
return;
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate tier
|
lines.push('');
|
||||||
const validTiers: ModelTier[] = ['fast', 'default', 'complex', 'local'];
|
lines.push(`**Total:** ${usage.total.inputTokens.toLocaleString()} in / ${usage.total.outputTokens.toLocaleString()} out (${usage.total.calls} calls)`);
|
||||||
const tier = modelArg as ModelTier;
|
|
||||||
if (!validTiers.includes(tier)) {
|
|
||||||
send(makeEvent(request.id, 'done', {
|
|
||||||
content: `Invalid tier: ${modelArg}. Available: ${validTiers.join(', ')}`,
|
|
||||||
}));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update agent tier
|
if (usage.total.estimatedCost > 0) {
|
||||||
agent.setModelTier(tier);
|
lines.push(`**Estimated cost:** $${usage.total.estimatedCost.toFixed(4)}`);
|
||||||
|
}
|
||||||
|
|
||||||
// Persist to session config
|
return lines.join('\n');
|
||||||
if (sessionId && deps.sessionManager) {
|
},
|
||||||
deps.sessionManager.setSessionConfig('ws', sessionId, 'modelTier', tier);
|
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', {
|
if (commandResult.handled) {
|
||||||
content: `Switched to model tier: ${tier}`,
|
send(makeEvent(request.id, 'done', { content: commandResult.text }));
|
||||||
}));
|
return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
deps.sessionBridge.setBusy(connectionId, false);
|
|
||||||
deps.metrics?.endRequest(requestId);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,12 +4,17 @@ import type { TokenUsageEntry } from './system.js';
|
|||||||
import { createSessionHandlers } from './sessions.js';
|
import { createSessionHandlers } from './sessions.js';
|
||||||
import { createToolHandlers } from './tools.js';
|
import { createToolHandlers } from './tools.js';
|
||||||
import { createAgentHandlers } from './agent.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 { createConfigHandlers, redactConfig } from './config.js';
|
||||||
import { createPairingHandlers } from './pairing.js';
|
import { createPairingHandlers } from './pairing.js';
|
||||||
import { PairingManager } from '../../channels/pairing.js';
|
import { PairingManager } from '../../channels/pairing.js';
|
||||||
import { LaneQueue } from '../lane-queue.js';
|
import { LaneQueue } from '../lane-queue.js';
|
||||||
import { ErrorCode } from '../protocol.js';
|
import { ErrorCode } from '../protocol.js';
|
||||||
import type { GatewayRequest, GatewayResponse, GatewayError, GatewayEvent, OutboundMessage } 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', () => {
|
describe('system handlers', () => {
|
||||||
const deps = {
|
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', () => {
|
describe('system.restart handler', () => {
|
||||||
it('returns restarting:true and calls restart callback', async () => {
|
it('returns restarting:true and calls restart callback', async () => {
|
||||||
const restartFn = vi.fn(async () => {});
|
const restartFn = vi.fn(async () => {});
|
||||||
|
|||||||
@@ -10,3 +10,9 @@ export { createConfigHandlers } from './config.js';
|
|||||||
export type { ConfigHandlerDeps } from './config.js';
|
export type { ConfigHandlerDeps } from './config.js';
|
||||||
export { createPairingHandlers } from './pairing.js';
|
export { createPairingHandlers } from './pairing.js';
|
||||||
export type { PairingHandlerDeps } 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,
|
createAgentHandlers,
|
||||||
createConfigHandlers,
|
createConfigHandlers,
|
||||||
createPairingHandlers,
|
createPairingHandlers,
|
||||||
|
createIntentHandlers,
|
||||||
|
createRoutingHandlers,
|
||||||
|
createHistoryHandlers,
|
||||||
} from './handlers/index.js';
|
} from './handlers/index.js';
|
||||||
import type { TokenUsageEntry } from './handlers/system.js';
|
import type { TokenUsageEntry } from './handlers/system.js';
|
||||||
import type { SessionManager } from '../session/manager.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 { GmailWatcher } from '../automation/gmail.js';
|
||||||
import type { PairingManager } from '../channels/pairing.js';
|
import type { PairingManager } from '../channels/pairing.js';
|
||||||
import type { MemoryStore } from '../memory/store.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 {
|
export interface GatewayServerConfig {
|
||||||
port: number;
|
port: number;
|
||||||
@@ -62,6 +68,9 @@ export interface GatewayServerConfig {
|
|||||||
/** Optional pairing manager for DM pairing code management via gateway. */
|
/** Optional pairing manager for DM pairing code management via gateway. */
|
||||||
pairingManager?: PairingManager;
|
pairingManager?: PairingManager;
|
||||||
memoryStore?: MemoryStore;
|
memoryStore?: MemoryStore;
|
||||||
|
commandRegistry?: CommandRegistry;
|
||||||
|
intentRegistry?: ComponentRegistry;
|
||||||
|
routingPolicy?: RoutingPolicy;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class GatewayServer {
|
export class GatewayServer {
|
||||||
@@ -122,6 +131,10 @@ export class GatewayServer {
|
|||||||
sessionBridge: this.sessionBridge,
|
sessionBridge: this.sessionBridge,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const historyHandlers = createHistoryHandlers({
|
||||||
|
sessionManager: this.config.sessionManager,
|
||||||
|
});
|
||||||
|
|
||||||
const toolHandlers = createToolHandlers({
|
const toolHandlers = createToolHandlers({
|
||||||
toolRegistry: this.config.toolRegistry,
|
toolRegistry: this.config.toolRegistry,
|
||||||
toolExecutor: this.config.toolExecutor,
|
toolExecutor: this.config.toolExecutor,
|
||||||
@@ -132,6 +145,17 @@ export class GatewayServer {
|
|||||||
laneQueue: this.laneQueue,
|
laneQueue: this.laneQueue,
|
||||||
metrics: this.metrics,
|
metrics: this.metrics,
|
||||||
sessionManager: this.config.sessionManager,
|
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)
|
// Config handlers (only if config object is provided)
|
||||||
@@ -157,12 +181,21 @@ export class GatewayServer {
|
|||||||
for (const [method, handler] of Object.entries(sessionHandlers)) {
|
for (const [method, handler] of Object.entries(sessionHandlers)) {
|
||||||
this.router.register(method, handler);
|
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)) {
|
for (const [method, handler] of Object.entries(toolHandlers)) {
|
||||||
this.router.register(method, handler);
|
this.router.register(method, handler);
|
||||||
}
|
}
|
||||||
for (const [method, handler] of Object.entries(agentHandlers)) {
|
for (const [method, handler] of Object.entries(agentHandlers)) {
|
||||||
this.router.register(method, handler);
|
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> {
|
async start(): Promise<void> {
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ const mockSession = {
|
|||||||
getHistory: vi.fn(() => []),
|
getHistory: vi.fn(() => []),
|
||||||
clear: vi.fn(),
|
clear: vi.fn(),
|
||||||
replaceHistory: vi.fn(),
|
replaceHistory: vi.fn(),
|
||||||
|
getConfig: vi.fn((_key: string) => undefined as string | undefined),
|
||||||
|
setConfig: vi.fn(),
|
||||||
|
deleteConfig: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockSessionManager = {
|
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', () => {
|
describe('SessionBridge', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
mockSession.getConfig.mockImplementation((_key: string) => undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('connect assigns a connection ID', () => {
|
it('connect assigns a connection ID', () => {
|
||||||
@@ -142,4 +157,78 @@ describe('SessionBridge', () => {
|
|||||||
expect(bridge.getAgent('conn-2')).toBeDefined();
|
expect(bridge.getAgent('conn-2')).toBeDefined();
|
||||||
expect(bridge.connectionCount).toBe(1);
|
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 type { VectorSearchResult, EmbeddingRow } from './vector-store.js';
|
||||||
export { HybridSearch } from './hybrid-search.js';
|
export { HybridSearch } from './hybrid-search.js';
|
||||||
export type { HybridSearchResult } 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', () => {
|
describe('search', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
store.write('notes', 'The quick brown fox jumps over the lazy dog\nAnother line of text\nFox sightings are common here', 'replace');
|
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');
|
const results = store.search('xyznonexistent');
|
||||||
expect(results).toEqual([]);
|
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', () => {
|
describe('listNamespaces', () => {
|
||||||
@@ -166,6 +223,8 @@ describe('MemoryStore', () => {
|
|||||||
it('includes user and global memory under headings', () => {
|
it('includes user and global memory under headings', () => {
|
||||||
store.write('user', 'User prefers concise answers', 'replace');
|
store.write('user', 'User prefers concise answers', 'replace');
|
||||||
store.write('global', 'System-wide knowledge base', '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();
|
const context = store.getContextForPrompt();
|
||||||
|
|
||||||
@@ -174,6 +233,10 @@ describe('MemoryStore', () => {
|
|||||||
expect(context).toContain('User prefers concise answers');
|
expect(context).toContain('User prefers concise answers');
|
||||||
expect(context).toContain('Global Memory');
|
expect(context).toContain('Global Memory');
|
||||||
expect(context).toContain('System-wide knowledge base');
|
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', () => {
|
it('truncates content to stay within maxContextTokens', () => {
|
||||||
|
|||||||
+116
-14
@@ -1,5 +1,6 @@
|
|||||||
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync } from 'fs';
|
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync } from 'fs';
|
||||||
import { join, relative, dirname } from 'path';
|
import { join, relative, dirname } from 'path';
|
||||||
|
import { MEMORY_CATEGORIES, categoryNamespace, isMemoryCategory, type MemoryCategory } from './categories.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configuration for the MemoryStore.
|
* Configuration for the MemoryStore.
|
||||||
@@ -11,6 +12,11 @@ export interface MemoryStoreConfig {
|
|||||||
maxContextTokens: number;
|
maxContextTokens: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PromptMemorySection {
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A single search result from scanning memory files.
|
* A single search result from scanning memory files.
|
||||||
*/
|
*/
|
||||||
@@ -25,6 +31,11 @@ export interface SearchResult {
|
|||||||
context: string;
|
context: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SearchOptions {
|
||||||
|
categories?: MemoryCategory[];
|
||||||
|
baseNamespacePrefix?: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages persistent markdown memory files on disk.
|
* Manages persistent markdown memory files on disk.
|
||||||
*
|
*
|
||||||
@@ -94,14 +105,72 @@ export class MemoryStore {
|
|||||||
this._dirtyNamespaces.add(namespace);
|
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.
|
* Search across all memory files for a keyword or phrase.
|
||||||
* Performs case-insensitive line-by-line matching.
|
* Performs case-insensitive line-by-line matching.
|
||||||
* Returns matching lines with 1 line of context above and below.
|
* 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 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();
|
const lowerQuery = query.toLowerCase();
|
||||||
|
|
||||||
for (const namespace of namespaces) {
|
for (const namespace of namespaces) {
|
||||||
@@ -178,23 +247,13 @@ export class MemoryStore {
|
|||||||
* (estimated at 4 characters per token).
|
* (estimated at 4 characters per token).
|
||||||
*/
|
*/
|
||||||
getContextForPrompt(): string {
|
getContextForPrompt(): string {
|
||||||
const userMemory = this.read('user');
|
const sections = this.getPromptSections().map((section) => `## ${section.title}\n\n${section.content}`);
|
||||||
const globalMemory = this.read('global');
|
|
||||||
|
|
||||||
// Nothing to inject
|
// Nothing to inject
|
||||||
if (userMemory.length === 0 && globalMemory.length === 0) {
|
if (sections.length === 0) {
|
||||||
return '';
|
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');
|
const full = sections.join('\n\n');
|
||||||
|
|
||||||
// Truncate to fit within the token budget (estimate: 4 chars ≈ 1 token)
|
// Truncate to fit within the token budget (estimate: 4 chars ≈ 1 token)
|
||||||
@@ -205,6 +264,45 @@ export class MemoryStore {
|
|||||||
return full.slice(0, maxChars);
|
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
|
// Private helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -236,4 +334,8 @@ export class MemoryStore {
|
|||||||
|
|
||||||
return namespaces;
|
return namespaces;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _categoryLabel(category: MemoryCategory): string {
|
||||||
|
return `${category.charAt(0).toUpperCase()}${category.slice(1)}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ const AUDIO_INCAPABLE_MODELS = new Set<string>([
|
|||||||
* false if audio must be transcribed to text before sending.
|
* false if audio must be transcribed to text before sending.
|
||||||
*/
|
*/
|
||||||
export function supportsAudioInput(provider: string, model: string, override?: boolean): boolean {
|
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
|
// Provider must be in the capable set
|
||||||
if (!AUDIO_CAPABLE_PROVIDERS.has(provider)) {
|
if (!AUDIO_CAPABLE_PROVIDERS.has(provider)) {
|
||||||
|
|||||||
@@ -1,2 +1,6 @@
|
|||||||
export { SessionStore, parseDuration } from './store.js';
|
export { SessionStore, parseDuration } from './store.js';
|
||||||
export { SessionManager, ManagedSession, type Session } from './manager.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('telegram:user-123');
|
||||||
expect(sessions).toContain('tui:local');
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+47
-3
@@ -1,6 +1,8 @@
|
|||||||
import type { Message } from '../models/types.js';
|
import type { Message } from '../models/types.js';
|
||||||
import type { SessionStore } from './store.js';
|
import type { SessionStore } from './store.js';
|
||||||
import { auditLogger } from '../audit/index.js';
|
import { auditLogger } from '../audit/index.js';
|
||||||
|
import { SessionIndexer } from './indexer.js';
|
||||||
|
import { SessionSearch, type HistorySearchResult } from './search.js';
|
||||||
|
|
||||||
export interface Session {
|
export interface Session {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -18,6 +20,7 @@ export class ManagedSession implements Session {
|
|||||||
public readonly id: string,
|
public readonly id: string,
|
||||||
private store: SessionStore,
|
private store: SessionStore,
|
||||||
private history: Message[] = [],
|
private history: Message[] = [],
|
||||||
|
private indexer?: SessionIndexer,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
addMessage(message: Message): Message {
|
addMessage(message: Message): Message {
|
||||||
@@ -26,7 +29,11 @@ export class ManagedSession implements Session {
|
|||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
};
|
};
|
||||||
this.history.push(messageWithTimestamp);
|
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({
|
auditLogger?.sessionMessage({
|
||||||
session_id: this.id,
|
session_id: this.id,
|
||||||
@@ -83,8 +90,25 @@ export class ManagedSession implements Session {
|
|||||||
|
|
||||||
export class SessionManager {
|
export class SessionManager {
|
||||||
private sessions: Map<string, ManagedSession> = new Map();
|
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 {
|
private makeSessionId(frontend: string, userId: string): string {
|
||||||
return `${frontend}:${userId}`;
|
return `${frontend}:${userId}`;
|
||||||
@@ -96,7 +120,7 @@ export class SessionManager {
|
|||||||
let session = this.sessions.get(id);
|
let session = this.sessions.get(id);
|
||||||
if (!session) {
|
if (!session) {
|
||||||
const history = this.store.getMessages(id);
|
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);
|
this.sessions.set(id, session);
|
||||||
|
|
||||||
auditLogger?.sessionCreate({
|
auditLogger?.sessionCreate({
|
||||||
@@ -162,4 +186,24 @@ export class SessionManager {
|
|||||||
const session = this.getSession(frontend, userId);
|
const session = this.getSession(frontend, userId);
|
||||||
session.deleteConfig(key);
|
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 { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
import { SessionStore } from './store.js';
|
import { SessionStore } from './store.js';
|
||||||
|
import { SessionIndexer } from './indexer.js';
|
||||||
import { unlinkSync, existsSync } from 'fs';
|
import { unlinkSync, existsSync } from 'fs';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { tmpdir } from 'os';
|
import { tmpdir } from 'os';
|
||||||
@@ -63,6 +64,16 @@ describe('SessionStore', () => {
|
|||||||
expect(sessions).toContain('session-b');
|
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', () => {
|
describe('pairing persistence', () => {
|
||||||
it('getPairingStore returns a PairingStore', () => {
|
it('getPairingStore returns a PairingStore', () => {
|
||||||
const pairingStore = store.getPairingStore();
|
const pairingStore = store.getPairingStore();
|
||||||
|
|||||||
+75
-5
@@ -1,6 +1,7 @@
|
|||||||
import Database from 'better-sqlite3';
|
import Database from 'better-sqlite3';
|
||||||
import type { Message } from '../models/types.js';
|
import type { Message } from '../models/types.js';
|
||||||
import type { PairingStore, ApprovedSender } from '../channels/pairing.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'. */
|
/** Parse a duration string like '30d', '7d', '12h' to milliseconds. Returns null if invalid or '0'. */
|
||||||
export function parseDuration(s: string): number | null {
|
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);
|
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(
|
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[] {
|
getMessages(sessionId: string): Message[] {
|
||||||
@@ -75,10 +81,10 @@ export class SessionStore {
|
|||||||
this.db.prepare('DELETE FROM messages WHERE session_id = ?').run(sessionId);
|
this.db.prepare('DELETE FROM messages WHERE session_id = ?').run(sessionId);
|
||||||
// Re-insert in order
|
// Re-insert in order
|
||||||
const insert = this.db.prepare(
|
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) {
|
for (const msg of messages) {
|
||||||
insert.run(sessionId, msg.role, msg.content);
|
insert.run(sessionId, msg.role, msg.content, null);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
transaction();
|
transaction();
|
||||||
@@ -194,4 +200,68 @@ export class SessionStore {
|
|||||||
close(): void {
|
close(): void {
|
||||||
this.db.close();
|
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 {
|
return {
|
||||||
name: 'memory.read',
|
name: 'memory.read',
|
||||||
description:
|
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: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ export function createMemorySearchTool(store: MemoryStore, hybridSearch?: Hybrid
|
|||||||
name: 'memory.search',
|
name: 'memory.search',
|
||||||
description:
|
description:
|
||||||
'Search across all memory files for a keyword or phrase. Returns matching lines with surrounding context from every namespace.' +
|
'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: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export function createMemoryWriteTool(store: MemoryStore): Tool {
|
|||||||
return {
|
return {
|
||||||
name: 'memory.write',
|
name: 'memory.write',
|
||||||
description:
|
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: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
|
|||||||
Reference in New Issue
Block a user