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:
William Valentin
2026-02-07 13:35:00 -08:00
parent 6bb424cddc
commit 1c2f54fae3
19 changed files with 563 additions and 20 deletions
+23 -2
View File
@@ -51,6 +51,7 @@ export class NativeAgent {
private _callCount: number = 0;
private _toolPolicyContext?: ToolPolicyContext;
private _attachmentCollector?: OutboundAttachmentCollector;
private _thinking: boolean = false;
constructor(config: NativeAgentConfig) {
this.modelClient = config.modelClient;
@@ -69,6 +70,14 @@ export class NativeAgent {
}
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);
if (this.session) {
@@ -89,6 +98,7 @@ export class NativeAgent {
const request: ChatRequest = {
messages: this.history,
system: this.systemPrompt,
...(this._thinking ? { thinking: true } : {}),
};
const response = await this.chatWithRouter(request);
@@ -101,10 +111,16 @@ export class NativeAgent {
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 };
this.addToHistory(assistantMsg);
return response.content;
return finalContent;
}
private async toolLoop(): Promise<string> {
@@ -124,6 +140,7 @@ export class NativeAgent {
messages: loopMessages as unknown as Message[],
system: this.systemPrompt,
tools,
...(this._thinking ? { thinking: true } : {}),
};
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 (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 };
this.addToHistory(assistantMsg);
return response.content;
return finalContent;
}
// Build the assistant message with tool_use content blocks
+7
View File
@@ -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
const text = message.content.replace(/<@!?\d+>/g, '').trim();
+2
View File
@@ -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)
const threadTs = message.thread_ts ?? message.ts ?? '';
const peerId = `${channelId}:${threadTs}`;
+6
View File
@@ -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
let text = message.body ?? '';
if (isGroup && this.botId) {
+20
View File
@@ -33,6 +33,18 @@ const modelConfigSchema = modelConfigBaseSchema.extend({
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({
local: modelConfigSchema.optional(),
fast: modelConfigSchema.optional(),
@@ -40,6 +52,7 @@ const modelsSchema = z.object({
complex: modelConfigSchema.optional(),
fallback_chain: z.array(z.string()).default(['anthropic']),
local_providers: z.record(z.string(), modelConfigSchema).optional(),
thinking: thinkingSchema,
});
const backendsSchema = z.object({
@@ -250,6 +263,10 @@ const promptSchema = z.object({
})).default([]),
}).default({});
const sessionsSchema = z.object({
ttl: z.string().default('30d'),
}).default({});
export const configSchema = z.object({
telegram: telegramSchema,
discord: discordSchema,
@@ -275,6 +292,7 @@ export const configSchema = z.object({
sandbox: sandboxSchema,
agent_configs: agentConfigsSchema,
routing: routingSchema,
sessions: sessionsSchema,
});
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 RoutingConfig = z.infer<typeof routingSchema>;
export type ServerConfig = z.infer<typeof serverSchema>;
export type SessionsConfig = z.infer<typeof sessionsSchema>;
export type ThinkingConfig = z.infer<typeof thinkingSchema>;
+18 -1
View File
@@ -7,7 +7,7 @@ import { AnthropicClient, OpenAIClient, OllamaClient, LlamaCppClient, GeminiClie
import type { ModelClient, RetryConfig, ModelTier } from '../models/index.js';
import { AgentOrchestrator, type DelegationConfig } from '../backends/index.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 { 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';
@@ -453,6 +453,23 @@ export async function startDaemon(config: Config): Promise<DaemonContext> {
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
const hookEngine = new HookEngine(config.hooks);
+5
View File
@@ -34,6 +34,10 @@ describe('parseCommand', () => {
expect(parseCommand('/usage')).toEqual({ type: 'usage' });
});
it('parses /verbose command', () => {
expect(parseCommand('/verbose')).toEqual({ type: 'verbose' });
});
it('parses /model command without argument', () => {
expect(parseCommand('/model')).toEqual({ type: 'model' });
});
@@ -100,6 +104,7 @@ describe('getHelpText', () => {
expect(help).toContain('/reset');
expect(help).toContain('/compact');
expect(help).toContain('/usage');
expect(help).toContain('/verbose');
expect(help).toContain('/quit');
});
});
+9
View File
@@ -6,6 +6,7 @@ export type Command =
| { type: 'fullscreen' }
| { type: 'compact' }
| { type: 'usage' }
| { type: 'verbose' }
| { type: 'model'; name?: string; providerModel?: string }
| { type: 'backend'; provider?: string }
| { type: 'login'; provider?: string }
@@ -51,6 +52,11 @@ export function parseCommand(input: string): Command | null {
return { type: 'usage' };
}
// Verbose
if (trimmed === '/verbose') {
return { type: 'verbose' };
}
// Model (with optional argument)
if (trimmed === '/model') {
return { type: 'model' };
@@ -108,6 +114,7 @@ Commands:
/reset, /clear, /new Clear conversation history
/compact Compact conversation history
/usage Show token usage and estimated cost
/verbose Toggle verbose mode (show raw streaming and tool output)
/status Show session info and token usage
/fullscreen, /fs Switch to fullscreen mode
/transfer <dest> Transfer session to another frontend
@@ -127,6 +134,7 @@ export const SLASH_COMMANDS = [
'/new',
'/compact',
'/usage',
'/verbose',
'/status',
'/fullscreen',
'/fs',
@@ -146,6 +154,7 @@ export const COMMAND_TOOLTIPS: Record<string, string> = {
'/new': 'Start a new conversation',
'/compact': 'Compact conversation history to save context space',
'/usage': 'Show token usage and estimated cost',
'/verbose': 'Toggle verbose mode (show raw streaming and tool output)',
'/status': 'Show session info and token usage',
'/fullscreen': 'Switch to fullscreen mode',
'/fs': 'Switch to fullscreen mode',
+11
View File
@@ -74,11 +74,21 @@ export class AnthropicClient implements ModelClient {
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 textContent = response.content.find((c) => c.type === '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
.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 }));
@@ -91,6 +101,7 @@ export class AnthropicClient implements ModelClient {
outputTokens: response.usage.output_tokens,
},
...(toolCalls.length > 0 ? { toolCalls } : {}),
...(thinkingContent ? { thinkingContent } : {}),
};
}
+10 -3
View File
@@ -25,13 +25,20 @@ export class GeminiClient implements ModelClient {
? [{ functionDeclarations: request.tools.map(t => convertToolDefinition(t)) }]
: 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({
model: this.model,
systemInstruction: request.system || undefined,
tools,
generationConfig: {
maxOutputTokens: request.maxTokens ?? this.defaultMaxTokens,
},
generationConfig,
});
}
+5
View File
@@ -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 choice = response.choices[0];
+5
View File
@@ -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 choice = response.choices[0];
+4
View File
@@ -66,6 +66,8 @@ export interface ChatRequest {
system?: string;
maxTokens?: number;
tools?: ToolDefinition[];
/** Enable extended thinking/reasoning mode for this request. */
thinking?: boolean;
}
export interface ChatResponse {
@@ -77,6 +79,8 @@ export interface ChatResponse {
fallback?: boolean;
/** Human-readable reason for the fallback. */
fallbackReason?: string;
/** Raw thinking/reasoning output from extended thinking mode. */
thinkingContent?: string;
}
export interface TokenUsage {
+1 -1
View File
@@ -1,2 +1,2 @@
export { SessionStore } from './store.js';
export { SessionStore, parseDuration } from './store.js';
export { SessionManager, ManagedSession, type Session } from './manager.js';
+7
View File
@@ -98,4 +98,11 @@ export class SessionManager {
const id = this.makeSessionId(frontend, userId);
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);
}
}
}
+30
View File
@@ -1,6 +1,15 @@
import Database from 'better-sqlite3';
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 {
private db: Database.Database;
@@ -71,6 +80,27 @@ export class SessionStore {
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 {
this.db.close();
}
+84
View File
@@ -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', () => {
it('handles empty tool list', () => {
const policy = new ToolPolicy(defaultConfig());
+32 -13
View File
@@ -45,6 +45,21 @@ const PROFILE_TOOLS: Record<ToolProfile, Set<string>> = {
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 ───────────────────────────────────────────────────
/**
@@ -122,19 +137,21 @@ export class ToolPolicy {
// Step 1: Start from global profile
let allowed = this.applyProfile(this.config.profile, allToolNames);
// Step 2: Apply global allow (adds tools)
if (this.config.allow.length > 0) {
// Step 2: Apply global allow (adds tools) — expand groups first
const globalAllow = expandGroups(this.config.allow);
if (globalAllow.length > 0) {
for (const name of allToolNames) {
if (matchesAnyPattern(name, this.config.allow)) {
if (matchesAnyPattern(name, globalAllow)) {
allowed.add(name);
}
}
}
// Step 3: Apply global deny (removes tools)
if (this.config.deny.length > 0) {
// Step 3: Apply global deny (removes tools) — expand groups first
const globalDeny = expandGroups(this.config.deny);
if (globalDeny.length > 0) {
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;
let allowed = this.applyProfile(baseProfile, allToolNames);
// Apply override allow
if (override.allow.length > 0) {
// Apply override allow — expand groups first
const overrideAllow = expandGroups(override.allow);
if (overrideAllow.length > 0) {
for (const name of allToolNames) {
if (matchesAnyPattern(name, override.allow)) {
if (matchesAnyPattern(name, overrideAllow)) {
allowed.add(name);
}
}
}
// Apply override deny (deny always wins)
if (override.deny.length > 0) {
// Apply override deny (deny always wins) — expand groups first
const overrideDeny = expandGroups(override.deny);
if (overrideDeny.length > 0) {
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).
*/
export { patternToRegex, matchesAnyPattern, PROFILE_TOOLS };
export { patternToRegex, matchesAnyPattern, PROFILE_TOOLS, expandGroups };