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
+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 };