klyro 1.0.17 → 1.0.18
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/dist/agent/runtime.d.ts +7 -0
- package/dist/agent/runtime.js +41 -1
- package/dist/checkpoints/store.d.ts +15 -1
- package/dist/checkpoints/store.js +7 -2
- package/dist/cli/args.d.ts +7 -0
- package/dist/cli/args.js +10 -0
- package/dist/cli/eval.d.ts +32 -0
- package/dist/cli/eval.js +82 -3
- package/dist/cli/run.d.ts +5 -0
- package/dist/cli/run.js +4 -0
- package/dist/context/klyro-md.d.ts +11 -0
- package/dist/context/klyro-md.js +59 -4
- package/dist/eval/baseline.d.ts +47 -0
- package/dist/eval/baseline.js +55 -0
- package/dist/eval/harness.d.ts +14 -2
- package/dist/eval/harness.js +40 -2
- package/dist/index.js +5 -2
- package/dist/tools/git/git-blame.d.ts +5 -0
- package/dist/tools/git/git-blame.js +22 -0
- package/dist/tools/git/git-diff.js +3 -20
- package/dist/tools/git/git-log.js +5 -19
- package/dist/tools/git/git-show.d.ts +5 -0
- package/dist/tools/git/git-show.js +22 -0
- package/dist/tools/git/git-status.js +4 -21
- package/dist/tools/git/run-git.d.ts +5 -0
- package/dist/tools/git/run-git.js +18 -0
- package/dist/tools/registry.js +4 -0
- package/dist/tools/search/glob.js +8 -3
- package/dist/tools/search/grep.js +10 -3
- package/dist/tools/search/ignore.d.ts +8 -0
- package/dist/tools/search/ignore.js +49 -0
- package/dist/tools/search/search-files.js +10 -3
- package/dist/tui/app.js +37 -1
- package/dist/tui/app.test.js +17 -3
- package/dist/tui/transcript-commands.d.ts +14 -0
- package/dist/tui/transcript-commands.js +31 -0
- package/package.json +1 -1
package/dist/eval/harness.js
CHANGED
|
@@ -177,10 +177,46 @@ export async function loadFileFixture(dir) {
|
|
|
177
177
|
catch { /* no script → static fixture */ }
|
|
178
178
|
return { dir, task: task.trim(), checkSh, meta, ...(repo ? { repo } : {}), ...(script ? { script } : {}) };
|
|
179
179
|
}
|
|
180
|
+
/**
|
|
181
|
+
* 5.4b — sanitized env for `check.sh`: PATH + home/locale + temp vars
|
|
182
|
+
* (+ Windows shell vars so the shell can spawn). Everything else —
|
|
183
|
+
* secrets, `KLYRO_*` config, caller sentinels — is dropped. Exported for tests.
|
|
184
|
+
*/
|
|
185
|
+
const FIXTURE_ENV_ALLOW = new Set([
|
|
186
|
+
'PATH', 'PATHEXT', 'HOME', 'LANG', 'LC_ALL', 'TMPDIR', 'TEMP', 'TMP',
|
|
187
|
+
'SYSTEMROOT', 'SYSTEMDRIVE', 'COMSPEC',
|
|
188
|
+
]);
|
|
189
|
+
export function sanitizedFixtureEnv(base = process.env) {
|
|
190
|
+
const out = {};
|
|
191
|
+
for (const k of FIXTURE_ENV_ALLOW) {
|
|
192
|
+
const v = base[k];
|
|
193
|
+
if (v !== undefined)
|
|
194
|
+
out[k] = v;
|
|
195
|
+
}
|
|
196
|
+
return out;
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* 5.4b — guard: fixture workdirs must stay inside `os.tmpdir()` (the
|
|
200
|
+
* harness relies on that isolation). Throws otherwise unless the caller
|
|
201
|
+
* explicitly passes `allowWorkdirOutsideTmp`. Exported for tests.
|
|
202
|
+
*/
|
|
203
|
+
export function assertTmpWorkdir(dir, opts = {}) {
|
|
204
|
+
if (opts.allowWorkdirOutsideTmp)
|
|
205
|
+
return;
|
|
206
|
+
const root = path.resolve(os.tmpdir());
|
|
207
|
+
const resolved = path.resolve(dir);
|
|
208
|
+
if (resolved !== root && !resolved.startsWith(root + path.sep)) {
|
|
209
|
+
throw new Error(`fixture workdir escapes os.tmpdir(): ${resolved} (pass allowWorkdirOutsideTmp to override)`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
180
212
|
export async function runFileFixture(fixture, _opts = {}) {
|
|
181
213
|
const start = Date.now();
|
|
182
214
|
const tmp = path.join(os.tmpdir(), 'klyro-eval-file-' + Math.random().toString(36).slice(2));
|
|
215
|
+
assertTmpWorkdir(tmp, _opts);
|
|
183
216
|
await fs.mkdir(tmp, { recursive: true });
|
|
217
|
+
// Restrictive creation (umask-style): the tmp workdir is owner-only.
|
|
218
|
+
// Best-effort — chmod is a no-op for ACLs on Windows but harmless.
|
|
219
|
+
await fs.chmod(tmp, 0o700).catch(() => undefined);
|
|
184
220
|
// Copy repo if exists
|
|
185
221
|
if (fixture.repo) {
|
|
186
222
|
const src = path.isAbsolute(fixture.repo) ? fixture.repo : path.join(fixture.dir, fixture.repo);
|
|
@@ -192,7 +228,7 @@ export async function runFileFixture(fixture, _opts = {}) {
|
|
|
192
228
|
// Run check.sh via shell
|
|
193
229
|
const { spawn } = await import('node:child_process');
|
|
194
230
|
const result = await new Promise((resolve) => {
|
|
195
|
-
const child = spawn('bash', ['-c', fixture.checkSh], { cwd: tmp, shell: false });
|
|
231
|
+
const child = spawn('bash', ['-c', fixture.checkSh], { cwd: tmp, shell: false, env: sanitizedFixtureEnv() });
|
|
196
232
|
const sink = cappedOutput(MAX_CHECK_BYTES);
|
|
197
233
|
child.stdout?.on('data', (b) => sink.push(b));
|
|
198
234
|
child.stderr?.on('data', (b) => sink.push(b));
|
|
@@ -226,7 +262,9 @@ export async function runAgentFixture(fixture, opts = {}) {
|
|
|
226
262
|
return { id, status: 'fail', details: 'agent fixture needs script.json', durationMs: Date.now() - start };
|
|
227
263
|
}
|
|
228
264
|
const tmp = path.join(os.tmpdir(), 'klyro-eval-agent-' + Math.random().toString(36).slice(2));
|
|
265
|
+
assertTmpWorkdir(tmp, opts);
|
|
229
266
|
await fs.mkdir(tmp, { recursive: true });
|
|
267
|
+
await fs.chmod(tmp, 0o700).catch(() => undefined);
|
|
230
268
|
try {
|
|
231
269
|
if (fixture.repo) {
|
|
232
270
|
const src = path.isAbsolute(fixture.repo) ? fixture.repo : path.join(fixture.dir, fixture.repo);
|
|
@@ -250,7 +288,7 @@ export async function runAgentFixture(fixture, opts = {}) {
|
|
|
250
288
|
// Structural pass — now assert real filesystem outcomes.
|
|
251
289
|
const { spawn } = await import('node:child_process');
|
|
252
290
|
const out = await new Promise((resolve) => {
|
|
253
|
-
const child = spawn('bash', ['-c', fixture.checkSh], { cwd: tmp, shell: false });
|
|
291
|
+
const child = spawn('bash', ['-c', fixture.checkSh], { cwd: tmp, shell: false, env: sanitizedFixtureEnv() });
|
|
254
292
|
const sink = cappedOutput(MAX_CHECK_BYTES);
|
|
255
293
|
child.stdout?.on('data', (b) => sink.push(b));
|
|
256
294
|
child.stderr?.on('data', (b) => sink.push(b));
|
package/dist/index.js
CHANGED
|
@@ -350,6 +350,7 @@ async function main() {
|
|
|
350
350
|
.option('--agent <name>', 'Run under an orchestrator context enabling spawn_agent/task_list/task_get (explorer|implementer|tester|reviewer)')
|
|
351
351
|
.option('--max-depth <n>', 'Max spawn depth for child agents (default 1)', (v) => parsePositiveInt('--max-depth', v))
|
|
352
352
|
.option('--bare', 'Deterministic runs: skip MCP, hooks, memory/KLYRO.md/context, persistence')
|
|
353
|
+
.option('--auto-answer <text>', 'Headless answer for ask_user prompts (sets KLYRO_AUTO_ANSWER)')
|
|
353
354
|
.action(async (prompt, opts) => {
|
|
354
355
|
const model = opts.model ?? process.env.KLYRO_MODEL;
|
|
355
356
|
if (!model) {
|
|
@@ -410,6 +411,7 @@ async function main() {
|
|
|
410
411
|
agent: opts.agent,
|
|
411
412
|
maxDepth: opts.maxDepth,
|
|
412
413
|
bare: !!opts.bare,
|
|
414
|
+
autoAnswer: opts.autoAnswer,
|
|
413
415
|
});
|
|
414
416
|
process.exit(code);
|
|
415
417
|
}
|
|
@@ -443,17 +445,18 @@ async function main() {
|
|
|
443
445
|
.option('--model <id>', 'Model for eval')
|
|
444
446
|
.option('--judge-model <id>', 'Live model id for grading judge.rubric (needs endpoint + key)')
|
|
445
447
|
.option('--cwd <path>', 'Shared scenario workdir (default: isolated tmp per scenario)')
|
|
448
|
+
.option('--auto-answer <text>', 'Headless answer for ask_user prompts (sets KLYRO_AUTO_ANSWER)')
|
|
446
449
|
.action(async (input, opts) => {
|
|
447
450
|
const output = (opts.output ?? 'human');
|
|
448
451
|
if (opts.suite) {
|
|
449
|
-
const code = await runEval({ inputPath: input ?? '-', output, suite: opts.suite, filter: opts.filter, runs: opts.runs, parallel: opts.parallel, model: opts.model, judgeModel: opts.judgeModel });
|
|
452
|
+
const code = await runEval({ inputPath: input ?? '-', output, suite: opts.suite, filter: opts.filter, runs: opts.runs, parallel: opts.parallel, model: opts.model, judgeModel: opts.judgeModel, cwd: opts.cwd, autoAnswer: opts.autoAnswer });
|
|
450
453
|
process.exit(code);
|
|
451
454
|
}
|
|
452
455
|
if (!input) {
|
|
453
456
|
process.stderr.write('klyro eval: missing input (provide <input> or --suite)\n');
|
|
454
457
|
process.exit(2);
|
|
455
458
|
}
|
|
456
|
-
const code = await runEval({ inputPath: input, output, suite: opts.suite, filter: opts.filter, runs: opts.runs, parallel: opts.parallel, model: opts.model, judgeModel: opts.judgeModel, cwd: opts.cwd });
|
|
459
|
+
const code = await runEval({ inputPath: input, output, suite: opts.suite, filter: opts.filter, runs: opts.runs, parallel: opts.parallel, model: opts.model, judgeModel: opts.judgeModel, cwd: opts.cwd, autoAnswer: opts.autoAnswer });
|
|
457
460
|
process.exit(code);
|
|
458
461
|
});
|
|
459
462
|
program
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { defineTool } from '../types.js';
|
|
3
|
+
import { safe } from '../normalize.js';
|
|
4
|
+
import { runGit } from './run-git.js';
|
|
5
|
+
const InputSchema = z.object({
|
|
6
|
+
path: z.string().min(1).describe('File path to blame'),
|
|
7
|
+
});
|
|
8
|
+
export const gitBlameTool = defineTool({
|
|
9
|
+
name: 'git_blame',
|
|
10
|
+
description: 'Show git blame for a file, line by line. Read-only.',
|
|
11
|
+
inputSchema: InputSchema,
|
|
12
|
+
permission: 'read',
|
|
13
|
+
isConcurrencySafe: true,
|
|
14
|
+
execute: async (input, ctx) => {
|
|
15
|
+
return safe(async () => {
|
|
16
|
+
const res = await runGit(['blame', '--', input.path], ctx.cwd);
|
|
17
|
+
if (res.code !== 0)
|
|
18
|
+
throw new Error(res.err || `git blame failed`);
|
|
19
|
+
return { blame: res.out };
|
|
20
|
+
});
|
|
21
|
+
},
|
|
22
|
+
});
|
|
@@ -2,31 +2,14 @@
|
|
|
2
2
|
* git_diff — show unstaged (`git diff`) or staged (`git diff --cached`)
|
|
3
3
|
* changes plus a compact per-file stat. Read-only.
|
|
4
4
|
*/
|
|
5
|
-
import { spawn } from 'node:child_process';
|
|
6
5
|
import { z } from 'zod';
|
|
7
6
|
import { defineTool } from '../types.js';
|
|
8
7
|
import { safe, TOOL_ERROR_CODES } from '../normalize.js';
|
|
8
|
+
import { runGit } from './run-git.js';
|
|
9
9
|
const InputSchema = z.object({
|
|
10
10
|
staged: z.boolean().optional().describe('Show staged changes instead of unstaged (default false)'),
|
|
11
11
|
path: z.string().optional().describe('Limit to a specific path'),
|
|
12
12
|
});
|
|
13
|
-
function run(args, cwd, timeoutMs = 15_000) {
|
|
14
|
-
return new Promise((resolve) => {
|
|
15
|
-
const child = spawn('git', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, shell: false });
|
|
16
|
-
const out = [];
|
|
17
|
-
const err = [];
|
|
18
|
-
const timer = setTimeout(() => { try {
|
|
19
|
-
child.kill('SIGKILL');
|
|
20
|
-
}
|
|
21
|
-
catch { /* noop */ } }, timeoutMs);
|
|
22
|
-
child.stdout.on('data', (b) => out.push(b));
|
|
23
|
-
child.stderr.on('data', (b) => err.push(b));
|
|
24
|
-
child.on('close', (code) => {
|
|
25
|
-
clearTimeout(timer);
|
|
26
|
-
resolve({ code: code ?? 1, out: Buffer.concat(out).toString('utf-8'), err: Buffer.concat(err).toString('utf-8') });
|
|
27
|
-
});
|
|
28
|
-
});
|
|
29
|
-
}
|
|
30
13
|
export const gitDiffTool = defineTool({
|
|
31
14
|
name: 'git_diff',
|
|
32
15
|
description: 'Show the unified diff of working-tree changes (unstaged by default) or staged changes. Read-only. Returns the diff text, a per-file stat, and the list of changed file paths.',
|
|
@@ -37,7 +20,7 @@ export const gitDiffTool = defineTool({
|
|
|
37
20
|
const args = [...base, '--no-color'];
|
|
38
21
|
if (input.path)
|
|
39
22
|
args.push('--', input.path);
|
|
40
|
-
const diff = await
|
|
23
|
+
const diff = await runGit(args, ctx.cwd);
|
|
41
24
|
if (diff.code !== 0) {
|
|
42
25
|
return {
|
|
43
26
|
ok: false,
|
|
@@ -47,7 +30,7 @@ export const gitDiffTool = defineTool({
|
|
|
47
30
|
const statArgs = [...base, '--stat', '--no-color'];
|
|
48
31
|
if (input.path)
|
|
49
32
|
statArgs.push('--', input.path);
|
|
50
|
-
const stat = await
|
|
33
|
+
const stat = await runGit(statArgs, ctx.cwd);
|
|
51
34
|
// Parse changed file paths from `diff --git a/... b/...` lines.
|
|
52
35
|
const patchedFiles = [];
|
|
53
36
|
const newline = /\r?\n/;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { defineTool } from '../types.js';
|
|
3
3
|
import { safe } from '../normalize.js';
|
|
4
|
-
import {
|
|
4
|
+
import { runGit } from './run-git.js';
|
|
5
5
|
const InputSchema = z.object({
|
|
6
6
|
limit: z.number().int().min(1).max(100).optional().describe('Number of commits'),
|
|
7
7
|
path: z.string().optional().describe('Filter by path'),
|
|
@@ -17,24 +17,10 @@ export const gitLogTool = defineTool({
|
|
|
17
17
|
const args = ['log', '--oneline', `-${input.limit ?? 20}`];
|
|
18
18
|
if (input.path)
|
|
19
19
|
args.push('--', input.path);
|
|
20
|
-
const
|
|
21
|
-
|
|
20
|
+
const res = await runGit(args, ctx.cwd);
|
|
21
|
+
if (res.code !== 0)
|
|
22
|
+
throw new Error(res.err || `git log failed`);
|
|
23
|
+
return { log: res.out };
|
|
22
24
|
});
|
|
23
25
|
},
|
|
24
26
|
});
|
|
25
|
-
function runGit(args, cwd) {
|
|
26
|
-
return new Promise((resolve, reject) => {
|
|
27
|
-
const child = spawn('git', args, { cwd, shell: false, windowsHide: true });
|
|
28
|
-
let out = '';
|
|
29
|
-
let err = '';
|
|
30
|
-
child.stdout.on('data', (b) => { out += b.toString(); });
|
|
31
|
-
child.stderr.on('data', (b) => { err += b.toString(); });
|
|
32
|
-
child.on('error', reject);
|
|
33
|
-
child.on('close', (code) => {
|
|
34
|
-
if (code === 0)
|
|
35
|
-
resolve(out);
|
|
36
|
-
else
|
|
37
|
-
reject(new Error(err || `git ${args[0]} failed`));
|
|
38
|
-
});
|
|
39
|
-
});
|
|
40
|
-
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { defineTool } from '../types.js';
|
|
3
|
+
import { safe } from '../normalize.js';
|
|
4
|
+
import { runGit } from './run-git.js';
|
|
5
|
+
const InputSchema = z.object({
|
|
6
|
+
target: z.string().min(1).describe('Commit hash, branch name, or tag to show'),
|
|
7
|
+
});
|
|
8
|
+
export const gitShowTool = defineTool({
|
|
9
|
+
name: 'git_show',
|
|
10
|
+
description: 'Show the patch/content of a specific commit. Read-only.',
|
|
11
|
+
inputSchema: InputSchema,
|
|
12
|
+
permission: 'read',
|
|
13
|
+
isConcurrencySafe: true,
|
|
14
|
+
execute: async (input, ctx) => {
|
|
15
|
+
return safe(async () => {
|
|
16
|
+
const res = await runGit(['show', '--no-color', input.target], ctx.cwd);
|
|
17
|
+
if (res.code !== 0)
|
|
18
|
+
throw new Error(res.err || `git show failed`);
|
|
19
|
+
return { content: res.out };
|
|
20
|
+
});
|
|
21
|
+
},
|
|
22
|
+
});
|
|
@@ -4,37 +4,20 @@
|
|
|
4
4
|
* - `git log -n 10 --oneline` for recent history
|
|
5
5
|
* The agent needs working-tree state AND recent history to plan edits.
|
|
6
6
|
*/
|
|
7
|
-
import { spawn } from 'node:child_process';
|
|
8
7
|
import { z } from 'zod';
|
|
9
8
|
import { defineTool } from '../types.js';
|
|
10
9
|
import { safe } from '../normalize.js';
|
|
10
|
+
import { runGit } from './run-git.js';
|
|
11
11
|
const InputSchema = z.object({
|
|
12
12
|
showLog: z.boolean().optional().describe('Also include the last 10 commit messages (default true)'),
|
|
13
13
|
});
|
|
14
|
-
function run(args, cwd, timeoutMs = 15_000) {
|
|
15
|
-
return new Promise((resolve) => {
|
|
16
|
-
const child = spawn('git', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, shell: false });
|
|
17
|
-
const out = [];
|
|
18
|
-
const err = [];
|
|
19
|
-
const timer = setTimeout(() => { try {
|
|
20
|
-
child.kill('SIGKILL');
|
|
21
|
-
}
|
|
22
|
-
catch { /* noop */ } }, timeoutMs);
|
|
23
|
-
child.stdout.on('data', (b) => out.push(b));
|
|
24
|
-
child.stderr.on('data', (b) => err.push(b));
|
|
25
|
-
child.on('close', (code) => {
|
|
26
|
-
clearTimeout(timer);
|
|
27
|
-
resolve({ code: code ?? 1, out: Buffer.concat(out).toString('utf-8'), err: Buffer.concat(err).toString('utf-8') });
|
|
28
|
-
});
|
|
29
|
-
});
|
|
30
|
-
}
|
|
31
14
|
export const gitStatusTool = defineTool({
|
|
32
15
|
name: 'git_status',
|
|
33
16
|
description: 'Show working-tree status (porcelain) and, optionally, the last 10 commit messages and current branch. Read-only.',
|
|
34
17
|
inputSchema: InputSchema,
|
|
35
18
|
execute: async (input, ctx) => {
|
|
36
19
|
return safe(async () => {
|
|
37
|
-
const status = await
|
|
20
|
+
const status = await runGit(['status', '--porcelain', '--untracked-files=all'], ctx.cwd);
|
|
38
21
|
if (status.code !== 0) {
|
|
39
22
|
// Not a git repo or git unavailable
|
|
40
23
|
return {
|
|
@@ -43,11 +26,11 @@ export const gitStatusTool = defineTool({
|
|
|
43
26
|
};
|
|
44
27
|
}
|
|
45
28
|
const result = { porcelain: status.out || '(clean)' };
|
|
46
|
-
const branch = await
|
|
29
|
+
const branch = await runGit(['branch', '--show-current'], ctx.cwd);
|
|
47
30
|
if (branch.code === 0 && branch.out.trim())
|
|
48
31
|
result.branch = branch.out.trim();
|
|
49
32
|
if (input.showLog !== false) {
|
|
50
|
-
const log = await
|
|
33
|
+
const log = await runGit(['log', '-n', '10', '--oneline', '--no-show-signature'], ctx.cwd);
|
|
51
34
|
if (log.code === 0 && log.out.trim())
|
|
52
35
|
result.recentCommits = log.out.trim();
|
|
53
36
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
export function runGit(args, cwd, timeoutMs = 15_000) {
|
|
3
|
+
return new Promise((resolve) => {
|
|
4
|
+
const child = spawn('git', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, shell: false });
|
|
5
|
+
const out = [];
|
|
6
|
+
const err = [];
|
|
7
|
+
const timer = setTimeout(() => { try {
|
|
8
|
+
child.kill('SIGKILL');
|
|
9
|
+
}
|
|
10
|
+
catch { /* noop */ } }, timeoutMs);
|
|
11
|
+
child.stdout.on('data', (b) => out.push(b));
|
|
12
|
+
child.stderr.on('data', (b) => err.push(b));
|
|
13
|
+
child.on('close', (code) => {
|
|
14
|
+
clearTimeout(timer);
|
|
15
|
+
resolve({ code: code ?? 1, out: Buffer.concat(out).toString('utf-8'), err: Buffer.concat(err).toString('utf-8') });
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
}
|
package/dist/tools/registry.js
CHANGED
|
@@ -16,6 +16,8 @@ import { shellExecTool } from './shell/shell-exec.js';
|
|
|
16
16
|
import { gitStatusTool } from './git/git-status.js';
|
|
17
17
|
import { gitDiffTool } from './git/git-diff.js';
|
|
18
18
|
import { gitLogTool } from './git/git-log.js';
|
|
19
|
+
import { gitBlameTool } from './git/git-blame.js';
|
|
20
|
+
import { gitShowTool } from './git/git-show.js';
|
|
19
21
|
import { runVerifyTool } from './verify/run-verify.js';
|
|
20
22
|
import { todoWriteTool } from './plan/todo-write.js';
|
|
21
23
|
import { askUserTool } from './plan/ask-user.js';
|
|
@@ -104,6 +106,8 @@ export const builtinRegistry = () => {
|
|
|
104
106
|
r.register(gitStatusTool);
|
|
105
107
|
r.register(gitDiffTool);
|
|
106
108
|
r.register(gitLogTool);
|
|
109
|
+
r.register(gitBlameTool);
|
|
110
|
+
r.register(gitShowTool);
|
|
107
111
|
r.register(runVerifyTool);
|
|
108
112
|
r.register(todoWriteTool);
|
|
109
113
|
r.register(askUserTool);
|
|
@@ -7,6 +7,7 @@ import { z } from 'zod';
|
|
|
7
7
|
import { defineTool } from '../types.js';
|
|
8
8
|
import { resolveWithinCwd } from '../../policy/path-guard.js';
|
|
9
9
|
import { safe } from '../normalize.js';
|
|
10
|
+
import { IgnoreFilter } from './ignore.js';
|
|
10
11
|
const InputSchema = z.object({
|
|
11
12
|
pattern: z.string().min(1).describe('Glob pattern, e.g. "**/*.ts" or "src/**/*.json"'),
|
|
12
13
|
cwd: z.string().optional().describe('Base directory; defaults to workspace cwd'),
|
|
@@ -25,7 +26,8 @@ export const globTool = defineTool({
|
|
|
25
26
|
const max = input.maxResults ?? DEFAULT_MAX;
|
|
26
27
|
const matches = [];
|
|
27
28
|
let truncated = false;
|
|
28
|
-
|
|
29
|
+
const ignore = await IgnoreFilter.load(ctx.cwd);
|
|
30
|
+
await walk(base, base, matcher, max + 1, matches, () => (truncated = true), ignore, ctx.cwd);
|
|
29
31
|
// Normalize to relative, forward-slash paths from ctx.cwd (so
|
|
30
32
|
// tool inputs/outputs are stable across Windows and POSIX).
|
|
31
33
|
const rel = matches.slice(0, max).map((m) => path.relative(ctx.cwd, m).split(path.sep).join('/'));
|
|
@@ -50,7 +52,7 @@ export function globToRegex(pattern) {
|
|
|
50
52
|
.replace(/::DOUBLESTAR::/g, '.*');
|
|
51
53
|
return new RegExp(`${anchor}${escaped}$`);
|
|
52
54
|
}
|
|
53
|
-
async function walk(root, dir, matcher, cap, out, onCap) {
|
|
55
|
+
async function walk(root, dir, matcher, cap, out, onCap, ignore, cwd) {
|
|
54
56
|
if (out.length >= cap)
|
|
55
57
|
return;
|
|
56
58
|
let names;
|
|
@@ -66,6 +68,9 @@ async function walk(root, dir, matcher, cap, out, onCap) {
|
|
|
66
68
|
if (SKIP_DIRS.has(name))
|
|
67
69
|
continue;
|
|
68
70
|
const full = path.join(dir, name);
|
|
71
|
+
const relToCwd = path.relative(cwd, full);
|
|
72
|
+
if (ignore.ignores(relToCwd))
|
|
73
|
+
continue;
|
|
69
74
|
let isDir = false;
|
|
70
75
|
try {
|
|
71
76
|
const s = await fs.lstat(full);
|
|
@@ -83,6 +88,6 @@ async function walk(root, dir, matcher, cap, out, onCap) {
|
|
|
83
88
|
onCap();
|
|
84
89
|
}
|
|
85
90
|
if (isDir)
|
|
86
|
-
await walk(root, full, matcher, cap, out, onCap);
|
|
91
|
+
await walk(root, full, matcher, cap, out, onCap, ignore, cwd);
|
|
87
92
|
}
|
|
88
93
|
}
|
|
@@ -7,6 +7,7 @@ import { z } from 'zod';
|
|
|
7
7
|
import { defineTool } from '../types.js';
|
|
8
8
|
import { resolveWithinCwd } from '../../policy/path-guard.js';
|
|
9
9
|
import { safe, TOOL_ERROR_CODES } from '../normalize.js';
|
|
10
|
+
import { IgnoreFilter } from './ignore.js';
|
|
10
11
|
const InputSchema = z.object({
|
|
11
12
|
pattern: z.string().min(1).describe('JavaScript regular expression (not ripgrep syntax)'),
|
|
12
13
|
cwd: z.string().optional(),
|
|
@@ -48,6 +49,7 @@ export const grepTool = defineTool({
|
|
|
48
49
|
const hits = [];
|
|
49
50
|
let searched = 0;
|
|
50
51
|
let truncated = false;
|
|
52
|
+
const ignore = await IgnoreFilter.load(ctx.cwd);
|
|
51
53
|
await walk(base, async (file) => {
|
|
52
54
|
if (hits.length >= max) {
|
|
53
55
|
truncated = true;
|
|
@@ -88,7 +90,7 @@ export const grepTool = defineTool({
|
|
|
88
90
|
hits.push(hit);
|
|
89
91
|
}
|
|
90
92
|
}
|
|
91
|
-
});
|
|
93
|
+
}, ignore, ctx.cwd);
|
|
92
94
|
return { pattern: input.pattern, hits, truncated, searchedFiles: searched };
|
|
93
95
|
});
|
|
94
96
|
},
|
|
@@ -102,7 +104,7 @@ function globToRegex(g) {
|
|
|
102
104
|
.replace(/::DS::/g, '.*');
|
|
103
105
|
return new RegExp(`^${escaped}$`);
|
|
104
106
|
}
|
|
105
|
-
async function walk(dir, visit) {
|
|
107
|
+
async function walk(dir, visit, ignore, cwd) {
|
|
106
108
|
let names;
|
|
107
109
|
try {
|
|
108
110
|
names = await fs.readdir(dir);
|
|
@@ -114,6 +116,11 @@ async function walk(dir, visit) {
|
|
|
114
116
|
if (SKIP_DIRS.has(name))
|
|
115
117
|
continue;
|
|
116
118
|
const full = path.join(dir, name);
|
|
119
|
+
if (ignore && cwd) {
|
|
120
|
+
const relToCwd = path.relative(cwd, full);
|
|
121
|
+
if (ignore.ignores(relToCwd))
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
117
124
|
let isDir = false;
|
|
118
125
|
try {
|
|
119
126
|
const s = await fs.lstat(full);
|
|
@@ -125,7 +132,7 @@ async function walk(dir, visit) {
|
|
|
125
132
|
continue;
|
|
126
133
|
}
|
|
127
134
|
if (isDir)
|
|
128
|
-
await walk(full, visit);
|
|
135
|
+
await walk(full, visit, ignore, cwd);
|
|
129
136
|
else
|
|
130
137
|
await visit(full);
|
|
131
138
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Converts a .gitignore rule to a regex. Assumes no negative (!) rules for now. */
|
|
2
|
+
export declare function ignoreRuleToRegex(rule: string): RegExp | null;
|
|
3
|
+
export declare class IgnoreFilter {
|
|
4
|
+
private rules;
|
|
5
|
+
static load(cwd: string): Promise<IgnoreFilter>;
|
|
6
|
+
/** Accepts a relative path (posix or windows) and returns true if it should be ignored. */
|
|
7
|
+
ignores(relPath: string): boolean;
|
|
8
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import * as fs from 'node:fs/promises';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
/** Converts a .gitignore rule to a regex. Assumes no negative (!) rules for now. */
|
|
4
|
+
export function ignoreRuleToRegex(rule) {
|
|
5
|
+
let p = rule.trim();
|
|
6
|
+
if (!p || p.startsWith('#') || p.startsWith('!'))
|
|
7
|
+
return null;
|
|
8
|
+
// Strip trailing slashes
|
|
9
|
+
if (p.endsWith('/'))
|
|
10
|
+
p = p.slice(0, -1);
|
|
11
|
+
// If it starts with /, anchor to root; else it can match anywhere
|
|
12
|
+
const anchorStart = p.startsWith('/') ? '^' : '(?:^|/)';
|
|
13
|
+
if (p.startsWith('/'))
|
|
14
|
+
p = p.slice(1);
|
|
15
|
+
const escaped = p
|
|
16
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
17
|
+
.replace(/\*\*/g, '::DS::')
|
|
18
|
+
.replace(/\*/g, '[^/]*')
|
|
19
|
+
.replace(/\?/g, '[^/]')
|
|
20
|
+
.replace(/::DS::/g, '.*');
|
|
21
|
+
return new RegExp(`${anchorStart}${escaped}(?:/|$)`);
|
|
22
|
+
}
|
|
23
|
+
export class IgnoreFilter {
|
|
24
|
+
rules = [];
|
|
25
|
+
static async load(cwd) {
|
|
26
|
+
const filter = new IgnoreFilter();
|
|
27
|
+
for (const name of ['.gitignore', '.klyroignore']) {
|
|
28
|
+
try {
|
|
29
|
+
const content = await fs.readFile(path.join(cwd, name), 'utf-8');
|
|
30
|
+
for (const line of content.split('\n')) {
|
|
31
|
+
const re = ignoreRuleToRegex(line);
|
|
32
|
+
if (re)
|
|
33
|
+
filter.rules.push(re);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
catch { /* missing/unreadable */ }
|
|
37
|
+
}
|
|
38
|
+
return filter;
|
|
39
|
+
}
|
|
40
|
+
/** Accepts a relative path (posix or windows) and returns true if it should be ignored. */
|
|
41
|
+
ignores(relPath) {
|
|
42
|
+
const normalized = relPath.replace(/\\/g, '/');
|
|
43
|
+
for (const re of this.rules) {
|
|
44
|
+
if (re.test(normalized))
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -16,6 +16,7 @@ import { defineTool } from '../types.js';
|
|
|
16
16
|
import { resolveWithinCwd } from '../../policy/path-guard.js';
|
|
17
17
|
import { safe } from '../normalize.js';
|
|
18
18
|
import { globToRegex } from './glob.js';
|
|
19
|
+
import { IgnoreFilter } from './ignore.js';
|
|
19
20
|
const InputSchema = z.object({
|
|
20
21
|
query: z.string().optional().describe('Substring or regex to match against file paths (case-insensitive)'),
|
|
21
22
|
glob: z.string().optional().describe('Glob pattern to constrain candidates, e.g. "src/**/*.ts"'),
|
|
@@ -29,7 +30,7 @@ function queryToRegex(query) {
|
|
|
29
30
|
const escaped = query.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
30
31
|
return new RegExp(escaped, 'i');
|
|
31
32
|
}
|
|
32
|
-
async function walk(root, dir, onFile) {
|
|
33
|
+
async function walk(root, dir, onFile, ignore, cwd) {
|
|
33
34
|
let names;
|
|
34
35
|
try {
|
|
35
36
|
names = await fs.readdir(dir);
|
|
@@ -41,6 +42,11 @@ async function walk(root, dir, onFile) {
|
|
|
41
42
|
if (SKIP_DIRS.has(name))
|
|
42
43
|
continue;
|
|
43
44
|
const full = path.join(dir, name);
|
|
45
|
+
if (ignore && cwd) {
|
|
46
|
+
const relToCwd = path.relative(cwd, full);
|
|
47
|
+
if (ignore.ignores(relToCwd))
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
44
50
|
let isDir = false;
|
|
45
51
|
try {
|
|
46
52
|
const s = await fs.lstat(full);
|
|
@@ -52,7 +58,7 @@ async function walk(root, dir, onFile) {
|
|
|
52
58
|
continue;
|
|
53
59
|
}
|
|
54
60
|
if (isDir)
|
|
55
|
-
await walk(root, full, onFile);
|
|
61
|
+
await walk(root, full, onFile, ignore, cwd);
|
|
56
62
|
else
|
|
57
63
|
await onFile(full);
|
|
58
64
|
}
|
|
@@ -69,6 +75,7 @@ export const searchFilesTool = defineTool({
|
|
|
69
75
|
const globRe = input.glob ? globToRegex(input.glob) : null;
|
|
70
76
|
const candidates = [];
|
|
71
77
|
const now = Date.now();
|
|
78
|
+
const ignore = await IgnoreFilter.load(ctx.cwd);
|
|
72
79
|
await walk(base, base, async (full) => {
|
|
73
80
|
const rel = path.relative(ctx.cwd, full).split(path.sep).join('/');
|
|
74
81
|
if (globRe && !globRe.test(rel))
|
|
@@ -101,7 +108,7 @@ export const searchFilesTool = defineTool({
|
|
|
101
108
|
// Recency: 0..30 points, 0 days = 30, 30+ days = 0
|
|
102
109
|
score += Math.max(0, 30 - ageDays);
|
|
103
110
|
candidates.push({ path: rel, score, mtimeMs, firstParty });
|
|
104
|
-
});
|
|
111
|
+
}, ignore, ctx.cwd);
|
|
105
112
|
candidates.sort((a, b) => b.score - a.score);
|
|
106
113
|
const truncated = candidates.length > max;
|
|
107
114
|
return { matches: candidates.slice(0, max), truncated };
|
package/dist/tui/app.js
CHANGED
|
@@ -7,6 +7,8 @@ import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
|
|
7
7
|
import { Box, Text, useInput, useStdout } from 'ink';
|
|
8
8
|
import Spinner from 'ink-spinner';
|
|
9
9
|
import { execFileSync } from 'node:child_process';
|
|
10
|
+
import { readFileSync } from 'node:fs';
|
|
11
|
+
import * as path from 'node:path';
|
|
10
12
|
import { TuiApprovalBridge, ApprovalModal } from './approval.js';
|
|
11
13
|
import { parse as parseSlash, suggestCommands } from '../cli/slash/parser.js';
|
|
12
14
|
import { loadCustomCommands, listCompletableFiles } from '../cli/slash/custom.js';
|
|
@@ -33,6 +35,40 @@ function Header({ cwd, model, version, width }) {
|
|
|
33
35
|
const showLinks = width >= 120;
|
|
34
36
|
return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, flexShrink: 0, children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Text, { bold: true, color: tokens.colors.accent, children: ["KLYRO v", version] }), showLinks ? _jsx(Text, { color: tokens.colors.dim, children: "\u2502 /help /config /clear /exit" }) : null] }), _jsxs(Text, { color: tokens.colors.dim, children: [model, " \u00B7 ", cwd] }), branch ? _jsxs(Text, { color: tokens.colors.dim, children: ["\u2387 ", branch] }) : null] }));
|
|
35
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* 5.5c — compact todo checklist. Live `plan` state (`todo_write` →
|
|
40
|
+
* `plan_update` events → `updatePlan`) wins; when it is empty, best-effort
|
|
41
|
+
* read of `.klyro/plans/todos.json` so a reloaded TUI still shows the list.
|
|
42
|
+
*/
|
|
43
|
+
const TODO_STATUSES = ['pending', 'in_progress', 'done', 'failed', 'skipped'];
|
|
44
|
+
function loadTodosFromDisk(cwd) {
|
|
45
|
+
try {
|
|
46
|
+
const raw = readFileSync(path.join(cwd, '.klyro', 'plans', 'todos.json'), 'utf-8');
|
|
47
|
+
const arr = JSON.parse(raw);
|
|
48
|
+
if (!Array.isArray(arr))
|
|
49
|
+
return [];
|
|
50
|
+
return arr
|
|
51
|
+
.filter((t) => typeof t.title === 'string' && t.title.length > 0)
|
|
52
|
+
.map((t, i) => ({
|
|
53
|
+
id: typeof t.id === 'string' ? t.id : `disk-${i}`,
|
|
54
|
+
title: t.title,
|
|
55
|
+
status: TODO_STATUSES.includes(t.status)
|
|
56
|
+
? t.status
|
|
57
|
+
: 'pending',
|
|
58
|
+
}));
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function TodoChecklist({ plan, cwd }) {
|
|
65
|
+
// Disk fallback only when live state is empty (never masks live updates).
|
|
66
|
+
const disk = useMemo(() => (plan.length > 0 ? [] : loadTodosFromDisk(cwd)), [plan.length, cwd]);
|
|
67
|
+
const items = plan.length > 0 ? plan : disk;
|
|
68
|
+
if (items.length === 0)
|
|
69
|
+
return null;
|
|
70
|
+
return (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, marginTop: 0, marginBottom: 1, children: [_jsxs(Box, { children: [_jsxs(Text, { color: tokens.colors.guide, children: [" ", g('guide'), " "] }), _jsxs(Text, { bold: true, children: [g('todoPlan'), " Plan ", items.filter((p) => p.status === 'done').length, "/", items.length] })] }), items.slice(0, 8).map((p, i) => (_jsxs(Box, { children: [_jsxs(Text, { color: tokens.colors.guide, children: [" ", g('guide'), " "] }), _jsxs(Text, { color: p.status === 'done' ? tokens.colors.ok : p.status === 'in_progress' ? tokens.colors.accent : tokens.colors.dim, children: [p.status === 'done' ? g('todoDone') : p.status === 'in_progress' ? g('todoActive') : g('todoPending'), " ", i + 1, ". ", redact(p.title)] })] }, p.id)))] }));
|
|
71
|
+
}
|
|
36
72
|
function verbForTool(name) {
|
|
37
73
|
if (name === 'read_file')
|
|
38
74
|
return 'Read';
|
|
@@ -1287,5 +1323,5 @@ export function App(props) {
|
|
|
1287
1323
|
if (it.kind === 'diff')
|
|
1288
1324
|
return (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, marginBottom: 1, children: [_jsx(Text, { bold: true, color: tokens.colors.soft, children: it.summary ?? 'Diff' }), it.hunks.map((h, i) => (_jsxs(Box, { flexDirection: "column", marginTop: 0, children: [_jsx(Text, { color: tokens.colors.soft, children: h.path }), h.lines.map((l, j) => (_jsxs(Text, { wrap: "wrap", color: l.kind === 'add' ? tokens.colors.ok : l.kind === 'remove' ? tokens.colors.err : tokens.colors.dim, children: [l.kind === 'add' ? '+ ' : l.kind === 'remove' ? '- ' : ' ', redact(l.text)] }, j)))] }, i)))] }, it.id));
|
|
1289
1325
|
return null;
|
|
1290
|
-
}) : null, showThinking && status.status === 'running' && !streamingIdRef.current ? (_jsxs(Box, { paddingLeft: 2, marginBottom: 1, children: [_jsxs(Text, { color: tokens.colors.guide, children: [" ", g('guide'), " "] }), _jsxs(Text, { color: tokens.colors.accent, children: [_jsx(Spinner, { type: "dots" }), " "] }), _jsx(Text, { color: tokens.colors.dim, children: "Thinking... (esc \u00D72 to cancel)" }), _jsxs(Text, { color: tokens.colors.dim, children: [" ", (elapsed / 1000).toFixed(1), "s"] })] })) : null, showPlan
|
|
1326
|
+
}) : null, showThinking && status.status === 'running' && !streamingIdRef.current ? (_jsxs(Box, { paddingLeft: 2, marginBottom: 1, children: [_jsxs(Text, { color: tokens.colors.guide, children: [" ", g('guide'), " "] }), _jsxs(Text, { color: tokens.colors.accent, children: [_jsx(Spinner, { type: "dots" }), " "] }), _jsx(Text, { color: tokens.colors.dim, children: "Thinking... (esc \u00D72 to cancel)" }), _jsxs(Text, { color: tokens.colors.dim, children: [" ", (elapsed / 1000).toFixed(1), "s"] })] })) : null, showPlan ? _jsx(TodoChecklist, { plan: plan, cwd: props.cwd }) : null, showQueued && queuedInputs.length > 0 ? (_jsx(Box, { flexDirection: "column", paddingLeft: 2, marginBottom: 1, children: queuedInputs.map((q, i) => (_jsxs(Text, { color: tokens.colors.dim, children: ["queued: ", redact(q.slice(0, 60)), i === 0 ? ' esc to drop' : ''] }, i))) })) : null] }), isFullscreen && !atBottom ? (_jsx(Box, { flexDirection: "column", width: 1, marginLeft: 1, children: Array.from({ length: trackH }).map((_, i) => (_jsx(Text, { color: i === thumbPos ? tokens.colors.accent : tokens.colors.guide, children: i === thumbPos ? '●' : '│' }, i))) })) : null] }), pinned && badgeLabel(atBottom, pendingNew, status.status !== 'running') ? (_jsx(Box, { justifyContent: "center", flexShrink: 0, children: _jsxs(Text, { backgroundColor: tokens.colors.accentSoft, color: tokens.colors.accent, bold: true, children: [' ', badgeLabel(atBottom, pendingNew, status.status !== 'running'), ' '] }) })) : null, slashSuggest.length > 0 ? (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, flexShrink: 0, children: [slashSuggest.map((s, i) => (_jsxs(Text, { color: i === 0 ? tokens.colors.accent : tokens.colors.dim, children: [i === 0 ? '▸' : ' ', " /", s.name, " \u2014 ", s.hint] }, s.name))), _jsx(Text, { color: tokens.colors.dim, children: " tab to complete" })] })) : null, slashSuggest.length === 0 && pathSuggest.length > 0 ? (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, flexShrink: 0, children: [pathSuggest.map((s, i) => (_jsxs(Text, { color: i === 0 ? tokens.colors.accent : tokens.colors.dim, children: [i === 0 ? '▸' : ' ', " ", s.name] }, s.name))), _jsx(Text, { color: tokens.colors.dim, children: " tab to complete" })] })) : null, _jsx(ApprovalModal, { bridge: bridge }), _jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [_jsx(Text, { color: tokens.colors.guide, children: g('rule').repeat(Math.max(10, width - 2)) }), _jsxs(Box, { children: [_jsxs(Text, { color: tokens.colors.accent, bold: true, children: [g('prompt'), " "] }), vimMode === 'normal' ? _jsx(Text, { color: tokens.colors.warn, bold: true, children: "--NORMAL-- " }) : null, _jsx(InputWithCursor, { input: input, cursor: vimCursor })] }), _jsx(Text, { color: tokens.colors.guide, children: g('rule').repeat(Math.max(10, width - 2)) })] }), _jsxs(Box, { justifyContent: "space-between", flexShrink: 0, children: [_jsxs(Text, { color: tokens.colors.dim, children: [status.status === 'running' ? (_jsxs(Text, { color: tokens.colors.accent, children: [_jsx(Spinner, { type: "dots" }), " working \u00B7 "] })) : null, baseHints, maxTop > 0 && isFullscreen ? ' · PgUp/Dn scroll' : ''] }), _jsxs(Text, { color: tokens.colors.dim, children: [maxTop > 0 && isFullscreen ? `⇅ ${topRow}/${maxTop} · ` : '', cost > 0 ? `$${cost.toFixed(2)} · ` : '', ctxPct, "% ctx \u00B7 ", status.model, status.status === 'running' ? ' ●' : ''] })] })] }));
|
|
1291
1327
|
}
|