@thispointon/kondi-chat 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +556 -0
  3. package/bin/kondi-chat +56 -0
  4. package/bin/kondi-chat.js +72 -0
  5. package/package.json +55 -0
  6. package/scripts/demo.tape +49 -0
  7. package/scripts/postinstall.cjs +103 -0
  8. package/src/audit/analytics.ts +261 -0
  9. package/src/audit/ledger.ts +253 -0
  10. package/src/audit/telemetry.ts +165 -0
  11. package/src/cli/backend.ts +675 -0
  12. package/src/cli/commands.ts +419 -0
  13. package/src/cli/help.ts +182 -0
  14. package/src/cli/submit-helpers.ts +159 -0
  15. package/src/cli/submit.ts +539 -0
  16. package/src/cli/wizard.ts +121 -0
  17. package/src/context/bootstrap.ts +138 -0
  18. package/src/context/budget.ts +100 -0
  19. package/src/context/manager.ts +666 -0
  20. package/src/context/memory.ts +160 -0
  21. package/src/context/preflight.ts +176 -0
  22. package/src/context/project-brain.ts +101 -0
  23. package/src/context/receipts.ts +108 -0
  24. package/src/context/skills.ts +154 -0
  25. package/src/context/symbol-index.ts +240 -0
  26. package/src/council/profiles.ts +137 -0
  27. package/src/council/tool.ts +138 -0
  28. package/src/council-engine/cli/council-artifacts.ts +230 -0
  29. package/src/council-engine/cli/council-config.ts +178 -0
  30. package/src/council-engine/cli/council-session-export.ts +116 -0
  31. package/src/council-engine/cli/kondi.ts +98 -0
  32. package/src/council-engine/cli/llm-caller.ts +229 -0
  33. package/src/council-engine/cli/localStorage-shim.ts +119 -0
  34. package/src/council-engine/cli/node-platform.ts +68 -0
  35. package/src/council-engine/cli/run-council.ts +481 -0
  36. package/src/council-engine/cli/run-pipeline.ts +772 -0
  37. package/src/council-engine/cli/session-export.ts +153 -0
  38. package/src/council-engine/configs/councils/analysis.json +101 -0
  39. package/src/council-engine/configs/councils/code-planning.json +86 -0
  40. package/src/council-engine/configs/councils/coding.json +89 -0
  41. package/src/council-engine/configs/councils/debate.json +97 -0
  42. package/src/council-engine/configs/councils/solo-claude.json +34 -0
  43. package/src/council-engine/configs/councils/solo-gpt.json +34 -0
  44. package/src/council-engine/council/coding-orchestrator.ts +1205 -0
  45. package/src/council-engine/council/context-bootstrap.ts +147 -0
  46. package/src/council-engine/council/context-inspection.ts +42 -0
  47. package/src/council-engine/council/context-store.ts +763 -0
  48. package/src/council-engine/council/deliberation-orchestrator.ts +2762 -0
  49. package/src/council-engine/council/factory.ts +164 -0
  50. package/src/council-engine/council/index.ts +201 -0
  51. package/src/council-engine/council/ledger-store.ts +438 -0
  52. package/src/council-engine/council/prompts.ts +1689 -0
  53. package/src/council-engine/council/storage-cleanup.ts +164 -0
  54. package/src/council-engine/council/store.ts +1110 -0
  55. package/src/council-engine/council/synthesis.ts +291 -0
  56. package/src/council-engine/council/types.ts +845 -0
  57. package/src/council-engine/council/validation.ts +613 -0
  58. package/src/council-engine/pipeline/build-detect.ts +73 -0
  59. package/src/council-engine/pipeline/executor.ts +1048 -0
  60. package/src/council-engine/pipeline/index.ts +9 -0
  61. package/src/council-engine/pipeline/install-detect.ts +84 -0
  62. package/src/council-engine/pipeline/memory-store.ts +182 -0
  63. package/src/council-engine/pipeline/output-parsers.ts +146 -0
  64. package/src/council-engine/pipeline/run-output.ts +149 -0
  65. package/src/council-engine/pipeline/session-import.ts +177 -0
  66. package/src/council-engine/pipeline/store.ts +753 -0
  67. package/src/council-engine/pipeline/test-detect.ts +82 -0
  68. package/src/council-engine/pipeline/types.ts +401 -0
  69. package/src/council-engine/services/deliberationSummary.ts +114 -0
  70. package/src/council-engine/tsconfig.json +16 -0
  71. package/src/council-engine/types/mcp.ts +122 -0
  72. package/src/council-engine/utils/filterTools.ts +73 -0
  73. package/src/engine/apply.ts +238 -0
  74. package/src/engine/checkpoints.ts +237 -0
  75. package/src/engine/consultants.ts +347 -0
  76. package/src/engine/diff.ts +171 -0
  77. package/src/engine/errors.ts +102 -0
  78. package/src/engine/git-tools.ts +246 -0
  79. package/src/engine/hooks.ts +181 -0
  80. package/src/engine/loop-guard.ts +155 -0
  81. package/src/engine/permissions.ts +293 -0
  82. package/src/engine/pipeline.ts +376 -0
  83. package/src/engine/sub-agents.ts +133 -0
  84. package/src/engine/task-card.ts +185 -0
  85. package/src/engine/task-router.ts +256 -0
  86. package/src/engine/task-store.ts +86 -0
  87. package/src/engine/tools.ts +783 -0
  88. package/src/engine/verify.ts +111 -0
  89. package/src/mcp/client.ts +225 -0
  90. package/src/mcp/config.ts +120 -0
  91. package/src/mcp/tool-manager.ts +192 -0
  92. package/src/mcp/types.ts +61 -0
  93. package/src/providers/llm-caller.ts +943 -0
  94. package/src/providers/rate-limiter.ts +238 -0
  95. package/src/router/NOTES.md +28 -0
  96. package/src/router/collector.ts +474 -0
  97. package/src/router/embeddings.ts +286 -0
  98. package/src/router/index.ts +299 -0
  99. package/src/router/intent-router.ts +225 -0
  100. package/src/router/nn-router.ts +205 -0
  101. package/src/router/profiles.ts +309 -0
  102. package/src/router/registry.ts +565 -0
  103. package/src/router/rules.ts +274 -0
  104. package/src/router/train.py +408 -0
  105. package/src/session/store.ts +211 -0
  106. package/src/test-utils/mock-llm.ts +39 -0
  107. package/src/types.ts +322 -0
  108. package/src/web/manager.ts +311 -0
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Verification — run local tests, lint, and typecheck after task execution.
3
+ */
4
+
5
+ import { execSync } from 'node:child_process';
6
+ import { existsSync, readFileSync } from 'node:fs';
7
+ import { join } from 'node:path';
8
+ import type { VerificationResult, RepoMap } from '../types.ts';
9
+
10
+ const TIMEOUT_MS = 60_000; // 1 minute per command
11
+
12
+ /**
13
+ * Run all available verification commands and return results.
14
+ */
15
+ export function verify(workingDir: string, repoMap?: RepoMap): VerificationResult {
16
+ const commands = repoMap?.commands || detectCommands(workingDir);
17
+ const result: VerificationResult = { passed: true };
18
+
19
+ // Test
20
+ if (commands.test) {
21
+ const testResult = runCommand(commands.test, workingDir);
22
+ result.testOutput = testResult.output;
23
+ if (!testResult.ok) result.passed = false;
24
+ }
25
+
26
+ // Typecheck
27
+ if (commands.typecheck) {
28
+ const typecheckResult = runCommand(commands.typecheck, workingDir);
29
+ result.typecheckOutput = typecheckResult.output;
30
+ if (!typecheckResult.ok) result.passed = false;
31
+ }
32
+
33
+ // Lint
34
+ if (commands.lint) {
35
+ const lintResult = runCommand(commands.lint, workingDir);
36
+ result.lintOutput = lintResult.output;
37
+ if (!lintResult.ok) result.passed = false;
38
+ }
39
+
40
+ return result;
41
+ }
42
+
43
+ /**
44
+ * Auto-detect build/test/lint commands from project files. Exported so the
45
+ * agent loop's per-edit auto-verify can reuse the same detection instead of
46
+ * hardcoding `tsc --noEmit` for every project.
47
+ */
48
+ export function detectCommands(workingDir: string): RepoMap['commands'] {
49
+ // fs and path imported at top level
50
+ const commands: RepoMap['commands'] = {};
51
+
52
+ // Node.js / package.json
53
+ const pkgPath = join(workingDir, 'package.json');
54
+ if (existsSync(pkgPath)) {
55
+ try {
56
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
57
+ const scripts = pkg.scripts || {};
58
+ if (scripts.test) commands.test = 'npm test';
59
+ if (scripts.lint) commands.lint = 'npm run lint';
60
+ if (scripts.typecheck) commands.typecheck = 'npm run typecheck';
61
+ else if (pkg.devDependencies?.typescript || pkg.dependencies?.typescript) {
62
+ commands.typecheck = 'npx tsc --noEmit';
63
+ }
64
+ if (scripts.build) commands.build = 'npm run build';
65
+ } catch { /* ignore */ }
66
+ }
67
+
68
+ // Python
69
+ if (existsSync(join(workingDir, 'pyproject.toml')) || existsSync(join(workingDir, 'setup.py'))) {
70
+ if (!commands.test) commands.test = 'pytest';
71
+ if (!commands.lint) commands.lint = 'ruff check .';
72
+ if (!commands.typecheck) commands.typecheck = 'mypy .';
73
+ }
74
+
75
+ // Rust
76
+ if (existsSync(join(workingDir, 'Cargo.toml'))) {
77
+ commands.test = 'cargo test';
78
+ commands.build = 'cargo build';
79
+ commands.lint = 'cargo clippy';
80
+ }
81
+
82
+ // Go
83
+ if (existsSync(join(workingDir, 'go.mod'))) {
84
+ commands.test = 'go test ./...';
85
+ commands.build = 'go build ./...';
86
+ commands.lint = 'golangci-lint run';
87
+ }
88
+
89
+ return commands;
90
+ }
91
+
92
+ // ---------------------------------------------------------------------------
93
+ // Helpers
94
+ // ---------------------------------------------------------------------------
95
+
96
+ function runCommand(cmd: string, cwd: string): { ok: boolean; output: string } {
97
+ try {
98
+ const output = execSync(cmd, {
99
+ cwd,
100
+ encoding: 'utf-8',
101
+ timeout: TIMEOUT_MS,
102
+ stdio: ['pipe', 'pipe', 'pipe'],
103
+ });
104
+ return { ok: true, output: output.slice(-2000) }; // Keep last 2K chars
105
+ } catch (error: any) {
106
+ const stdout = error.stdout?.toString() || '';
107
+ const stderr = error.stderr?.toString() || '';
108
+ const combined = `${stdout}\n${stderr}`.trim().slice(-2000);
109
+ return { ok: false, output: combined || error.message };
110
+ }
111
+ }
@@ -0,0 +1,225 @@
1
+ /**
2
+ * MCP Client Manager — connects to MCP servers and exposes their tools.
3
+ *
4
+ * Supports:
5
+ * - stdio: local process (command + args)
6
+ * - http/sse: remote server (URL + optional headers)
7
+ *
8
+ * Each server's tools are namespaced as "servername__toolname" to avoid
9
+ * collisions across servers.
10
+ */
11
+
12
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
13
+ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
14
+ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
15
+ import type {
16
+ McpScopedConfig, McpServerState, McpToolInfo, McpConnectionStatus,
17
+ } from './types.ts';
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // MCP Client Manager
21
+ // ---------------------------------------------------------------------------
22
+
23
+ export class McpClientManager {
24
+ private servers: Map<string, McpServerState> = new Map();
25
+ private clients: Map<string, { client: Client; cleanup: () => Promise<void> }> = new Map();
26
+
27
+ /** Connect to all configured servers */
28
+ async connectAll(configs: Map<string, McpScopedConfig>): Promise<void> {
29
+ const promises = [...configs.entries()].map(([name, config]) =>
30
+ this.connect(name, config)
31
+ );
32
+ await Promise.allSettled(promises);
33
+ }
34
+
35
+ /** Connect to a single server */
36
+ async connect(name: string, config: McpScopedConfig): Promise<McpServerState> {
37
+ // Clean up existing connection if any
38
+ await this.disconnect(name);
39
+
40
+ const state: McpServerState = {
41
+ name,
42
+ status: 'pending',
43
+ config,
44
+ tools: [],
45
+ };
46
+ this.servers.set(name, state);
47
+
48
+ const configType = 'type' in config ? config.type : 'stdio';
49
+ process.stderr.write(`[mcp] Connecting to ${name} (${configType || 'stdio'})...\n`);
50
+
51
+ try {
52
+ const { client, cleanup } = await this.createClient(name, config);
53
+ this.clients.set(name, { client, cleanup });
54
+
55
+ // Discover tools
56
+ const toolsResult = await client.listTools();
57
+ const tools: McpToolInfo[] = (toolsResult.tools || []).map(tool => ({
58
+ qualifiedName: `${name}__${tool.name}`,
59
+ originalName: tool.name,
60
+ serverName: name,
61
+ description: tool.description || '',
62
+ parameters: (tool.inputSchema as Record<string, unknown>) || { type: 'object', properties: {} },
63
+ }));
64
+
65
+ state.status = 'connected';
66
+ state.tools = tools;
67
+
68
+ process.stderr.write(`[mcp] ${name}: connected (${tools.length} tools)\n`);
69
+ } catch (error) {
70
+ state.status = 'failed';
71
+ state.error = (error as Error).message;
72
+ process.stderr.write(`[mcp] ${name}: failed — ${state.error}\n`);
73
+ }
74
+
75
+ return state;
76
+ }
77
+
78
+ /** Disconnect a server */
79
+ async disconnect(name: string): Promise<void> {
80
+ const entry = this.clients.get(name);
81
+ if (entry) {
82
+ try {
83
+ await entry.cleanup();
84
+ } catch { /* ignore cleanup errors */ }
85
+ this.clients.delete(name);
86
+ }
87
+ this.servers.delete(name);
88
+ }
89
+
90
+ /** Disconnect all servers */
91
+ async disconnectAll(): Promise<void> {
92
+ const names = [...this.servers.keys()];
93
+ await Promise.allSettled(names.map(n => this.disconnect(n)));
94
+ }
95
+
96
+ // -------------------------------------------------------------------------
97
+ // Tool access
98
+ // -------------------------------------------------------------------------
99
+
100
+ /** Get all tools from all connected servers */
101
+ getAllTools(): McpToolInfo[] {
102
+ const tools: McpToolInfo[] = [];
103
+ for (const state of this.servers.values()) {
104
+ if (state.status === 'connected') {
105
+ tools.push(...state.tools);
106
+ }
107
+ }
108
+ return tools;
109
+ }
110
+
111
+ /** Call a tool on an MCP server */
112
+ async callTool(
113
+ qualifiedName: string,
114
+ args: Record<string, unknown>,
115
+ ): Promise<{ content: string; isError?: boolean }> {
116
+ // Parse server__tool
117
+ const sep = qualifiedName.indexOf('__');
118
+ if (sep < 0) {
119
+ return { content: `Invalid MCP tool name: ${qualifiedName}`, isError: true };
120
+ }
121
+
122
+ const serverName = qualifiedName.slice(0, sep);
123
+ const toolName = qualifiedName.slice(sep + 2);
124
+
125
+ const entry = this.clients.get(serverName);
126
+ if (!entry) {
127
+ return { content: `MCP server not connected: ${serverName}`, isError: true };
128
+ }
129
+
130
+ try {
131
+ const result = await entry.client.callTool({ name: toolName, arguments: args });
132
+
133
+ // Extract text content from MCP response
134
+ const textParts = (result.content as any[])
135
+ ?.filter((c: any) => c.type === 'text')
136
+ .map((c: any) => c.text) || [];
137
+
138
+ return {
139
+ content: textParts.join('\n') || '(no output)',
140
+ isError: result.isError === true,
141
+ };
142
+ } catch (error) {
143
+ return { content: `MCP tool error: ${(error as Error).message}`, isError: true };
144
+ }
145
+ }
146
+
147
+ // -------------------------------------------------------------------------
148
+ // Server state
149
+ // -------------------------------------------------------------------------
150
+
151
+ getServers(): McpServerState[] {
152
+ return [...this.servers.values()];
153
+ }
154
+
155
+ getServer(name: string): McpServerState | undefined {
156
+ return this.servers.get(name);
157
+ }
158
+
159
+ /** Format for display */
160
+ format(): string {
161
+ const servers = this.getServers();
162
+ if (servers.length === 0) return 'No MCP servers configured. See /mcp add.';
163
+
164
+ const lines: string[] = ['MCP Servers:'];
165
+ for (const s of servers) {
166
+ const icon = s.status === 'connected' ? 'OK' : s.status === 'failed' ? 'FAIL' : '...';
167
+ const scope = s.config.scope;
168
+ const type = s.config.type || 'stdio';
169
+ lines.push(` [${icon.padEnd(4)}] ${s.name} (${type}, ${scope})`);
170
+ if (s.status === 'connected') {
171
+ lines.push(` ${s.tools.length} tools: ${s.tools.map(t => t.originalName).join(', ')}`);
172
+ }
173
+ if (s.error) {
174
+ lines.push(` Error: ${s.error}`);
175
+ }
176
+ }
177
+ return lines.join('\n');
178
+ }
179
+
180
+ // -------------------------------------------------------------------------
181
+ // Transport creation
182
+ // -------------------------------------------------------------------------
183
+
184
+ private async createClient(
185
+ name: string,
186
+ config: McpScopedConfig,
187
+ ): Promise<{ client: Client; cleanup: () => Promise<void> }> {
188
+ const client = new Client({ name: `kondi-chat-${name}`, version: '0.1.0' });
189
+ const type = 'type' in config ? config.type || 'stdio' : 'stdio';
190
+
191
+ if (type === 'stdio') {
192
+ const stdioConfig = config as { command: string; args?: string[]; env?: Record<string, string>; scope: string };
193
+ const transport = new StdioClientTransport({
194
+ command: stdioConfig.command,
195
+ args: stdioConfig.args || [],
196
+ env: { ...process.env, ...(stdioConfig.env || {}) } as Record<string, string>,
197
+ });
198
+
199
+ await client.connect(transport);
200
+ return {
201
+ client,
202
+ cleanup: async () => {
203
+ await client.close();
204
+ },
205
+ };
206
+ }
207
+
208
+ if (type === 'http' || type === 'sse') {
209
+ const httpConfig = config as { url: string; headers?: Record<string, string>; scope: string };
210
+ const transport = new StreamableHTTPClientTransport(
211
+ new URL(httpConfig.url),
212
+ );
213
+
214
+ await client.connect(transport);
215
+ return {
216
+ client,
217
+ cleanup: async () => {
218
+ await client.close();
219
+ },
220
+ };
221
+ }
222
+
223
+ throw new Error(`Unsupported MCP transport: ${type}`);
224
+ }
225
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * MCP Config — loads server configuration from multiple scopes.
3
+ *
4
+ * Config files (checked in order, merged):
5
+ * 1. Project: .kondi-chat/mcp.json (project-specific)
6
+ * 2. User: ~/.kondi-chat/mcp.json (user-wide)
7
+ *
8
+ * Format (same as Claude Code):
9
+ * {
10
+ * "mcpServers": {
11
+ * "filesystem": {
12
+ * "command": "npx",
13
+ * "args": ["-y", "@modelcontextprotocol/server-filesystem", "/path"]
14
+ * },
15
+ * "remote-api": {
16
+ * "type": "http",
17
+ * "url": "https://api.example.com/mcp"
18
+ * }
19
+ * }
20
+ * }
21
+ */
22
+
23
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
24
+ import { join } from 'node:path';
25
+ import { homedir } from 'node:os';
26
+ import type { McpConfigFile, McpServerConfig, McpScopedConfig, McpScope } from './types.ts';
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Config loader
30
+ // ---------------------------------------------------------------------------
31
+
32
+ export function loadMcpConfig(projectDir: string): Map<string, McpScopedConfig> {
33
+ const servers = new Map<string, McpScopedConfig>();
34
+
35
+ // Load user-level config first (lower priority)
36
+ const userDir = join(homedir(), '.kondi-chat');
37
+ const userConfig = loadConfigFile(join(userDir, 'mcp.json'), 'user');
38
+ for (const [name, config] of userConfig) {
39
+ servers.set(name, config);
40
+ }
41
+
42
+ // Load project-level config (higher priority, overrides user)
43
+ const projectConfig = loadConfigFile(join(projectDir, '.kondi-chat', 'mcp.json'), 'project');
44
+ for (const [name, config] of projectConfig) {
45
+ servers.set(name, config);
46
+ }
47
+
48
+ return servers;
49
+ }
50
+
51
+ function loadConfigFile(path: string, scope: McpScope): Map<string, McpScopedConfig> {
52
+ const servers = new Map<string, McpScopedConfig>();
53
+ if (!existsSync(path)) return servers;
54
+
55
+ try {
56
+ const raw = readFileSync(path, 'utf-8');
57
+ const config: McpConfigFile = JSON.parse(raw);
58
+
59
+ if (config.mcpServers) {
60
+ for (const [name, serverConfig] of Object.entries(config.mcpServers)) {
61
+ servers.set(name, { ...serverConfig, scope });
62
+ }
63
+ }
64
+ } catch (error) {
65
+ process.stderr.write(`[mcp] Failed to load ${path}: ${(error as Error).message}\n`);
66
+ }
67
+
68
+ return servers;
69
+ }
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // Config writer
73
+ // ---------------------------------------------------------------------------
74
+
75
+ export function saveMcpServer(
76
+ projectDir: string,
77
+ name: string,
78
+ config: McpServerConfig,
79
+ scope: McpScope = 'project',
80
+ ): void {
81
+ const dir = scope === 'user'
82
+ ? join(homedir(), '.kondi-chat')
83
+ : join(projectDir, '.kondi-chat');
84
+ const path = join(dir, 'mcp.json');
85
+
86
+ mkdirSync(dir, { recursive: true });
87
+
88
+ let existing: McpConfigFile = { mcpServers: {} };
89
+ if (existsSync(path)) {
90
+ try {
91
+ existing = JSON.parse(readFileSync(path, 'utf-8'));
92
+ } catch { /* start fresh */ }
93
+ }
94
+
95
+ existing.mcpServers[name] = config;
96
+ writeFileSync(path, JSON.stringify(existing, null, 2));
97
+ }
98
+
99
+ export function removeMcpServer(
100
+ projectDir: string,
101
+ name: string,
102
+ scope: McpScope = 'project',
103
+ ): boolean {
104
+ const dir = scope === 'user'
105
+ ? join(homedir(), '.kondi-chat')
106
+ : join(projectDir, '.kondi-chat');
107
+ const path = join(dir, 'mcp.json');
108
+
109
+ if (!existsSync(path)) return false;
110
+
111
+ try {
112
+ const existing: McpConfigFile = JSON.parse(readFileSync(path, 'utf-8'));
113
+ if (!existing.mcpServers[name]) return false;
114
+ delete existing.mcpServers[name];
115
+ writeFileSync(path, JSON.stringify(existing, null, 2));
116
+ return true;
117
+ } catch {
118
+ return false;
119
+ }
120
+ }
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Tool Manager — merges built-in tools and MCP tools into a unified set.
3
+ *
4
+ * Handles:
5
+ * - Combining built-in tools (read_file, etc.) with MCP tools
6
+ * - Dispatching tool calls to the right handler (built-in or MCP)
7
+ * - Filtering tools per request to save tokens
8
+ * - Converting MCP tools to our ToolDefinition format
9
+ */
10
+
11
+ import type { ToolDefinition } from '../types.ts';
12
+ import { AGENT_TOOLS, executeTool, type ToolContext, type ToolExecutionResult } from '../engine/tools.ts';
13
+ import { McpClientManager } from './client.ts';
14
+ import type { McpToolInfo } from './types.ts';
15
+ import type { HookRunner } from '../engine/hooks.ts';
16
+
17
+ /** Extra tools registered at runtime (e.g., council) */
18
+ let extraTools: ToolDefinition[] = [];
19
+ let extraExecutors: Map<string, (args: Record<string, unknown>, ctx: ToolContext) => Promise<ToolExecutionResult>> = new Map();
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Tool categories for filtering
23
+ // ---------------------------------------------------------------------------
24
+
25
+ /** Built-in tool categories */
26
+ const BUILTIN_CATEGORIES: Record<string, string[]> = {
27
+ read_file: ['filesystem', 'coding'],
28
+ write_file: ['filesystem', 'coding'],
29
+ edit_file: ['filesystem', 'coding'],
30
+ list_files: ['filesystem', 'coding'],
31
+ search_code: ['coding', 'analysis'],
32
+ run_command: ['system', 'coding'],
33
+ create_task: ['coding', 'execution'],
34
+ update_plan: ['planning'],
35
+ update_memory: ['planning'],
36
+ spawn_agent: ['planning', 'coding'],
37
+ git_status: ['git', 'analysis'],
38
+ git_diff: ['git', 'analysis'],
39
+ git_log: ['git', 'analysis'],
40
+ git_branch: ['git', 'coding'],
41
+ git_commit: ['git', 'coding'],
42
+ git_create_pr: ['git', 'coding'],
43
+ };
44
+
45
+ /** Phase → which tool categories are relevant */
46
+ const PHASE_TOOLS: Record<string, string[]> = {
47
+ discuss: ['filesystem', 'coding', 'analysis', 'planning', 'system', 'git'],
48
+ dispatch: ['planning'],
49
+ execute: ['filesystem', 'coding', 'system', 'git'],
50
+ reflect: [],
51
+ compress: [],
52
+ state_update: ['planning'],
53
+ };
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // Tool Manager
57
+ // ---------------------------------------------------------------------------
58
+
59
+ export class ToolManager {
60
+ private mcpClient: McpClientManager;
61
+ private hookRunner?: HookRunner;
62
+
63
+ constructor(mcpClient: McpClientManager) {
64
+ this.mcpClient = mcpClient;
65
+ }
66
+
67
+ setHookRunner(runner: HookRunner): void {
68
+ this.hookRunner = runner;
69
+ runner.setToolExecutor((name, args, ctx) => this.executeWithoutHooks(name, args, ctx));
70
+ }
71
+
72
+ /** Register an extra tool (e.g., council) */
73
+ registerTool(
74
+ tool: ToolDefinition,
75
+ executor: (args: Record<string, unknown>, ctx: ToolContext) => Promise<ToolExecutionResult>,
76
+ ): void {
77
+ extraTools.push(tool);
78
+ extraExecutors.set(tool.name, executor);
79
+ }
80
+
81
+ /**
82
+ * Get all available tool definitions (built-in + MCP).
83
+ * Optionally filter by phase for token efficiency.
84
+ */
85
+ getTools(phase?: string): ToolDefinition[] {
86
+ const builtIn = AGENT_TOOLS;
87
+ const mcpTools = this.mcpClient.getAllTools().map(mcpToToolDef);
88
+ const all = [...builtIn, ...extraTools, ...mcpTools];
89
+
90
+ if (!phase) return all;
91
+
92
+ // Filter by phase relevance
93
+ const relevantCategories = PHASE_TOOLS[phase];
94
+ if (!relevantCategories) return all;
95
+
96
+ return all.filter(tool => {
97
+ // Built-in tools: check category
98
+ const categories = BUILTIN_CATEGORIES[tool.name];
99
+ if (categories) {
100
+ return categories.some(c => relevantCategories.includes(c));
101
+ }
102
+ // MCP tools: always include for now (can't categorize without metadata)
103
+ // TODO: use tool description to auto-categorize
104
+ return true;
105
+ });
106
+ }
107
+
108
+ /**
109
+ * Execute a tool call — routes to built-in handler or MCP server.
110
+ * Spec 01: every path goes through the permission wedge first.
111
+ */
112
+ async execute(
113
+ name: string,
114
+ args: Record<string, unknown>,
115
+ toolCtx: ToolContext,
116
+ ): Promise<ToolExecutionResult> {
117
+ // Spec 12 — before hooks run first (can block before the permission dialog).
118
+ if (this.hookRunner) {
119
+ const before = await this.hookRunner.runBefore(name, args, toolCtx, toolCtx.emit);
120
+ if (before.blocked) {
121
+ return { content: `Tool blocked by hook: ${before.messages.join('; ')}`, isError: true };
122
+ }
123
+ }
124
+
125
+ const result = await this.executeWithoutHooks(name, args, toolCtx);
126
+
127
+ if (this.hookRunner) {
128
+ return this.hookRunner.runAfter(name, args, result, toolCtx, toolCtx.emit);
129
+ }
130
+ return result;
131
+ }
132
+
133
+ /**
134
+ * Run a tool without the before/after hook pass — used by `tool:` hooks to
135
+ * avoid recursion. Permissions still apply (Spec 01 hasn't moved).
136
+ */
137
+ async executeWithoutHooks(
138
+ name: string,
139
+ args: Record<string, unknown>,
140
+ toolCtx: ToolContext,
141
+ ): Promise<ToolExecutionResult> {
142
+ // Spec 01 — permission gate (applies to built-in, extra, and MCP tools)
143
+ const pm = toolCtx.permissionManager;
144
+ if (pm) {
145
+ const tier = pm.check(name, args);
146
+ if (tier !== 'auto-approve') {
147
+ if (!toolCtx.emit) {
148
+ return { content: `Permission check for ${name} requires an emit channel`, isError: true };
149
+ }
150
+ const decision = await pm.requestPermission(name, args, toolCtx.emit);
151
+ if (decision === 'denied') {
152
+ return { content: `Permission denied for ${name}.`, isError: true };
153
+ }
154
+ }
155
+ }
156
+
157
+ // Check extra tools first (e.g., council)
158
+ const extraExec = extraExecutors.get(name);
159
+ if (extraExec) {
160
+ return extraExec(args, toolCtx);
161
+ }
162
+
163
+ // Check if it's an MCP tool (contains __ separator)
164
+ if (name.includes('__')) {
165
+ return this.mcpClient.callTool(name, args);
166
+ }
167
+
168
+ // Built-in tool
169
+ return executeTool(name, args, toolCtx);
170
+ }
171
+
172
+ /** Get summary for display */
173
+ getSummary(): { builtIn: number; mcp: number; servers: number } {
174
+ return {
175
+ builtIn: AGENT_TOOLS.length,
176
+ mcp: this.mcpClient.getAllTools().length,
177
+ servers: this.mcpClient.getServers().filter(s => s.status === 'connected').length,
178
+ };
179
+ }
180
+ }
181
+
182
+ // ---------------------------------------------------------------------------
183
+ // Helpers
184
+ // ---------------------------------------------------------------------------
185
+
186
+ function mcpToToolDef(tool: McpToolInfo): ToolDefinition {
187
+ return {
188
+ name: tool.qualifiedName,
189
+ description: `[${tool.serverName}] ${tool.description}`,
190
+ parameters: tool.parameters,
191
+ };
192
+ }