feat: implement tier 1 quick wins (tool groups, typing, pruning, verbose, think)
Five additive features with no breaking changes: - Tool groups: group:fs, group:runtime, group:web, group:memory syntactic sugar for allow/deny lists in tool policy config - Typing indicators: Discord sendTyping() and WhatsApp sendStateTyping() on message receipt for better UX feedback - Session pruning: TTL-based auto-cleanup via sessions.ttl config with hourly daemon timer and SQLite GROUP BY pruning - /verbose command: TUI command parser toggle for raw streaming display - !!think prefix: per-message extended thinking mode wired through Anthropic (budget_tokens), OpenAI/GitHub (reasoning_effort), and Gemini (thinkingConfig) providers Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,284 @@
|
|||||||
|
# Tier 1 Quick Wins — Design
|
||||||
|
|
||||||
|
**Date:** 2026-02-07
|
||||||
|
**Status:** Draft
|
||||||
|
**Scope:** 5 additive features, no breaking changes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Per-message thinking mode (`!!think` prefix)
|
||||||
|
|
||||||
|
### Trigger
|
||||||
|
|
||||||
|
User prefixes a message with `!!think`. The prefix is stripped before the message reaches the model.
|
||||||
|
|
||||||
|
### Data flow
|
||||||
|
|
||||||
|
1. Frontend/channel adapter detects `!!think` prefix, strips it, sets `thinking: true` on the message metadata
|
||||||
|
2. Agent loop passes `thinking` flag through to `ChatRequest`
|
||||||
|
3. Each provider client checks the flag:
|
||||||
|
- **Anthropic:** sets `thinking.budget_tokens` (default 4096)
|
||||||
|
- **OpenAI/GitHub Models:** sets `reasoning_effort` (default `'medium'`)
|
||||||
|
- **Gemini:** sets `thinkingConfig.thinkBudgetTokens` (default 4096)
|
||||||
|
- **Bedrock:** sets via Anthropic thinking params
|
||||||
|
- **Ollama/llama.cpp:** no-op (silently ignored)
|
||||||
|
4. Response thinking/reasoning content is included in the reply (displayed as a collapsible block in TUI/WebChat, omitted in channel adapters)
|
||||||
|
|
||||||
|
### Config additions
|
||||||
|
|
||||||
|
All optional — controls per-provider defaults when `!!think` is active:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
models:
|
||||||
|
thinking:
|
||||||
|
anthropic:
|
||||||
|
budgetTokens: 4096
|
||||||
|
openai:
|
||||||
|
reasoningEffort: medium # low | medium | high
|
||||||
|
gemini:
|
||||||
|
budgetTokens: 4096
|
||||||
|
```
|
||||||
|
|
||||||
|
### Types changes
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/models/types.ts — ChatRequest
|
||||||
|
export interface ChatRequest {
|
||||||
|
messages: Message[];
|
||||||
|
system?: string;
|
||||||
|
maxTokens?: number;
|
||||||
|
tools?: ToolDefinition[];
|
||||||
|
thinking?: boolean; // NEW
|
||||||
|
}
|
||||||
|
|
||||||
|
// src/models/types.ts — ChatResponse
|
||||||
|
export interface ChatResponse {
|
||||||
|
content: string;
|
||||||
|
toolCalls?: ToolCall[];
|
||||||
|
stopReason?: string;
|
||||||
|
usage?: TokenUsage;
|
||||||
|
thinkingContent?: string; // NEW — raw thinking/reasoning output
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Provider implementation
|
||||||
|
|
||||||
|
Each client checks `request.thinking` and maps to native API:
|
||||||
|
|
||||||
|
- **`anthropic.ts`**: Add `thinking: { type: 'enabled', budget_tokens }` to `messages.create()` params. Parse `thinking` content blocks from response.
|
||||||
|
- **`openai.ts`**: Add `reasoning_effort` to `chat.completions.create()`. Parse `reasoning` from response.
|
||||||
|
- **`github.ts`**: Same as OpenAI (uses OpenAI SDK).
|
||||||
|
- **`gemini.ts`**: Add `thinkingConfig` to `generationConfig`. Parse thinking parts from response.
|
||||||
|
- **`bedrock.ts`**: Add thinking params via Anthropic Converse API format.
|
||||||
|
- **`ollama.ts` / `llamacpp.ts`**: Ignore the flag.
|
||||||
|
|
||||||
|
### Files affected
|
||||||
|
|
||||||
|
- `src/models/types.ts` — Add `thinking` to ChatRequest, `thinkingContent` to ChatResponse
|
||||||
|
- `src/models/anthropic.ts` — Wire `budget_tokens`, parse thinking blocks
|
||||||
|
- `src/models/openai.ts` — Wire `reasoning_effort`, parse reasoning
|
||||||
|
- `src/models/github.ts` — Pass through to OpenAI client
|
||||||
|
- `src/models/gemini.ts` — Wire `thinkingConfig`
|
||||||
|
- `src/models/bedrock.ts` — Wire thinking params
|
||||||
|
- `src/config/schema.ts` — Add `models.thinking` config section
|
||||||
|
- `src/backends/native/agent.ts` — Pass `thinking` flag from message metadata to ChatRequest
|
||||||
|
- `src/frontends/tui/commands.ts` — Detect and strip `!!think` prefix
|
||||||
|
- Channel adapters — Detect and strip `!!think` prefix
|
||||||
|
- TUI/WebChat — Display `thinkingContent` as collapsible block
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Verbose streaming mode (`/verbose`)
|
||||||
|
|
||||||
|
### Trigger
|
||||||
|
|
||||||
|
`/verbose` command toggles a boolean in the frontend's local state. Not persisted to session or config.
|
||||||
|
|
||||||
|
### Effect when on
|
||||||
|
|
||||||
|
- Raw streaming chunks displayed as they arrive, including tool call JSON being generated
|
||||||
|
- Tool arguments and raw results shown in full (no summarization)
|
||||||
|
|
||||||
|
### Scope
|
||||||
|
|
||||||
|
TUI and WebChat only. Channel adapters (Telegram, Discord, Slack, WhatsApp) do not support this.
|
||||||
|
|
||||||
|
### Implementation
|
||||||
|
|
||||||
|
- Add `verbose: boolean` to TUI and WebChat frontend state (default `false`)
|
||||||
|
- Add `/verbose` to command parser — toggles the flag, prints current status
|
||||||
|
- Streaming renderer checks the flag:
|
||||||
|
- **On:** emit raw chunks as-is, display full tool call JSON and results
|
||||||
|
- **Off:** current behavior (summarized tool output, clean text display)
|
||||||
|
- No backend changes — purely a display concern
|
||||||
|
|
||||||
|
### Files affected
|
||||||
|
|
||||||
|
- `src/frontends/tui/commands.ts` — Add `verbose` command type and parsing
|
||||||
|
- `src/frontends/tui/minimal.ts` — Handle `/verbose`, toggle state, modify streaming display
|
||||||
|
- `src/gateway/ui/pages/chat.js` — WebChat verbose toggle and raw display mode
|
||||||
|
- WebSocket message handler — Pass raw chunks when verbose is active
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Typing indicators
|
||||||
|
|
||||||
|
### When
|
||||||
|
|
||||||
|
Immediately on receiving a user message. Sustained until the response is fully sent.
|
||||||
|
|
||||||
|
### Per-adapter implementation
|
||||||
|
|
||||||
|
| Adapter | API | Notes |
|
||||||
|
|---------|-----|-------|
|
||||||
|
| **Discord** | `channel.sendTyping()` | Auto-expires after 10s. Re-fire on a 9s interval while processing. |
|
||||||
|
| **Slack** | Bolt typing indicator API | Fire on receipt, cancel on response. |
|
||||||
|
| **WhatsApp** | `sock.sendPresenceUpdate('composing', jid)` | Fire on receipt, send `'paused'` on response. |
|
||||||
|
| **Telegram** | grammY `sendChatAction('typing')` | Already implemented. No changes needed. |
|
||||||
|
|
||||||
|
### Implementation pattern
|
||||||
|
|
||||||
|
Each adapter's message handler calls `sendTyping()` before dispatching to the agent loop. A cleanup/cancel mechanism (interval clear or presence update) stops the indicator once the response is sent.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Pseudocode for Discord adapter
|
||||||
|
async handleMessage(msg) {
|
||||||
|
const typingInterval = setInterval(() => msg.channel.sendTyping(), 9000);
|
||||||
|
msg.channel.sendTyping(); // immediate first call
|
||||||
|
try {
|
||||||
|
await this.dispatch(msg);
|
||||||
|
} finally {
|
||||||
|
clearInterval(typingInterval);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Files affected
|
||||||
|
|
||||||
|
- `src/channels/discord/adapter.ts` — Add typing interval in message handler
|
||||||
|
- `src/channels/slack/adapter.ts` — Add typing indicator in message handler
|
||||||
|
- `src/channels/whatsapp/adapter.ts` — Add presence composing/paused in message handler
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Session pruning (TTL-based)
|
||||||
|
|
||||||
|
### Config addition
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
sessions:
|
||||||
|
ttl: 30d # duration string. Default: 30d. Set to 0 or false to disable.
|
||||||
|
```
|
||||||
|
|
||||||
|
Supported formats: `"30d"`, `"7d"`, `"12h"`, `"0"` (disabled).
|
||||||
|
|
||||||
|
### Mechanism
|
||||||
|
|
||||||
|
1. Daemon startup schedules a periodic timer (every 1 hour)
|
||||||
|
2. Timer calls `SessionStore.pruneStale(cutoffTimestamp)`
|
||||||
|
3. SQLite query finds all `session_id`s where `MAX(created_at) < cutoff`
|
||||||
|
4. Deletes all messages for stale sessions
|
||||||
|
5. Evicts pruned sessions from `SessionManager`'s in-memory cache
|
||||||
|
6. Logs: `"Pruned 3 stale sessions (TTL: 30d)"`
|
||||||
|
|
||||||
|
### Duration parsing
|
||||||
|
|
||||||
|
Simple regex parser for duration strings — no external library:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function parseDuration(s: string): number | null {
|
||||||
|
const match = s.match(/^(\d+)(h|d)$/);
|
||||||
|
if (!match) return null;
|
||||||
|
const [, n, unit] = match;
|
||||||
|
const ms = unit === 'h' ? Number(n) * 3600000 : Number(n) * 86400000;
|
||||||
|
return ms;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### New SessionStore method
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async pruneStale(beforeTimestamp: number): Promise<string[]> {
|
||||||
|
// Returns list of pruned session IDs
|
||||||
|
const stale = db.prepare(`
|
||||||
|
SELECT session_id FROM messages
|
||||||
|
GROUP BY session_id
|
||||||
|
HAVING MAX(created_at) < ?
|
||||||
|
`).all(beforeTimestamp);
|
||||||
|
|
||||||
|
for (const { session_id } of stale) {
|
||||||
|
db.prepare('DELETE FROM messages WHERE session_id = ?').run(session_id);
|
||||||
|
}
|
||||||
|
return stale.map(r => r.session_id);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Files affected
|
||||||
|
|
||||||
|
- `src/config/schema.ts` — Add `sessions.ttl` field
|
||||||
|
- `src/session/store.ts` — Add `pruneStale()` method
|
||||||
|
- `src/session/manager.ts` — Add `evictSessions(ids)` to clear in-memory cache
|
||||||
|
- `src/daemon/index.ts` — Schedule pruning timer on startup
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Tool groups
|
||||||
|
|
||||||
|
### Group definitions
|
||||||
|
|
||||||
|
Static map in `policy.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const TOOL_GROUPS: Record<string, string[]> = {
|
||||||
|
'group:fs': ['file.read', 'file.write', 'file.edit', 'file.list'],
|
||||||
|
'group:runtime': ['shell.exec', 'process.start', 'process.output', 'process.status', 'process.kill', 'process.list'],
|
||||||
|
'group:web': ['web.fetch', 'web.search', 'browser.navigate', 'browser.click', 'browser.type', 'browser.screenshot', 'browser.evaluate'],
|
||||||
|
'group:memory': ['memory.read', 'memory.write', 'memory.search'],
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Resolution
|
||||||
|
|
||||||
|
`ToolPolicy` expands `group:*` entries in allow/deny lists before applying filters. Expansion happens early in the resolution pipeline, before any set operations.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function expandGroups(names: string[]): string[] {
|
||||||
|
return names.flatMap(n => TOOL_GROUPS[n] ?? [n]);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Works in all scopes: global allow/deny, per-agent overrides, per-provider overrides.
|
||||||
|
|
||||||
|
### Config usage example
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
tools:
|
||||||
|
profile: minimal
|
||||||
|
allow: ['group:web']
|
||||||
|
agents:
|
||||||
|
fast:
|
||||||
|
allow: ['group:fs']
|
||||||
|
deny: ['shell.exec']
|
||||||
|
providers:
|
||||||
|
ollama:
|
||||||
|
deny: ['group:web']
|
||||||
|
```
|
||||||
|
|
||||||
|
### Files affected
|
||||||
|
|
||||||
|
- `src/tools/policy.ts` — Add `TOOL_GROUPS` map, `expandGroups()` helper, integrate into resolution pipeline
|
||||||
|
- `src/tools/policy.test.ts` — Tests for group expansion in all scopes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation order
|
||||||
|
|
||||||
|
Recommended order by independence and risk:
|
||||||
|
|
||||||
|
1. **Tool groups** — Isolated to `policy.ts`, no cross-cutting concerns
|
||||||
|
2. **Typing indicators** — Per-adapter, independent changes
|
||||||
|
3. **Session pruning** — Self-contained, touches store/manager/daemon
|
||||||
|
4. **`/verbose`** — Frontend-only, no backend changes
|
||||||
|
5. **`!!think`** — Largest scope, touches all providers + agent loop + frontends
|
||||||
|
|
||||||
|
Features 1–3 can be implemented in parallel. Feature 4 is independent. Feature 5 depends on understanding the streaming path touched by feature 4.
|
||||||
@@ -51,6 +51,7 @@ export class NativeAgent {
|
|||||||
private _callCount: number = 0;
|
private _callCount: number = 0;
|
||||||
private _toolPolicyContext?: ToolPolicyContext;
|
private _toolPolicyContext?: ToolPolicyContext;
|
||||||
private _attachmentCollector?: OutboundAttachmentCollector;
|
private _attachmentCollector?: OutboundAttachmentCollector;
|
||||||
|
private _thinking: boolean = false;
|
||||||
|
|
||||||
constructor(config: NativeAgentConfig) {
|
constructor(config: NativeAgentConfig) {
|
||||||
this.modelClient = config.modelClient;
|
this.modelClient = config.modelClient;
|
||||||
@@ -69,6 +70,14 @@ export class NativeAgent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async process(userMessage: string, attachments?: Attachment[]): Promise<string> {
|
async process(userMessage: string, attachments?: Attachment[]): Promise<string> {
|
||||||
|
// Detect and strip !!think prefix for per-message thinking mode
|
||||||
|
if (userMessage.startsWith('!!think ') || userMessage === '!!think') {
|
||||||
|
this._thinking = true;
|
||||||
|
userMessage = userMessage.replace(/^!!think\s*/, '').trim() || 'Think about this.';
|
||||||
|
} else {
|
||||||
|
this._thinking = false;
|
||||||
|
}
|
||||||
|
|
||||||
const userMsg = buildUserMessage(userMessage, attachments);
|
const userMsg = buildUserMessage(userMessage, attachments);
|
||||||
|
|
||||||
if (this.session) {
|
if (this.session) {
|
||||||
@@ -89,6 +98,7 @@ export class NativeAgent {
|
|||||||
const request: ChatRequest = {
|
const request: ChatRequest = {
|
||||||
messages: this.history,
|
messages: this.history,
|
||||||
system: this.systemPrompt,
|
system: this.systemPrompt,
|
||||||
|
...(this._thinking ? { thinking: true } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await this.chatWithRouter(request);
|
const response = await this.chatWithRouter(request);
|
||||||
@@ -101,10 +111,16 @@ export class NativeAgent {
|
|||||||
console.warn(`[Flynn] ${response.fallbackReason}`);
|
console.warn(`[Flynn] ${response.fallbackReason}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prepend thinking content if present
|
||||||
|
let finalContent = response.content;
|
||||||
|
if (response.thinkingContent) {
|
||||||
|
finalContent = `<thinking>\n${response.thinkingContent}\n</thinking>\n\n${response.content}`;
|
||||||
|
}
|
||||||
|
|
||||||
const assistantMsg: Message = { role: 'assistant', content: response.content };
|
const assistantMsg: Message = { role: 'assistant', content: response.content };
|
||||||
this.addToHistory(assistantMsg);
|
this.addToHistory(assistantMsg);
|
||||||
|
|
||||||
return response.content;
|
return finalContent;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async toolLoop(): Promise<string> {
|
private async toolLoop(): Promise<string> {
|
||||||
@@ -124,6 +140,7 @@ export class NativeAgent {
|
|||||||
messages: loopMessages as unknown as Message[],
|
messages: loopMessages as unknown as Message[],
|
||||||
system: this.systemPrompt,
|
system: this.systemPrompt,
|
||||||
tools,
|
tools,
|
||||||
|
...(this._thinking ? { thinking: true } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await this.chatWithRouter(request);
|
const response = await this.chatWithRouter(request);
|
||||||
@@ -138,9 +155,13 @@ export class NativeAgent {
|
|||||||
|
|
||||||
// If the model didn't request tool use, we're done
|
// If the model didn't request tool use, we're done
|
||||||
if (response.stopReason !== 'tool_use' || !response.toolCalls?.length) {
|
if (response.stopReason !== 'tool_use' || !response.toolCalls?.length) {
|
||||||
|
let finalContent = response.content;
|
||||||
|
if (response.thinkingContent) {
|
||||||
|
finalContent = `<thinking>\n${response.thinkingContent}\n</thinking>\n\n${response.content}`;
|
||||||
|
}
|
||||||
const assistantMsg: Message = { role: 'assistant', content: response.content };
|
const assistantMsg: Message = { role: 'assistant', content: response.content };
|
||||||
this.addToHistory(assistantMsg);
|
this.addToHistory(assistantMsg);
|
||||||
return response.content;
|
return finalContent;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build the assistant message with tool_use content blocks
|
// Build the assistant message with tool_use content blocks
|
||||||
|
|||||||
@@ -196,6 +196,13 @@ export class DiscordAdapter implements ChannelAdapter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Send typing indicator (lasts 10 seconds, no need for interval)
|
||||||
|
try {
|
||||||
|
if ('sendTyping' in message.channel) {
|
||||||
|
(message.channel as any).sendTyping();
|
||||||
|
}
|
||||||
|
} catch { /* ignore typing errors */ }
|
||||||
|
|
||||||
// Strip bot mention from the message text
|
// Strip bot mention from the message text
|
||||||
const text = message.content.replace(/<@!?\d+>/g, '').trim();
|
const text = message.content.replace(/<@!?\d+>/g, '').trim();
|
||||||
|
|
||||||
|
|||||||
@@ -287,6 +287,8 @@ export class SlackAdapter implements ChannelAdapter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Note: Slack doesn't expose a typing indicator API for bots
|
||||||
|
|
||||||
// Build peer ID: channelId:threadTs (thread-aware)
|
// Build peer ID: channelId:threadTs (thread-aware)
|
||||||
const threadTs = message.thread_ts ?? message.ts ?? '';
|
const threadTs = message.thread_ts ?? message.ts ?? '';
|
||||||
const peerId = `${channelId}:${threadTs}`;
|
const peerId = `${channelId}:${threadTs}`;
|
||||||
|
|||||||
@@ -236,6 +236,12 @@ export class WhatsAppAdapter implements ChannelAdapter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Send typing indicator
|
||||||
|
try {
|
||||||
|
const chat = await (message as any).getChat();
|
||||||
|
await chat.sendStateTyping();
|
||||||
|
} catch { /* ignore typing errors */ }
|
||||||
|
|
||||||
// Strip bot mention from message body for group messages
|
// Strip bot mention from message body for group messages
|
||||||
let text = message.body ?? '';
|
let text = message.body ?? '';
|
||||||
if (isGroup && this.botId) {
|
if (isGroup && this.botId) {
|
||||||
|
|||||||
@@ -33,6 +33,18 @@ const modelConfigSchema = modelConfigBaseSchema.extend({
|
|||||||
fallback: modelConfigBaseSchema.optional(),
|
fallback: modelConfigBaseSchema.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const thinkingSchema = z.object({
|
||||||
|
anthropic: z.object({
|
||||||
|
budgetTokens: z.number().default(4096),
|
||||||
|
}).default({}),
|
||||||
|
openai: z.object({
|
||||||
|
reasoningEffort: z.enum(['low', 'medium', 'high']).default('medium'),
|
||||||
|
}).default({}),
|
||||||
|
gemini: z.object({
|
||||||
|
budgetTokens: z.number().default(4096),
|
||||||
|
}).default({}),
|
||||||
|
}).default({});
|
||||||
|
|
||||||
const modelsSchema = z.object({
|
const modelsSchema = z.object({
|
||||||
local: modelConfigSchema.optional(),
|
local: modelConfigSchema.optional(),
|
||||||
fast: modelConfigSchema.optional(),
|
fast: modelConfigSchema.optional(),
|
||||||
@@ -40,6 +52,7 @@ const modelsSchema = z.object({
|
|||||||
complex: modelConfigSchema.optional(),
|
complex: modelConfigSchema.optional(),
|
||||||
fallback_chain: z.array(z.string()).default(['anthropic']),
|
fallback_chain: z.array(z.string()).default(['anthropic']),
|
||||||
local_providers: z.record(z.string(), modelConfigSchema).optional(),
|
local_providers: z.record(z.string(), modelConfigSchema).optional(),
|
||||||
|
thinking: thinkingSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
const backendsSchema = z.object({
|
const backendsSchema = z.object({
|
||||||
@@ -250,6 +263,10 @@ const promptSchema = z.object({
|
|||||||
})).default([]),
|
})).default([]),
|
||||||
}).default({});
|
}).default({});
|
||||||
|
|
||||||
|
const sessionsSchema = z.object({
|
||||||
|
ttl: z.string().default('30d'),
|
||||||
|
}).default({});
|
||||||
|
|
||||||
export const configSchema = z.object({
|
export const configSchema = z.object({
|
||||||
telegram: telegramSchema,
|
telegram: telegramSchema,
|
||||||
discord: discordSchema,
|
discord: discordSchema,
|
||||||
@@ -275,6 +292,7 @@ export const configSchema = z.object({
|
|||||||
sandbox: sandboxSchema,
|
sandbox: sandboxSchema,
|
||||||
agent_configs: agentConfigsSchema,
|
agent_configs: agentConfigsSchema,
|
||||||
routing: routingSchema,
|
routing: routingSchema,
|
||||||
|
sessions: sessionsSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
export type Config = z.infer<typeof configSchema>;
|
export type Config = z.infer<typeof configSchema>;
|
||||||
@@ -300,3 +318,5 @@ export type SandboxConfig = z.infer<typeof sandboxSchema>;
|
|||||||
export type AgentConfigEntry = z.infer<typeof agentConfigEntrySchema>;
|
export type AgentConfigEntry = z.infer<typeof agentConfigEntrySchema>;
|
||||||
export type RoutingConfig = z.infer<typeof routingSchema>;
|
export type RoutingConfig = z.infer<typeof routingSchema>;
|
||||||
export type ServerConfig = z.infer<typeof serverSchema>;
|
export type ServerConfig = z.infer<typeof serverSchema>;
|
||||||
|
export type SessionsConfig = z.infer<typeof sessionsSchema>;
|
||||||
|
export type ThinkingConfig = z.infer<typeof thinkingSchema>;
|
||||||
|
|||||||
+18
-1
@@ -7,7 +7,7 @@ import { AnthropicClient, OpenAIClient, OllamaClient, LlamaCppClient, GeminiClie
|
|||||||
import type { ModelClient, RetryConfig, ModelTier } from '../models/index.js';
|
import type { ModelClient, RetryConfig, ModelTier } from '../models/index.js';
|
||||||
import { AgentOrchestrator, type DelegationConfig } from '../backends/index.js';
|
import { AgentOrchestrator, type DelegationConfig } from '../backends/index.js';
|
||||||
import { OutboundAttachmentCollector } from '../backends/native/attachments.js';
|
import { OutboundAttachmentCollector } from '../backends/native/attachments.js';
|
||||||
import { SessionStore, SessionManager } from '../session/index.js';
|
import { SessionStore, SessionManager, parseDuration } from '../session/index.js';
|
||||||
import { HookEngine } from '../hooks/index.js';
|
import { HookEngine } from '../hooks/index.js';
|
||||||
import { ToolRegistry, ToolExecutor, ToolPolicy, allBuiltinTools, createWebSearchTools, createProcessTools, ProcessManager, BrowserManager, createBrowserTools, createMediaSendTool, createSessionTools, createAgentsListTool, createMessageSendTool, createCronTools } from '../tools/index.js';
|
import { ToolRegistry, ToolExecutor, ToolPolicy, allBuiltinTools, createWebSearchTools, createProcessTools, ProcessManager, BrowserManager, createBrowserTools, createMediaSendTool, createSessionTools, createAgentsListTool, createMessageSendTool, createCronTools } from '../tools/index.js';
|
||||||
import type { Tool } from '../tools/types.js';
|
import type { Tool } from '../tools/types.js';
|
||||||
@@ -453,6 +453,23 @@ export async function startDaemon(config: Config): Promise<DaemonContext> {
|
|||||||
console.log('Session store closed');
|
console.log('Session store closed');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Session pruning timer (TTL-based cleanup)
|
||||||
|
const ttlMs = parseDuration(config.sessions?.ttl ?? '30d');
|
||||||
|
if (ttlMs) {
|
||||||
|
const pruneInterval = setInterval(() => {
|
||||||
|
const cutoff = Math.floor((Date.now() - ttlMs) / 1000); // created_at is unix seconds
|
||||||
|
const pruned = sessionStore.pruneStale(cutoff);
|
||||||
|
if (pruned.length > 0) {
|
||||||
|
sessionManager.evictSessions(pruned);
|
||||||
|
console.log(`Pruned ${pruned.length} stale session(s) (TTL: ${config.sessions?.ttl ?? '30d'})`);
|
||||||
|
}
|
||||||
|
}, 3_600_000); // every hour
|
||||||
|
|
||||||
|
lifecycle.onShutdown(async () => {
|
||||||
|
clearInterval(pruneInterval);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize hook engine
|
// Initialize hook engine
|
||||||
const hookEngine = new HookEngine(config.hooks);
|
const hookEngine = new HookEngine(config.hooks);
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ describe('parseCommand', () => {
|
|||||||
expect(parseCommand('/usage')).toEqual({ type: 'usage' });
|
expect(parseCommand('/usage')).toEqual({ type: 'usage' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('parses /verbose command', () => {
|
||||||
|
expect(parseCommand('/verbose')).toEqual({ type: 'verbose' });
|
||||||
|
});
|
||||||
|
|
||||||
it('parses /model command without argument', () => {
|
it('parses /model command without argument', () => {
|
||||||
expect(parseCommand('/model')).toEqual({ type: 'model' });
|
expect(parseCommand('/model')).toEqual({ type: 'model' });
|
||||||
});
|
});
|
||||||
@@ -100,6 +104,7 @@ describe('getHelpText', () => {
|
|||||||
expect(help).toContain('/reset');
|
expect(help).toContain('/reset');
|
||||||
expect(help).toContain('/compact');
|
expect(help).toContain('/compact');
|
||||||
expect(help).toContain('/usage');
|
expect(help).toContain('/usage');
|
||||||
|
expect(help).toContain('/verbose');
|
||||||
expect(help).toContain('/quit');
|
expect(help).toContain('/quit');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export type Command =
|
|||||||
| { type: 'fullscreen' }
|
| { type: 'fullscreen' }
|
||||||
| { type: 'compact' }
|
| { type: 'compact' }
|
||||||
| { type: 'usage' }
|
| { type: 'usage' }
|
||||||
|
| { type: 'verbose' }
|
||||||
| { type: 'model'; name?: string; providerModel?: string }
|
| { type: 'model'; name?: string; providerModel?: string }
|
||||||
| { type: 'backend'; provider?: string }
|
| { type: 'backend'; provider?: string }
|
||||||
| { type: 'login'; provider?: string }
|
| { type: 'login'; provider?: string }
|
||||||
@@ -51,6 +52,11 @@ export function parseCommand(input: string): Command | null {
|
|||||||
return { type: 'usage' };
|
return { type: 'usage' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Verbose
|
||||||
|
if (trimmed === '/verbose') {
|
||||||
|
return { type: 'verbose' };
|
||||||
|
}
|
||||||
|
|
||||||
// Model (with optional argument)
|
// Model (with optional argument)
|
||||||
if (trimmed === '/model') {
|
if (trimmed === '/model') {
|
||||||
return { type: 'model' };
|
return { type: 'model' };
|
||||||
@@ -108,6 +114,7 @@ Commands:
|
|||||||
/reset, /clear, /new Clear conversation history
|
/reset, /clear, /new Clear conversation history
|
||||||
/compact Compact conversation history
|
/compact Compact conversation history
|
||||||
/usage Show token usage and estimated cost
|
/usage Show token usage and estimated cost
|
||||||
|
/verbose Toggle verbose mode (show raw streaming and tool output)
|
||||||
/status Show session info and token usage
|
/status Show session info and token usage
|
||||||
/fullscreen, /fs Switch to fullscreen mode
|
/fullscreen, /fs Switch to fullscreen mode
|
||||||
/transfer <dest> Transfer session to another frontend
|
/transfer <dest> Transfer session to another frontend
|
||||||
@@ -127,6 +134,7 @@ export const SLASH_COMMANDS = [
|
|||||||
'/new',
|
'/new',
|
||||||
'/compact',
|
'/compact',
|
||||||
'/usage',
|
'/usage',
|
||||||
|
'/verbose',
|
||||||
'/status',
|
'/status',
|
||||||
'/fullscreen',
|
'/fullscreen',
|
||||||
'/fs',
|
'/fs',
|
||||||
@@ -146,6 +154,7 @@ export const COMMAND_TOOLTIPS: Record<string, string> = {
|
|||||||
'/new': 'Start a new conversation',
|
'/new': 'Start a new conversation',
|
||||||
'/compact': 'Compact conversation history to save context space',
|
'/compact': 'Compact conversation history to save context space',
|
||||||
'/usage': 'Show token usage and estimated cost',
|
'/usage': 'Show token usage and estimated cost',
|
||||||
|
'/verbose': 'Toggle verbose mode (show raw streaming and tool output)',
|
||||||
'/status': 'Show session info and token usage',
|
'/status': 'Show session info and token usage',
|
||||||
'/fullscreen': 'Switch to fullscreen mode',
|
'/fullscreen': 'Switch to fullscreen mode',
|
||||||
'/fs': 'Switch to fullscreen mode',
|
'/fs': 'Switch to fullscreen mode',
|
||||||
|
|||||||
@@ -74,11 +74,21 @@ export class AnthropicClient implements ModelClient {
|
|||||||
params.tools = request.tools;
|
params.tools = request.tools;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extended thinking mode — enable thinking with a budget
|
||||||
|
if (request.thinking) {
|
||||||
|
params.max_tokens = Math.max(params.max_tokens as number, 16384);
|
||||||
|
(params as any).thinking = { type: 'enabled', budget_tokens: 4096 };
|
||||||
|
}
|
||||||
|
|
||||||
const response = await this.client.messages.create(params as unknown as Parameters<typeof this.client.messages.create>[0]) as AnthropicMessage;
|
const response = await this.client.messages.create(params as unknown as Parameters<typeof this.client.messages.create>[0]) as AnthropicMessage;
|
||||||
|
|
||||||
const textContent = response.content.find((c) => c.type === 'text');
|
const textContent = response.content.find((c) => c.type === 'text');
|
||||||
const content = textContent?.type === 'text' ? textContent.text : '';
|
const content = textContent?.type === 'text' ? textContent.text : '';
|
||||||
|
|
||||||
|
// Extract thinking content if present
|
||||||
|
const thinkingBlock = response.content.find((c) => c.type === 'thinking');
|
||||||
|
const thinkingContent = thinkingBlock && 'thinking' in thinkingBlock ? (thinkingBlock as any).text : undefined;
|
||||||
|
|
||||||
const toolCalls = response.content
|
const toolCalls = response.content
|
||||||
.filter((c): c is { type: 'tool_use'; id: string; name: string; input: unknown } => c.type === 'tool_use')
|
.filter((c): c is { type: 'tool_use'; id: string; name: string; input: unknown } => c.type === 'tool_use')
|
||||||
.map(c => ({ id: c.id, name: c.name, args: c.input }));
|
.map(c => ({ id: c.id, name: c.name, args: c.input }));
|
||||||
@@ -91,6 +101,7 @@ export class AnthropicClient implements ModelClient {
|
|||||||
outputTokens: response.usage.output_tokens,
|
outputTokens: response.usage.output_tokens,
|
||||||
},
|
},
|
||||||
...(toolCalls.length > 0 ? { toolCalls } : {}),
|
...(toolCalls.length > 0 ? { toolCalls } : {}),
|
||||||
|
...(thinkingContent ? { thinkingContent } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+10
-3
@@ -25,13 +25,20 @@ export class GeminiClient implements ModelClient {
|
|||||||
? [{ functionDeclarations: request.tools.map(t => convertToolDefinition(t)) }]
|
? [{ functionDeclarations: request.tools.map(t => convertToolDefinition(t)) }]
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
const generationConfig: Record<string, unknown> = {
|
||||||
|
maxOutputTokens: request.maxTokens ?? this.defaultMaxTokens,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Extended thinking mode
|
||||||
|
if (request.thinking) {
|
||||||
|
generationConfig.thinkingConfig = { thinkingBudget: 4096 };
|
||||||
|
}
|
||||||
|
|
||||||
return this.genAI.getGenerativeModel({
|
return this.genAI.getGenerativeModel({
|
||||||
model: this.model,
|
model: this.model,
|
||||||
systemInstruction: request.system || undefined,
|
systemInstruction: request.system || undefined,
|
||||||
tools,
|
tools,
|
||||||
generationConfig: {
|
generationConfig,
|
||||||
maxOutputTokens: request.maxTokens ?? this.defaultMaxTokens,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -137,6 +137,11 @@ export class GitHubModelsClient implements ModelClient {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extended thinking/reasoning mode
|
||||||
|
if (request.thinking) {
|
||||||
|
(params as any).reasoning_effort = 'medium';
|
||||||
|
}
|
||||||
|
|
||||||
const response = await this.client.chat.completions.create(params);
|
const response = await this.client.chat.completions.create(params);
|
||||||
|
|
||||||
const choice = response.choices[0];
|
const choice = response.choices[0];
|
||||||
|
|||||||
@@ -79,6 +79,11 @@ export class OpenAIClient implements ModelClient {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extended thinking/reasoning mode for o1/o3 models
|
||||||
|
if (request.thinking) {
|
||||||
|
(params as any).reasoning_effort = 'medium';
|
||||||
|
}
|
||||||
|
|
||||||
const response = await this.client.chat.completions.create(params);
|
const response = await this.client.chat.completions.create(params);
|
||||||
|
|
||||||
const choice = response.choices[0];
|
const choice = response.choices[0];
|
||||||
|
|||||||
@@ -66,6 +66,8 @@ export interface ChatRequest {
|
|||||||
system?: string;
|
system?: string;
|
||||||
maxTokens?: number;
|
maxTokens?: number;
|
||||||
tools?: ToolDefinition[];
|
tools?: ToolDefinition[];
|
||||||
|
/** Enable extended thinking/reasoning mode for this request. */
|
||||||
|
thinking?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatResponse {
|
export interface ChatResponse {
|
||||||
@@ -77,6 +79,8 @@ export interface ChatResponse {
|
|||||||
fallback?: boolean;
|
fallback?: boolean;
|
||||||
/** Human-readable reason for the fallback. */
|
/** Human-readable reason for the fallback. */
|
||||||
fallbackReason?: string;
|
fallbackReason?: string;
|
||||||
|
/** Raw thinking/reasoning output from extended thinking mode. */
|
||||||
|
thinkingContent?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TokenUsage {
|
export interface TokenUsage {
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
export { SessionStore } 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';
|
||||||
|
|||||||
@@ -98,4 +98,11 @@ export class SessionManager {
|
|||||||
const id = this.makeSessionId(frontend, userId);
|
const id = this.makeSessionId(frontend, userId);
|
||||||
this.sessions.delete(id);
|
this.sessions.delete(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Remove sessions from the in-memory cache by their IDs. */
|
||||||
|
evictSessions(sessionIds: string[]): void {
|
||||||
|
for (const id of sessionIds) {
|
||||||
|
this.sessions.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
import Database from 'better-sqlite3';
|
import Database from 'better-sqlite3';
|
||||||
import type { Message } from '../models/types.js';
|
import type { Message } from '../models/types.js';
|
||||||
|
|
||||||
|
/** Parse a duration string like '30d', '7d', '12h' to milliseconds. Returns null if invalid or '0'. */
|
||||||
|
export function parseDuration(s: string): number | null {
|
||||||
|
if (s === '0' || s === 'false') return null;
|
||||||
|
const match = s.match(/^(\d+)(h|d)$/);
|
||||||
|
if (!match) return null;
|
||||||
|
const [, n, unit] = match;
|
||||||
|
return unit === 'h' ? Number(n) * 3600_000 : Number(n) * 86_400_000;
|
||||||
|
}
|
||||||
|
|
||||||
export class SessionStore {
|
export class SessionStore {
|
||||||
private db: Database.Database;
|
private db: Database.Database;
|
||||||
|
|
||||||
@@ -71,6 +80,27 @@ export class SessionStore {
|
|||||||
return rows.map(row => row.session_id);
|
return rows.map(row => row.session_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Delete all messages for sessions with no activity since the given timestamp. Returns pruned session IDs. */
|
||||||
|
pruneStale(beforeTimestamp: number): string[] {
|
||||||
|
const stale = this.db.prepare(`
|
||||||
|
SELECT session_id FROM messages
|
||||||
|
GROUP BY session_id
|
||||||
|
HAVING MAX(created_at) < ?
|
||||||
|
`).all(beforeTimestamp) as Array<{ session_id: string }>;
|
||||||
|
|
||||||
|
if (stale.length === 0) return [];
|
||||||
|
|
||||||
|
const deleteStmt = this.db.prepare('DELETE FROM messages WHERE session_id = ?');
|
||||||
|
const transaction = this.db.transaction(() => {
|
||||||
|
for (const { session_id } of stale) {
|
||||||
|
deleteStmt.run(session_id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
transaction();
|
||||||
|
|
||||||
|
return stale.map(r => r.session_id);
|
||||||
|
}
|
||||||
|
|
||||||
close(): void {
|
close(): void {
|
||||||
this.db.close();
|
this.db.close();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -409,6 +409,90 @@ describe('ToolPolicy', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('tool groups', () => {
|
||||||
|
it('expands group:fs in allow list', () => {
|
||||||
|
const policy = new ToolPolicy(defaultConfig({
|
||||||
|
profile: 'minimal',
|
||||||
|
allow: ['group:fs'],
|
||||||
|
}));
|
||||||
|
const result = policy.filterTools(ALL_TOOLS);
|
||||||
|
const names = result.map(t => t.name);
|
||||||
|
expect(names).toContain('file.read');
|
||||||
|
expect(names).toContain('file.write');
|
||||||
|
expect(names).toContain('file.edit');
|
||||||
|
expect(names).toContain('file.list');
|
||||||
|
expect(names).not.toContain('shell.exec');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('expands group:runtime in deny list', () => {
|
||||||
|
const policy = new ToolPolicy(defaultConfig({
|
||||||
|
deny: ['group:runtime'],
|
||||||
|
}));
|
||||||
|
const result = policy.filterTools(ALL_TOOLS);
|
||||||
|
const names = result.map(t => t.name);
|
||||||
|
expect(names).not.toContain('shell.exec');
|
||||||
|
expect(names).not.toContain('process.start');
|
||||||
|
expect(names).not.toContain('process.status');
|
||||||
|
expect(names).not.toContain('process.output');
|
||||||
|
expect(names).not.toContain('process.kill');
|
||||||
|
expect(names).not.toContain('process.list');
|
||||||
|
expect(names).toContain('file.read');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('expands groups in agent overrides', () => {
|
||||||
|
const policy = new ToolPolicy(defaultConfig({
|
||||||
|
agents: {
|
||||||
|
fast: { profile: 'minimal', allow: ['group:memory'], deny: [] },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
const result = policy.filterTools(ALL_TOOLS, { agent: 'fast' });
|
||||||
|
const names = result.map(t => t.name);
|
||||||
|
expect(names).toContain('memory.read');
|
||||||
|
expect(names).toContain('memory.write');
|
||||||
|
expect(names).toContain('memory.search');
|
||||||
|
expect(names).toContain('file.read'); // from minimal profile
|
||||||
|
expect(names).not.toContain('shell.exec');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('expands groups in provider deny', () => {
|
||||||
|
const policy = new ToolPolicy(defaultConfig({
|
||||||
|
providers: {
|
||||||
|
ollama: { allow: [], deny: ['group:web'] },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
const result = policy.filterTools(ALL_TOOLS, { provider: 'ollama' });
|
||||||
|
const names = result.map(t => t.name);
|
||||||
|
expect(names).not.toContain('web.fetch');
|
||||||
|
expect(names).not.toContain('web.search');
|
||||||
|
expect(names).toContain('file.read');
|
||||||
|
expect(names).toContain('shell.exec');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mixes groups with individual names', () => {
|
||||||
|
const policy = new ToolPolicy(defaultConfig({
|
||||||
|
profile: 'minimal',
|
||||||
|
allow: ['group:memory', 'shell.exec'],
|
||||||
|
}));
|
||||||
|
const result = policy.filterTools(ALL_TOOLS);
|
||||||
|
const names = result.map(t => t.name);
|
||||||
|
expect(names).toContain('memory.read');
|
||||||
|
expect(names).toContain('shell.exec');
|
||||||
|
expect(names).toContain('file.read'); // from minimal
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unknown group name passes through as literal', () => {
|
||||||
|
const policy = new ToolPolicy(defaultConfig({
|
||||||
|
profile: 'minimal',
|
||||||
|
allow: ['group:nonexistent'],
|
||||||
|
}));
|
||||||
|
const result = policy.filterTools(ALL_TOOLS);
|
||||||
|
// Should only have minimal tools — 'group:nonexistent' doesn't match any real tool
|
||||||
|
const names = result.map(t => t.name);
|
||||||
|
expect(names).toContain('file.read');
|
||||||
|
expect(names).not.toContain('shell.exec');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('edge cases', () => {
|
describe('edge cases', () => {
|
||||||
it('handles empty tool list', () => {
|
it('handles empty tool list', () => {
|
||||||
const policy = new ToolPolicy(defaultConfig());
|
const policy = new ToolPolicy(defaultConfig());
|
||||||
|
|||||||
+32
-13
@@ -45,6 +45,21 @@ const PROFILE_TOOLS: Record<ToolProfile, Set<string>> = {
|
|||||||
full: new Set(), // Special: matches everything
|
full: new Set(), // Special: matches everything
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── Tool groups ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Named groups for use in allow/deny lists (e.g. 'group:fs'). */
|
||||||
|
export const TOOL_GROUPS: Record<string, string[]> = {
|
||||||
|
'group:fs': ['file.read', 'file.write', 'file.edit', 'file.list'],
|
||||||
|
'group:runtime': ['shell.exec', 'process.start', 'process.output', 'process.status', 'process.kill', 'process.list'],
|
||||||
|
'group:web': ['web.fetch', 'web.search', 'browser.navigate', 'browser.screenshot', 'browser.click', 'browser.type', 'browser.content', 'browser.eval'],
|
||||||
|
'group:memory': ['memory.read', 'memory.write', 'memory.search'],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Expand group references in a list of tool names/patterns. */
|
||||||
|
function expandGroups(names: string[]): string[] {
|
||||||
|
return names.flatMap(n => TOOL_GROUPS[n] ?? [n]);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Glob matching ───────────────────────────────────────────────────
|
// ── Glob matching ───────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -122,19 +137,21 @@ export class ToolPolicy {
|
|||||||
// Step 1: Start from global profile
|
// Step 1: Start from global profile
|
||||||
let allowed = this.applyProfile(this.config.profile, allToolNames);
|
let allowed = this.applyProfile(this.config.profile, allToolNames);
|
||||||
|
|
||||||
// Step 2: Apply global allow (adds tools)
|
// Step 2: Apply global allow (adds tools) — expand groups first
|
||||||
if (this.config.allow.length > 0) {
|
const globalAllow = expandGroups(this.config.allow);
|
||||||
|
if (globalAllow.length > 0) {
|
||||||
for (const name of allToolNames) {
|
for (const name of allToolNames) {
|
||||||
if (matchesAnyPattern(name, this.config.allow)) {
|
if (matchesAnyPattern(name, globalAllow)) {
|
||||||
allowed.add(name);
|
allowed.add(name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 3: Apply global deny (removes tools)
|
// Step 3: Apply global deny (removes tools) — expand groups first
|
||||||
if (this.config.deny.length > 0) {
|
const globalDeny = expandGroups(this.config.deny);
|
||||||
|
if (globalDeny.length > 0) {
|
||||||
allowed = new Set(
|
allowed = new Set(
|
||||||
[...allowed].filter(name => !matchesAnyPattern(name, this.config.deny)),
|
[...allowed].filter(name => !matchesAnyPattern(name, globalDeny)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,19 +214,21 @@ export class ToolPolicy {
|
|||||||
const baseProfile = override.profile ?? this.config.profile;
|
const baseProfile = override.profile ?? this.config.profile;
|
||||||
let allowed = this.applyProfile(baseProfile, allToolNames);
|
let allowed = this.applyProfile(baseProfile, allToolNames);
|
||||||
|
|
||||||
// Apply override allow
|
// Apply override allow — expand groups first
|
||||||
if (override.allow.length > 0) {
|
const overrideAllow = expandGroups(override.allow);
|
||||||
|
if (overrideAllow.length > 0) {
|
||||||
for (const name of allToolNames) {
|
for (const name of allToolNames) {
|
||||||
if (matchesAnyPattern(name, override.allow)) {
|
if (matchesAnyPattern(name, overrideAllow)) {
|
||||||
allowed.add(name);
|
allowed.add(name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply override deny (deny always wins)
|
// Apply override deny (deny always wins) — expand groups first
|
||||||
if (override.deny.length > 0) {
|
const overrideDeny = expandGroups(override.deny);
|
||||||
|
if (overrideDeny.length > 0) {
|
||||||
allowed = new Set(
|
allowed = new Set(
|
||||||
[...allowed].filter(name => !matchesAnyPattern(name, override.deny)),
|
[...allowed].filter(name => !matchesAnyPattern(name, overrideDeny)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,4 +251,4 @@ function intersect(a: Set<string>, b: Set<string>): Set<string> {
|
|||||||
/**
|
/**
|
||||||
* Exported for testing and for use in HookEngine (DRY).
|
* Exported for testing and for use in HookEngine (DRY).
|
||||||
*/
|
*/
|
||||||
export { patternToRegex, matchesAnyPattern, PROFILE_TOOLS };
|
export { patternToRegex, matchesAnyPattern, PROFILE_TOOLS, expandGroups };
|
||||||
|
|||||||
Reference in New Issue
Block a user