@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.
- package/LICENSE +21 -0
- package/README.md +556 -0
- package/bin/kondi-chat +56 -0
- package/bin/kondi-chat.js +72 -0
- package/package.json +55 -0
- package/scripts/demo.tape +49 -0
- package/scripts/postinstall.cjs +103 -0
- package/src/audit/analytics.ts +261 -0
- package/src/audit/ledger.ts +253 -0
- package/src/audit/telemetry.ts +165 -0
- package/src/cli/backend.ts +675 -0
- package/src/cli/commands.ts +419 -0
- package/src/cli/help.ts +182 -0
- package/src/cli/submit-helpers.ts +159 -0
- package/src/cli/submit.ts +539 -0
- package/src/cli/wizard.ts +121 -0
- package/src/context/bootstrap.ts +138 -0
- package/src/context/budget.ts +100 -0
- package/src/context/manager.ts +666 -0
- package/src/context/memory.ts +160 -0
- package/src/context/preflight.ts +176 -0
- package/src/context/project-brain.ts +101 -0
- package/src/context/receipts.ts +108 -0
- package/src/context/skills.ts +154 -0
- package/src/context/symbol-index.ts +240 -0
- package/src/council/profiles.ts +137 -0
- package/src/council/tool.ts +138 -0
- package/src/council-engine/cli/council-artifacts.ts +230 -0
- package/src/council-engine/cli/council-config.ts +178 -0
- package/src/council-engine/cli/council-session-export.ts +116 -0
- package/src/council-engine/cli/kondi.ts +98 -0
- package/src/council-engine/cli/llm-caller.ts +229 -0
- package/src/council-engine/cli/localStorage-shim.ts +119 -0
- package/src/council-engine/cli/node-platform.ts +68 -0
- package/src/council-engine/cli/run-council.ts +481 -0
- package/src/council-engine/cli/run-pipeline.ts +772 -0
- package/src/council-engine/cli/session-export.ts +153 -0
- package/src/council-engine/configs/councils/analysis.json +101 -0
- package/src/council-engine/configs/councils/code-planning.json +86 -0
- package/src/council-engine/configs/councils/coding.json +89 -0
- package/src/council-engine/configs/councils/debate.json +97 -0
- package/src/council-engine/configs/councils/solo-claude.json +34 -0
- package/src/council-engine/configs/councils/solo-gpt.json +34 -0
- package/src/council-engine/council/coding-orchestrator.ts +1205 -0
- package/src/council-engine/council/context-bootstrap.ts +147 -0
- package/src/council-engine/council/context-inspection.ts +42 -0
- package/src/council-engine/council/context-store.ts +763 -0
- package/src/council-engine/council/deliberation-orchestrator.ts +2762 -0
- package/src/council-engine/council/factory.ts +164 -0
- package/src/council-engine/council/index.ts +201 -0
- package/src/council-engine/council/ledger-store.ts +438 -0
- package/src/council-engine/council/prompts.ts +1689 -0
- package/src/council-engine/council/storage-cleanup.ts +164 -0
- package/src/council-engine/council/store.ts +1110 -0
- package/src/council-engine/council/synthesis.ts +291 -0
- package/src/council-engine/council/types.ts +845 -0
- package/src/council-engine/council/validation.ts +613 -0
- package/src/council-engine/pipeline/build-detect.ts +73 -0
- package/src/council-engine/pipeline/executor.ts +1048 -0
- package/src/council-engine/pipeline/index.ts +9 -0
- package/src/council-engine/pipeline/install-detect.ts +84 -0
- package/src/council-engine/pipeline/memory-store.ts +182 -0
- package/src/council-engine/pipeline/output-parsers.ts +146 -0
- package/src/council-engine/pipeline/run-output.ts +149 -0
- package/src/council-engine/pipeline/session-import.ts +177 -0
- package/src/council-engine/pipeline/store.ts +753 -0
- package/src/council-engine/pipeline/test-detect.ts +82 -0
- package/src/council-engine/pipeline/types.ts +401 -0
- package/src/council-engine/services/deliberationSummary.ts +114 -0
- package/src/council-engine/tsconfig.json +16 -0
- package/src/council-engine/types/mcp.ts +122 -0
- package/src/council-engine/utils/filterTools.ts +73 -0
- package/src/engine/apply.ts +238 -0
- package/src/engine/checkpoints.ts +237 -0
- package/src/engine/consultants.ts +347 -0
- package/src/engine/diff.ts +171 -0
- package/src/engine/errors.ts +102 -0
- package/src/engine/git-tools.ts +246 -0
- package/src/engine/hooks.ts +181 -0
- package/src/engine/loop-guard.ts +155 -0
- package/src/engine/permissions.ts +293 -0
- package/src/engine/pipeline.ts +376 -0
- package/src/engine/sub-agents.ts +133 -0
- package/src/engine/task-card.ts +185 -0
- package/src/engine/task-router.ts +256 -0
- package/src/engine/task-store.ts +86 -0
- package/src/engine/tools.ts +783 -0
- package/src/engine/verify.ts +111 -0
- package/src/mcp/client.ts +225 -0
- package/src/mcp/config.ts +120 -0
- package/src/mcp/tool-manager.ts +192 -0
- package/src/mcp/types.ts +61 -0
- package/src/providers/llm-caller.ts +943 -0
- package/src/providers/rate-limiter.ts +238 -0
- package/src/router/NOTES.md +28 -0
- package/src/router/collector.ts +474 -0
- package/src/router/embeddings.ts +286 -0
- package/src/router/index.ts +299 -0
- package/src/router/intent-router.ts +225 -0
- package/src/router/nn-router.ts +205 -0
- package/src/router/profiles.ts +309 -0
- package/src/router/registry.ts +565 -0
- package/src/router/rules.ts +274 -0
- package/src/router/train.py +408 -0
- package/src/session/store.ts +211 -0
- package/src/test-utils/mock-llm.ts +39 -0
- package/src/types.ts +322 -0
- package/src/web/manager.ts +311 -0
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git Tools — first-class git awareness.
|
|
3
|
+
*
|
|
4
|
+
* detectGitRepo() shells out to git and returns a plain snapshot the
|
|
5
|
+
* backend caches for prompt injection and the TUI status bar.
|
|
6
|
+
* Mutating tools refresh the snapshot after execution.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { execSync } from 'node:child_process';
|
|
10
|
+
import type { ToolDefinition } from '../types.ts';
|
|
11
|
+
import { computeUnifiedDiff } from './diff.ts';
|
|
12
|
+
|
|
13
|
+
const GIT_TIMEOUT_MS = 15_000;
|
|
14
|
+
|
|
15
|
+
export interface GitContext {
|
|
16
|
+
isGitRepo: boolean;
|
|
17
|
+
branch: string;
|
|
18
|
+
dirtyCount: number;
|
|
19
|
+
untrackedCount: number;
|
|
20
|
+
stagedCount: number;
|
|
21
|
+
lastCommitHash: string;
|
|
22
|
+
lastCommitMessage: string;
|
|
23
|
+
hasRemote: boolean;
|
|
24
|
+
remoteUrl?: string;
|
|
25
|
+
isWorktree: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function gitQ(cmd: string, cwd: string): string {
|
|
29
|
+
return execSync(cmd, { cwd, encoding: 'utf-8', timeout: GIT_TIMEOUT_MS, stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function tryGit(cmd: string, cwd: string): string {
|
|
33
|
+
try { return gitQ(cmd, cwd); } catch { return ''; }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function detectGitRepo(workingDir: string): GitContext {
|
|
37
|
+
const empty: GitContext = {
|
|
38
|
+
isGitRepo: false, branch: '', dirtyCount: 0, untrackedCount: 0, stagedCount: 0,
|
|
39
|
+
lastCommitHash: '', lastCommitMessage: '', hasRemote: false, isWorktree: false,
|
|
40
|
+
};
|
|
41
|
+
const isRepo = tryGit('git rev-parse --is-inside-work-tree', workingDir);
|
|
42
|
+
if (isRepo !== 'true') return empty;
|
|
43
|
+
|
|
44
|
+
const branch = tryGit('git rev-parse --abbrev-ref HEAD', workingDir) || 'HEAD';
|
|
45
|
+
const status = tryGit('git status --porcelain', workingDir);
|
|
46
|
+
let dirty = 0, untracked = 0, staged = 0;
|
|
47
|
+
for (const line of status.split('\n')) {
|
|
48
|
+
if (!line) continue;
|
|
49
|
+
const x = line[0];
|
|
50
|
+
const y = line[1];
|
|
51
|
+
if (x === '?' && y === '?') untracked++;
|
|
52
|
+
else {
|
|
53
|
+
if (x !== ' ' && x !== '?') staged++;
|
|
54
|
+
if (y !== ' ') dirty++;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const lastHash = tryGit('git rev-parse --short HEAD', workingDir);
|
|
59
|
+
const lastMsg = tryGit('git log -1 --pretty=%s', workingDir);
|
|
60
|
+
const remoteUrl = tryGit('git config --get remote.origin.url', workingDir);
|
|
61
|
+
const gitDir = tryGit('git rev-parse --git-dir', workingDir);
|
|
62
|
+
const commonDir = tryGit('git rev-parse --git-common-dir', workingDir);
|
|
63
|
+
const isWorktree = gitDir !== '' && commonDir !== '' && gitDir !== commonDir && gitDir !== '.git';
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
isGitRepo: true,
|
|
67
|
+
branch,
|
|
68
|
+
dirtyCount: dirty,
|
|
69
|
+
untrackedCount: untracked,
|
|
70
|
+
stagedCount: staged,
|
|
71
|
+
lastCommitHash: lastHash,
|
|
72
|
+
lastCommitMessage: lastMsg,
|
|
73
|
+
hasRemote: remoteUrl !== '',
|
|
74
|
+
remoteUrl: remoteUrl || undefined,
|
|
75
|
+
isWorktree,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function formatGitContextForPrompt(ctx: GitContext): string {
|
|
80
|
+
if (!ctx.isGitRepo) return '';
|
|
81
|
+
const parts: string[] = [];
|
|
82
|
+
parts.push(`Branch: ${ctx.branch}${ctx.isWorktree ? ' (worktree)' : ''}`);
|
|
83
|
+
parts.push(`Status: ${ctx.stagedCount} staged, ${ctx.dirtyCount} modified, ${ctx.untrackedCount} untracked`);
|
|
84
|
+
if (ctx.lastCommitHash) parts.push(`Last commit: ${ctx.lastCommitHash} ${ctx.lastCommitMessage}`);
|
|
85
|
+
if (ctx.remoteUrl) parts.push(`Remote: ${ctx.remoteUrl}`);
|
|
86
|
+
return `## Git\n${parts.join('\n')}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export const GIT_TOOLS: ToolDefinition[] = [
|
|
90
|
+
{
|
|
91
|
+
name: 'git_status',
|
|
92
|
+
description: 'Show the current git status: branch, modified files, staged files, untracked files.',
|
|
93
|
+
parameters: { type: 'object', properties: {} },
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
name: 'git_diff',
|
|
97
|
+
description: 'Show the git diff for staged or unstaged changes. Optionally filter to a single file.',
|
|
98
|
+
parameters: {
|
|
99
|
+
type: 'object',
|
|
100
|
+
properties: {
|
|
101
|
+
path: { type: 'string', description: 'File path to diff (optional)' },
|
|
102
|
+
staged: { type: 'boolean', description: 'Show staged changes only (default false)' },
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
name: 'git_commit',
|
|
108
|
+
description: 'Create a git commit. Stages the listed files first; errors if no files provided.',
|
|
109
|
+
parameters: {
|
|
110
|
+
type: 'object',
|
|
111
|
+
properties: {
|
|
112
|
+
message: { type: 'string', description: 'Commit message' },
|
|
113
|
+
files: { type: 'array', items: { type: 'string' }, description: 'Files to stage before committing' },
|
|
114
|
+
},
|
|
115
|
+
required: ['message', 'files'],
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
name: 'git_log',
|
|
120
|
+
description: 'Show recent git log entries.',
|
|
121
|
+
parameters: {
|
|
122
|
+
type: 'object',
|
|
123
|
+
properties: {
|
|
124
|
+
count: { type: 'number', description: 'Number of commits to show (default 10)' },
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
name: 'git_branch',
|
|
130
|
+
description: 'List branches, or create/switch to a branch.',
|
|
131
|
+
parameters: {
|
|
132
|
+
type: 'object',
|
|
133
|
+
properties: {
|
|
134
|
+
create: { type: 'string', description: 'Create and switch to this new branch' },
|
|
135
|
+
switch: { type: 'string', description: 'Switch to this existing branch' },
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
name: 'git_create_pr',
|
|
141
|
+
description: 'Create a GitHub pull request for the current branch via the gh CLI.',
|
|
142
|
+
parameters: {
|
|
143
|
+
type: 'object',
|
|
144
|
+
properties: {
|
|
145
|
+
title: { type: 'string', description: 'PR title' },
|
|
146
|
+
body: { type: 'string', description: 'PR body' },
|
|
147
|
+
base: { type: 'string', description: 'Base branch (default main)' },
|
|
148
|
+
draft: { type: 'boolean', description: 'Create as draft PR' },
|
|
149
|
+
},
|
|
150
|
+
required: ['title'],
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
];
|
|
154
|
+
|
|
155
|
+
export async function executeGitTool(
|
|
156
|
+
name: string,
|
|
157
|
+
args: Record<string, unknown>,
|
|
158
|
+
workingDir: string,
|
|
159
|
+
ctx: GitContext,
|
|
160
|
+
): Promise<{ content: string; isError?: boolean; diff?: string }> {
|
|
161
|
+
if (!ctx.isGitRepo) {
|
|
162
|
+
return { content: 'Not a git repository', isError: true };
|
|
163
|
+
}
|
|
164
|
+
try {
|
|
165
|
+
switch (name) {
|
|
166
|
+
case 'git_status': {
|
|
167
|
+
const out = gitQ('git status --short --branch', workingDir);
|
|
168
|
+
return { content: out || '(clean)' };
|
|
169
|
+
}
|
|
170
|
+
case 'git_diff': {
|
|
171
|
+
const path = args.path ? ` -- ${JSON.stringify(String(args.path))}` : '';
|
|
172
|
+
const staged = args.staged ? ' --cached' : '';
|
|
173
|
+
const out = gitQ(`git diff${staged}${path}`, workingDir);
|
|
174
|
+
if (!out) return { content: '(no changes)' };
|
|
175
|
+
const truncated = out.length > 5000 ? out.slice(0, 5000) + '\n... (truncated)' : out;
|
|
176
|
+
return { content: truncated, diff: truncated };
|
|
177
|
+
}
|
|
178
|
+
case 'git_log': {
|
|
179
|
+
const count = Math.max(1, Math.min(50, (args.count as number) || 10));
|
|
180
|
+
const out = gitQ(`git log -n ${count} --oneline --decorate`, workingDir);
|
|
181
|
+
return { content: out || '(no commits)' };
|
|
182
|
+
}
|
|
183
|
+
case 'git_branch': {
|
|
184
|
+
const create = args.create as string | undefined;
|
|
185
|
+
const sw = args.switch as string | undefined;
|
|
186
|
+
if (create) {
|
|
187
|
+
if (!/^[\w./-]+$/.test(create)) return { content: `Invalid branch name: ${create}`, isError: true };
|
|
188
|
+
gitQ(`git checkout -b ${JSON.stringify(create)}`, workingDir);
|
|
189
|
+
return { content: `Created and switched to ${create}` };
|
|
190
|
+
}
|
|
191
|
+
if (sw) {
|
|
192
|
+
if (!/^[\w./-]+$/.test(sw)) return { content: `Invalid branch name: ${sw}`, isError: true };
|
|
193
|
+
gitQ(`git checkout ${JSON.stringify(sw)}`, workingDir);
|
|
194
|
+
return { content: `Switched to ${sw}` };
|
|
195
|
+
}
|
|
196
|
+
const out = gitQ('git branch -vv', workingDir);
|
|
197
|
+
return { content: out };
|
|
198
|
+
}
|
|
199
|
+
case 'git_commit': {
|
|
200
|
+
const message = String(args.message || '').trim();
|
|
201
|
+
if (!message) return { content: 'Empty commit message', isError: true };
|
|
202
|
+
if (message.split('\n')[0].length > 500) {
|
|
203
|
+
return { content: 'Commit subject exceeds 500 characters', isError: true };
|
|
204
|
+
}
|
|
205
|
+
const files = args.files as string[] | undefined;
|
|
206
|
+
if (!files || files.length === 0) {
|
|
207
|
+
return { content: 'git_commit requires a non-empty `files` list', isError: true };
|
|
208
|
+
}
|
|
209
|
+
// Stage explicitly listed files (deletions included).
|
|
210
|
+
const quoted = files.map(f => JSON.stringify(f)).join(' ');
|
|
211
|
+
gitQ(`git add -- ${quoted}`, workingDir);
|
|
212
|
+
// Use --cleanup=strip and -F - via env-free execSync: pass message via -m (single arg, escaped).
|
|
213
|
+
gitQ(`git commit -m ${JSON.stringify(message)}`, workingDir);
|
|
214
|
+
const hash = tryGit('git rev-parse --short HEAD', workingDir);
|
|
215
|
+
return { content: `Committed ${hash}: ${message.split('\n')[0]}` };
|
|
216
|
+
}
|
|
217
|
+
case 'git_create_pr': {
|
|
218
|
+
if (ctx.branch === 'main' || ctx.branch === 'master') {
|
|
219
|
+
return { content: `Refusing to PR from ${ctx.branch}. Create a feature branch first.`, isError: true };
|
|
220
|
+
}
|
|
221
|
+
try { gitQ('gh --version', workingDir); }
|
|
222
|
+
catch { return { content: 'gh CLI not found. Install from https://cli.github.com/', isError: true }; }
|
|
223
|
+
const title = String(args.title || '').trim();
|
|
224
|
+
if (!title) return { content: 'PR title required', isError: true };
|
|
225
|
+
const body = String(args.body || '');
|
|
226
|
+
const base = String(args.base || 'main');
|
|
227
|
+
const draft = args.draft ? ' --draft' : '';
|
|
228
|
+
const out = gitQ(
|
|
229
|
+
`gh pr create --title ${JSON.stringify(title)} --body ${JSON.stringify(body)} --base ${JSON.stringify(base)}${draft}`,
|
|
230
|
+
workingDir,
|
|
231
|
+
);
|
|
232
|
+
return { content: out };
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return { content: `Unknown git tool: ${name}`, isError: true };
|
|
236
|
+
} catch (e) {
|
|
237
|
+
const err = e as any;
|
|
238
|
+
const stderr = err.stderr?.toString() || '';
|
|
239
|
+
const stdout = err.stdout?.toString() || '';
|
|
240
|
+
return { content: `${err.message}\n${stderr || stdout}`.trim(), isError: true };
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// `computeUnifiedDiff` is re-exported for callers that think of git diffs
|
|
245
|
+
// as a single namespace. The canonical implementation lives in ./diff.ts.
|
|
246
|
+
export { computeUnifiedDiff };
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hooks — run shell commands or tool calls before/after any agent tool.
|
|
3
|
+
*
|
|
4
|
+
* Config lives at `.kondi-chat/hooks.json`. Keys: `before_<tool>` / `after_<tool>`.
|
|
5
|
+
* Each hook is either a shorthand shell command string, a shell object, or a
|
|
6
|
+
* tool-call object. Before-hooks can block execution; after-hooks augment the
|
|
7
|
+
* tool result.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { execSync } from 'node:child_process';
|
|
11
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import type { ToolContext, ToolExecutionResult } from './tools.ts';
|
|
14
|
+
|
|
15
|
+
export type HookFailureMode = 'block' | 'warn' | 'ignore';
|
|
16
|
+
|
|
17
|
+
export type HookDefinition =
|
|
18
|
+
| string
|
|
19
|
+
| {
|
|
20
|
+
type?: 'shell' | 'tool';
|
|
21
|
+
command?: string;
|
|
22
|
+
tool?: string;
|
|
23
|
+
args?: Record<string, unknown>;
|
|
24
|
+
onFailure?: HookFailureMode;
|
|
25
|
+
timeoutMs?: number;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export interface HooksConfig {
|
|
29
|
+
hooks?: Record<string, HookDefinition | HookDefinition[]>;
|
|
30
|
+
builtin?: { autoFormat?: boolean };
|
|
31
|
+
defaultFailureMode?: HookFailureMode;
|
|
32
|
+
defaultTimeoutMs?: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
36
|
+
const DEFAULT_FAILURE_MODE: HookFailureMode = 'warn';
|
|
37
|
+
const MAX_HOOK_DEPTH = 3;
|
|
38
|
+
|
|
39
|
+
type ToolExecutor = (name: string, args: Record<string, unknown>, ctx: ToolContext) => Promise<ToolExecutionResult>;
|
|
40
|
+
|
|
41
|
+
function shellQuote(s: string): string {
|
|
42
|
+
// Single-quote everything, escape embedded single quotes.
|
|
43
|
+
return `'${String(s).replace(/'/g, `'\\''`)}'`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function interpolate(template: string, vars: Record<string, unknown>): string {
|
|
47
|
+
return template.replace(/\{(\w+)\}/g, (_m, key) => {
|
|
48
|
+
const v = vars[key];
|
|
49
|
+
if (v === undefined || v === null) return '';
|
|
50
|
+
return shellQuote(String(v));
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export class HookRunner {
|
|
55
|
+
private config: HooksConfig;
|
|
56
|
+
private workingDir: string;
|
|
57
|
+
private toolExecutor: ToolExecutor | null = null;
|
|
58
|
+
private depth = 0;
|
|
59
|
+
|
|
60
|
+
constructor(configPath: string, workingDir: string) {
|
|
61
|
+
this.workingDir = workingDir;
|
|
62
|
+
this.config = loadConfig(configPath);
|
|
63
|
+
if (this.config.builtin?.autoFormat) this.installBuiltinFormatters();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
setToolExecutor(fn: ToolExecutor): void { this.toolExecutor = fn; }
|
|
67
|
+
|
|
68
|
+
async runBefore(tool: string, args: Record<string, unknown>, ctx: ToolContext, emit?: (e: any) => void): Promise<{ blocked: boolean; messages: string[] }> {
|
|
69
|
+
const hooks = this.getHooks(`before_${tool}`);
|
|
70
|
+
if (hooks.length === 0 || this.depth >= MAX_HOOK_DEPTH) return { blocked: false, messages: [] };
|
|
71
|
+
const messages: string[] = [];
|
|
72
|
+
for (const hook of hooks) {
|
|
73
|
+
const outcome = await this.runHook(hook, tool, args, undefined, ctx, emit);
|
|
74
|
+
if (outcome.blocked) return { blocked: true, messages: [outcome.message] };
|
|
75
|
+
if (outcome.message) messages.push(outcome.message);
|
|
76
|
+
}
|
|
77
|
+
return { blocked: false, messages };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async runAfter(
|
|
81
|
+
tool: string,
|
|
82
|
+
args: Record<string, unknown>,
|
|
83
|
+
result: ToolExecutionResult,
|
|
84
|
+
ctx: ToolContext,
|
|
85
|
+
emit?: (e: any) => void,
|
|
86
|
+
): Promise<ToolExecutionResult> {
|
|
87
|
+
const hooks = this.getHooks(`after_${tool}`);
|
|
88
|
+
if (hooks.length === 0 || this.depth >= MAX_HOOK_DEPTH) return result;
|
|
89
|
+
let out = { ...result };
|
|
90
|
+
for (const hook of hooks) {
|
|
91
|
+
const outcome = await this.runHook(hook, tool, args, out, ctx, emit);
|
|
92
|
+
if (outcome.blocked) {
|
|
93
|
+
out = { ...out, isError: true, content: `${out.content}\n[after-hook blocked] ${outcome.message}` };
|
|
94
|
+
} else if (outcome.message) {
|
|
95
|
+
out = { ...out, content: `${out.content}\n[hook] ${outcome.message}` };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private getHooks(key: string): HookDefinition[] {
|
|
102
|
+
const raw = this.config.hooks?.[key];
|
|
103
|
+
if (!raw) return [];
|
|
104
|
+
return Array.isArray(raw) ? raw : [raw];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private async runHook(
|
|
108
|
+
hook: HookDefinition,
|
|
109
|
+
tool: string,
|
|
110
|
+
args: Record<string, unknown>,
|
|
111
|
+
result: ToolExecutionResult | undefined,
|
|
112
|
+
ctx: ToolContext,
|
|
113
|
+
emit?: (e: any) => void,
|
|
114
|
+
): Promise<{ blocked: boolean; message: string }> {
|
|
115
|
+
const normalized = typeof hook === 'string' ? { type: 'shell' as const, command: hook } : hook;
|
|
116
|
+
const onFailure: HookFailureMode = normalized.onFailure ?? this.config.defaultFailureMode ?? DEFAULT_FAILURE_MODE;
|
|
117
|
+
const timeoutMs = normalized.timeoutMs ?? this.config.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
118
|
+
|
|
119
|
+
const vars: Record<string, unknown> = { ...args, cwd: this.workingDir };
|
|
120
|
+
if (result) vars.result = result.content.slice(0, 2000);
|
|
121
|
+
|
|
122
|
+
const started = Date.now();
|
|
123
|
+
try {
|
|
124
|
+
if (normalized.type === 'tool' || (!normalized.type && normalized.tool)) {
|
|
125
|
+
if (!this.toolExecutor) throw new Error('Tool executor not wired');
|
|
126
|
+
const toolName = normalized.tool!;
|
|
127
|
+
this.depth++;
|
|
128
|
+
try { await this.toolExecutor(toolName, normalized.args || {}, ctx); }
|
|
129
|
+
finally { this.depth--; }
|
|
130
|
+
emit?.({ type: 'activity', text: `${toolName} (${Date.now() - started}ms)`, activity_type: 'hook' });
|
|
131
|
+
return { blocked: false, message: `tool:${toolName}` };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const template = normalized.command || (typeof hook === 'string' ? hook : '');
|
|
135
|
+
if (!template) return { blocked: false, message: '' };
|
|
136
|
+
const command = interpolate(template, vars);
|
|
137
|
+
execSync(command, {
|
|
138
|
+
cwd: this.workingDir,
|
|
139
|
+
encoding: 'utf-8',
|
|
140
|
+
timeout: timeoutMs,
|
|
141
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
142
|
+
});
|
|
143
|
+
emit?.({ type: 'activity', text: `${tool} (${Date.now() - started}ms)`, activity_type: 'hook' });
|
|
144
|
+
return { blocked: false, message: `ok (${Date.now() - started}ms)` };
|
|
145
|
+
} catch (e) {
|
|
146
|
+
const msg = (e as Error).message;
|
|
147
|
+
if (onFailure === 'block') return { blocked: true, message: `blocked: ${msg}` };
|
|
148
|
+
if (onFailure === 'warn') return { blocked: false, message: `warn: ${msg}` };
|
|
149
|
+
return { blocked: false, message: '' };
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private installBuiltinFormatters(): void {
|
|
154
|
+
const wd = this.workingDir;
|
|
155
|
+
const hooks: Record<string, HookDefinition | HookDefinition[]> = { ...(this.config.hooks || {}) };
|
|
156
|
+
const add = (key: string, cmd: string) => {
|
|
157
|
+
if (!hooks[key]) hooks[key] = cmd;
|
|
158
|
+
};
|
|
159
|
+
if (existsSync(join(wd, '.prettierrc')) || existsSync(join(wd, '.prettierrc.json'))) {
|
|
160
|
+
add('after_write_file', 'npx prettier --write {path}');
|
|
161
|
+
add('after_edit_file', 'npx prettier --write {path}');
|
|
162
|
+
} else if (existsSync(join(wd, 'pyproject.toml'))) {
|
|
163
|
+
add('after_write_file', 'black {path}');
|
|
164
|
+
add('after_edit_file', 'black {path}');
|
|
165
|
+
} else if (existsSync(join(wd, 'Cargo.toml'))) {
|
|
166
|
+
add('after_write_file', 'rustfmt {path}');
|
|
167
|
+
add('after_edit_file', 'rustfmt {path}');
|
|
168
|
+
}
|
|
169
|
+
this.config.hooks = hooks;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function loadConfig(path: string): HooksConfig {
|
|
174
|
+
if (!existsSync(path)) return {};
|
|
175
|
+
try {
|
|
176
|
+
return JSON.parse(readFileSync(path, 'utf-8')) as HooksConfig;
|
|
177
|
+
} catch (e) {
|
|
178
|
+
process.stderr.write(`[hooks] failed to parse ${path}: ${(e as Error).message}\n`);
|
|
179
|
+
return {};
|
|
180
|
+
}
|
|
181
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loop Guard — prevents runaway costs in autonomous loops.
|
|
3
|
+
*
|
|
4
|
+
* Tracks:
|
|
5
|
+
* - Iteration count vs cap
|
|
6
|
+
* - Cumulative cost vs budget
|
|
7
|
+
* - Error deduplication (same error twice = stuck)
|
|
8
|
+
* - Diminishing returns (no progress between iterations)
|
|
9
|
+
*
|
|
10
|
+
* Used by the regular agent loop inside handleSubmit and by the autonomous
|
|
11
|
+
* /loop command which runs handleSubmit with opts.loop = true (so the loop
|
|
12
|
+
* does not stop at the first no-tool-call response).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { BudgetProfile } from '../router/profiles.ts';
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Types
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
export interface LoopStatus {
|
|
22
|
+
iteration: number;
|
|
23
|
+
maxIterations: number;
|
|
24
|
+
costUsd: number;
|
|
25
|
+
costCap: number;
|
|
26
|
+
lastErrors: string[];
|
|
27
|
+
stuck: boolean;
|
|
28
|
+
shouldStop: boolean;
|
|
29
|
+
stopReason?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// Loop Guard
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
export class LoopGuard {
|
|
37
|
+
private iteration = 0;
|
|
38
|
+
private costUsd = 0;
|
|
39
|
+
private recentErrors: string[] = [];
|
|
40
|
+
private errorCounts: Map<string, number> = new Map();
|
|
41
|
+
private lastOutputHash = '';
|
|
42
|
+
|
|
43
|
+
private maxIterations: number;
|
|
44
|
+
private costCap: number;
|
|
45
|
+
|
|
46
|
+
constructor(profile: BudgetProfile) {
|
|
47
|
+
this.maxIterations = profile.loopIterationCap;
|
|
48
|
+
this.costCap = profile.loopCostCap;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Record a completed iteration */
|
|
52
|
+
recordIteration(cost: number, error?: string, outputHash?: string): void {
|
|
53
|
+
this.iteration++;
|
|
54
|
+
this.costUsd += cost;
|
|
55
|
+
|
|
56
|
+
if (error) {
|
|
57
|
+
// Normalize error for dedup (strip line numbers, timestamps)
|
|
58
|
+
const normalized = this.normalizeError(error);
|
|
59
|
+
this.recentErrors.push(normalized);
|
|
60
|
+
if (this.recentErrors.length > 5) this.recentErrors.shift();
|
|
61
|
+
|
|
62
|
+
const count = (this.errorCounts.get(normalized) || 0) + 1;
|
|
63
|
+
this.errorCounts.set(normalized, count);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (outputHash) {
|
|
67
|
+
this.lastOutputHash = outputHash;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Check if the loop should continue */
|
|
72
|
+
check(): LoopStatus {
|
|
73
|
+
const status: LoopStatus = {
|
|
74
|
+
iteration: this.iteration,
|
|
75
|
+
maxIterations: this.maxIterations,
|
|
76
|
+
costUsd: this.costUsd,
|
|
77
|
+
costCap: this.costCap,
|
|
78
|
+
lastErrors: [...this.recentErrors.slice(-3)],
|
|
79
|
+
stuck: false,
|
|
80
|
+
shouldStop: false,
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
// Iteration cap
|
|
84
|
+
if (this.iteration >= this.maxIterations) {
|
|
85
|
+
status.shouldStop = true;
|
|
86
|
+
status.stopReason = `iteration limit (${this.maxIterations})`;
|
|
87
|
+
return status;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Cost cap
|
|
91
|
+
if (this.costUsd >= this.costCap) {
|
|
92
|
+
status.shouldStop = true;
|
|
93
|
+
status.stopReason = `cost limit ($${this.costCap.toFixed(2)}, spent $${this.costUsd.toFixed(4)})`;
|
|
94
|
+
return status;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Error deduplication — same error 3+ times means stuck
|
|
98
|
+
for (const [error, count] of this.errorCounts) {
|
|
99
|
+
if (count >= 3) {
|
|
100
|
+
status.stuck = true;
|
|
101
|
+
status.shouldStop = true;
|
|
102
|
+
status.stopReason = `stuck on repeated error (${count}x): ${error.slice(0, 100)}`;
|
|
103
|
+
return status;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Same error back-to-back
|
|
108
|
+
if (this.recentErrors.length >= 2) {
|
|
109
|
+
const last = this.recentErrors[this.recentErrors.length - 1];
|
|
110
|
+
const prev = this.recentErrors[this.recentErrors.length - 2];
|
|
111
|
+
if (last === prev) {
|
|
112
|
+
status.stuck = true;
|
|
113
|
+
status.shouldStop = true;
|
|
114
|
+
status.stopReason = `same error repeated: ${last.slice(0, 100)}`;
|
|
115
|
+
return status;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return status;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Get a summary for display */
|
|
123
|
+
getSummary(): string {
|
|
124
|
+
const status = this.check();
|
|
125
|
+
return [
|
|
126
|
+
`Iteration ${status.iteration}/${status.maxIterations}`,
|
|
127
|
+
`Cost: $${status.costUsd.toFixed(4)} / $${status.costCap.toFixed(2)}`,
|
|
128
|
+
status.stuck ? 'STUCK — same error repeating' : '',
|
|
129
|
+
status.shouldStop ? `Stopped: ${status.stopReason}` : 'Running',
|
|
130
|
+
].filter(Boolean).join(' | ');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Reset for a new loop */
|
|
134
|
+
reset(): void {
|
|
135
|
+
this.iteration = 0;
|
|
136
|
+
this.costUsd = 0;
|
|
137
|
+
this.recentErrors = [];
|
|
138
|
+
this.errorCounts.clear();
|
|
139
|
+
this.lastOutputHash = '';
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// -------------------------------------------------------------------------
|
|
143
|
+
// Helpers
|
|
144
|
+
// -------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
private normalizeError(error: string): string {
|
|
147
|
+
return error
|
|
148
|
+
.replace(/line \d+/g, 'line N')
|
|
149
|
+
.replace(/\d{4}-\d{2}-\d{2}/g, 'DATE')
|
|
150
|
+
.replace(/\d+:\d+:\d+/g, 'TIME')
|
|
151
|
+
.replace(/0x[a-f0-9]+/gi, '0xADDR')
|
|
152
|
+
.trim()
|
|
153
|
+
.slice(0, 300);
|
|
154
|
+
}
|
|
155
|
+
}
|