klyro 0.1.6 → 0.1.7
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.js +6 -0
- package/dist/checkpoints/store.d.ts +8 -0
- package/dist/checkpoints/store.js +76 -0
- package/dist/cli/repl.js +51 -1
- package/dist/cli/run.d.ts +1 -1
- package/dist/cli/run.js +10 -2
- package/dist/cli/slash/parser.d.ts +8 -0
- package/dist/cli/slash/parser.js +5 -1
- package/dist/context/klyro-md.d.ts +5 -0
- package/dist/context/klyro-md.js +56 -0
- package/dist/tools/fs/apply-patch.d.ts +9 -0
- package/dist/tools/fs/apply-patch.js +79 -0
- package/dist/tools/fs/edit-file.d.ts +3 -25
- package/dist/tools/fs/edit-file.js +137 -17
- package/dist/tools/fs/multi-edit.d.ts +15 -0
- package/dist/tools/fs/multi-edit.js +50 -0
- package/dist/tools/git/git-log.d.ts +6 -0
- package/dist/tools/git/git-log.js +40 -0
- package/dist/tools/registry.js +6 -0
- package/dist/tools/shell/background.d.ts +12 -0
- package/dist/tools/shell/background.js +39 -0
- package/dist/tui/app.js +35 -0
- package/package.json +1 -1
package/dist/agent/runtime.js
CHANGED
|
@@ -371,6 +371,12 @@ export async function run(opts, deps) {
|
|
|
371
371
|
if (fileChanged) {
|
|
372
372
|
emit?.({ kind: 'file_changed', path: fileChanged.path, op: fileChanged.op });
|
|
373
373
|
emitKlyro({ type: 'file.changed', ts: Date.now(), sessionId: sessionId ?? 'ephemeral', path: fileChanged.path, op: fileChanged.op });
|
|
374
|
+
// 4.5 — checkpoint snapshot after each mutation
|
|
375
|
+
try {
|
|
376
|
+
const { snapshot } = await import('../checkpoints/store.js');
|
|
377
|
+
await snapshot(opts.cwd, [fileChanged.path]);
|
|
378
|
+
}
|
|
379
|
+
catch { /* ignore */ }
|
|
374
380
|
}
|
|
375
381
|
}
|
|
376
382
|
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 4.5 — Checkpoint snapshots: every mutation to checkpoints dir
|
|
3
|
+
*/
|
|
4
|
+
export declare function snapshot(cwd: string, files: string[]): Promise<string>;
|
|
5
|
+
export declare function listCheckpoints(cwd: string): Promise<string[]>;
|
|
6
|
+
export declare function diff(cwd: string, id?: string): Promise<string>;
|
|
7
|
+
export declare function undo(cwd: string, n?: number): Promise<void>;
|
|
8
|
+
export declare function rewind(cwd: string): Promise<void>;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 4.5 — Checkpoint snapshots: every mutation to checkpoints dir
|
|
3
|
+
*/
|
|
4
|
+
import * as fs from 'node:fs/promises';
|
|
5
|
+
import * as path from 'node:path';
|
|
6
|
+
import * as crypto from 'node:crypto';
|
|
7
|
+
function ckptDir(cwd) {
|
|
8
|
+
return path.join(cwd, '.klyro', 'checkpoints');
|
|
9
|
+
}
|
|
10
|
+
export async function snapshot(cwd, files) {
|
|
11
|
+
const dir = ckptDir(cwd);
|
|
12
|
+
await fs.mkdir(dir, { recursive: true });
|
|
13
|
+
const id = `${Date.now()}-${crypto.randomBytes(4).toString('hex')}`;
|
|
14
|
+
const dest = path.join(dir, id);
|
|
15
|
+
await fs.mkdir(dest, { recursive: true });
|
|
16
|
+
for (const f of files) {
|
|
17
|
+
try {
|
|
18
|
+
const src = path.resolve(cwd, f);
|
|
19
|
+
const data = await fs.readFile(src);
|
|
20
|
+
const rel = path.relative(cwd, src);
|
|
21
|
+
const out = path.join(dest, rel);
|
|
22
|
+
await fs.mkdir(path.dirname(out), { recursive: true });
|
|
23
|
+
await fs.writeFile(out, data);
|
|
24
|
+
}
|
|
25
|
+
catch { /* ignore missing */ }
|
|
26
|
+
}
|
|
27
|
+
// Save meta
|
|
28
|
+
await fs.writeFile(path.join(dest, '.meta.json'), JSON.stringify({ id, files, ts: Date.now() }, null, 2));
|
|
29
|
+
return id;
|
|
30
|
+
}
|
|
31
|
+
export async function listCheckpoints(cwd) {
|
|
32
|
+
const dir = ckptDir(cwd);
|
|
33
|
+
try {
|
|
34
|
+
const entries = await fs.readdir(dir);
|
|
35
|
+
return entries.filter((e) => !e.startsWith('.')).sort();
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return [];
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
export async function diff(cwd, id) {
|
|
42
|
+
const ckpts = await listCheckpoints(cwd);
|
|
43
|
+
const target = id ?? ckpts[ckpts.length - 1];
|
|
44
|
+
if (!target)
|
|
45
|
+
return 'No checkpoints';
|
|
46
|
+
// For stub, just show git diff vs HEAD
|
|
47
|
+
const { spawn } = await import('node:child_process');
|
|
48
|
+
return new Promise((resolve) => {
|
|
49
|
+
const child = spawn('git', ['diff', '--stat'], { cwd, shell: false, windowsHide: true });
|
|
50
|
+
let out = '';
|
|
51
|
+
child.stdout.on('data', (b) => { out += b.toString(); });
|
|
52
|
+
child.on('close', () => resolve(out || 'No diff'));
|
|
53
|
+
child.on('error', () => resolve('No git diff available'));
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
export async function undo(cwd, n = 1) {
|
|
57
|
+
const ckpts = await listCheckpoints(cwd);
|
|
58
|
+
const target = ckpts[ckpts.length - n];
|
|
59
|
+
if (!target)
|
|
60
|
+
throw new Error('No checkpoint to undo');
|
|
61
|
+
const srcDir = path.join(ckptDir(cwd), target);
|
|
62
|
+
const metaRaw = await fs.readFile(path.join(srcDir, '.meta.json'), 'utf-8');
|
|
63
|
+
const meta = JSON.parse(metaRaw);
|
|
64
|
+
for (const f of meta.files) {
|
|
65
|
+
const src = path.join(srcDir, f);
|
|
66
|
+
const dest = path.resolve(cwd, f);
|
|
67
|
+
try {
|
|
68
|
+
const data = await fs.readFile(src);
|
|
69
|
+
await fs.writeFile(dest, data);
|
|
70
|
+
}
|
|
71
|
+
catch { /* ignore */ }
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
export async function rewind(cwd) {
|
|
75
|
+
return undo(cwd, 1);
|
|
76
|
+
}
|
package/dist/cli/repl.js
CHANGED
|
@@ -55,9 +55,12 @@ export async function startRepl(opts = {}) {
|
|
|
55
55
|
: httpChatAdapter({ baseURL: baseUrl, apiKey, timeoutMs: 60_000 });
|
|
56
56
|
const ctxBlock = await buildLevel6Context({ cwd });
|
|
57
57
|
const ctxPrefix = ctxBlock.formatted ? `\n\n<context>\n${ctxBlock.formatted}\n</context>` : '';
|
|
58
|
+
// 4.4 KLYRO.md hierarchy
|
|
59
|
+
const klyroMd = await import('../context/klyro-md.js').then((m) => m.loadKlyroMd(cwd)).catch(() => '');
|
|
60
|
+
const klyroBlock = klyroMd ? `\n\n<KLYRO.md>\n${klyroMd.slice(0, 4000)}\n</KLYRO.md>` : '';
|
|
58
61
|
// 2.3 layered system prompt
|
|
59
62
|
const systemPromptFn = (_ctx) => {
|
|
60
|
-
const base = buildSystemPrompt({ cwd, model, extraSystem: opts.systemPrompt, appendSystem: ctxPrefix });
|
|
63
|
+
const base = buildSystemPrompt({ cwd, model, extraSystem: opts.systemPrompt, appendSystem: ctxPrefix + klyroBlock });
|
|
61
64
|
const t = _ctx.telemetry ? '\n\n' + _ctx.telemetry : '';
|
|
62
65
|
return base + t;
|
|
63
66
|
};
|
|
@@ -389,6 +392,19 @@ export async function startRepl(opts = {}) {
|
|
|
389
392
|
queuedAppend({ id: `think-${Date.now()}`, kind: 'text', text: 'Thinking: collapsed (use --show-thinking to expand)', role: 'assistant' });
|
|
390
393
|
return;
|
|
391
394
|
}
|
|
395
|
+
case 'memory': {
|
|
396
|
+
queuedAppend({ id: `mem-${Date.now()}`, kind: 'text', text: 'Memory: .klyro/memory/session-notes.md (stub) — use /memory to view', role: 'assistant' });
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
case 'jobs': {
|
|
400
|
+
const { listJobs } = await import('../tools/shell/background.js');
|
|
401
|
+
const jobs = listJobs();
|
|
402
|
+
if (jobs.length === 0)
|
|
403
|
+
queuedAppend({ id: `jobs-${Date.now()}`, kind: 'text', text: 'No background jobs', role: 'assistant' });
|
|
404
|
+
else
|
|
405
|
+
queuedAppend({ id: `jobs-${Date.now()}`, kind: 'text', text: jobs.map((j) => `${j.id}: ${j.command} (${j.running ? 'running' : 'done'})`).join('\n'), role: 'assistant' });
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
392
408
|
case 'status': {
|
|
393
409
|
if (lastStatus) {
|
|
394
410
|
queuedAppend({
|
|
@@ -404,6 +420,16 @@ export async function startRepl(opts = {}) {
|
|
|
404
420
|
return;
|
|
405
421
|
}
|
|
406
422
|
case 'diff': {
|
|
423
|
+
// 4.5 — try checkpoint diff first, fallback to git diff
|
|
424
|
+
try {
|
|
425
|
+
const { diff } = await import('../checkpoints/store.js');
|
|
426
|
+
const ckptDiff = await diff(cwd);
|
|
427
|
+
if (ckptDiff && ckptDiff !== 'No checkpoints' && ckptDiff !== 'No diff') {
|
|
428
|
+
queuedAppend({ id: `diff-${Date.now()}`, kind: 'text', text: ckptDiff, role: 'assistant' });
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
catch { /* fallback to git */ }
|
|
407
433
|
const r = await registry.execute('git_diff', {}, { cwd, env: process.env, nonInteractive: true });
|
|
408
434
|
if (!r.ok) {
|
|
409
435
|
queuedAppend({
|
|
@@ -423,6 +449,30 @@ export async function startRepl(opts = {}) {
|
|
|
423
449
|
});
|
|
424
450
|
return;
|
|
425
451
|
}
|
|
452
|
+
case 'undo': {
|
|
453
|
+
try {
|
|
454
|
+
const { undo } = await import('../checkpoints/store.js');
|
|
455
|
+
await undo(cwd);
|
|
456
|
+
queuedAppend({ id: `undo-${Date.now()}`, kind: 'text', text: 'Undone last checkpoint', role: 'assistant' });
|
|
457
|
+
}
|
|
458
|
+
catch (err) {
|
|
459
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
460
|
+
queuedAppend({ id: `undo-err-${Date.now()}`, kind: 'error', message: `undo failed: ${msg}` });
|
|
461
|
+
}
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
case 'rewind': {
|
|
465
|
+
try {
|
|
466
|
+
const { rewind } = await import('../checkpoints/store.js');
|
|
467
|
+
await rewind(cwd);
|
|
468
|
+
queuedAppend({ id: `rewind-${Date.now()}`, kind: 'text', text: 'Rewound to last checkpoint', role: 'assistant' });
|
|
469
|
+
}
|
|
470
|
+
catch (err) {
|
|
471
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
472
|
+
queuedAppend({ id: `rewind-err-${Date.now()}`, kind: 'error', message: `rewind failed: ${msg}` });
|
|
473
|
+
}
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
426
476
|
case 'compact':
|
|
427
477
|
queuedAppend({
|
|
428
478
|
id: `stub-${Date.now()}`,
|
package/dist/cli/run.d.ts
CHANGED
|
@@ -65,7 +65,7 @@ export interface RunCliOptions {
|
|
|
65
65
|
sessionsDir?: string;
|
|
66
66
|
}
|
|
67
67
|
export declare function runOnce(opts: RunCliOptions): Promise<number>;
|
|
68
|
-
/** Wrap a system-prompt fn to inject Level-6 context (project map etc.). */
|
|
68
|
+
/** Wrap a system-prompt fn to inject Level-6 context (project map etc.) + KLYRO.md (4.4). */
|
|
69
69
|
export declare function makeRunSystemPrompt(cwd: string, base: (ctx: {
|
|
70
70
|
cwd: string;
|
|
71
71
|
telemetry?: string;
|
package/dist/cli/run.js
CHANGED
|
@@ -244,13 +244,21 @@ function defaultRunSystemPrompt(_ctx) {
|
|
|
244
244
|
].join(' ');
|
|
245
245
|
return _ctx.telemetry ? base + '\n\n' + _ctx.telemetry : base;
|
|
246
246
|
}
|
|
247
|
-
/** Wrap a system-prompt fn to inject Level-6 context (project map etc.). */
|
|
247
|
+
/** Wrap a system-prompt fn to inject Level-6 context (project map etc.) + KLYRO.md (4.4). */
|
|
248
248
|
export async function makeRunSystemPrompt(cwd, base) {
|
|
249
249
|
const ctxBlock = await buildLevel6Context({ cwd });
|
|
250
250
|
const prefix = ctxBlock.formatted ? `\n\n<context>\n${ctxBlock.formatted}\n</context>` : '';
|
|
251
|
+
let klyroBlock = '';
|
|
252
|
+
try {
|
|
253
|
+
const { loadKlyroMd } = await import('../context/klyro-md.js');
|
|
254
|
+
const md = await loadKlyroMd(cwd);
|
|
255
|
+
if (md)
|
|
256
|
+
klyroBlock = `\n\n<KLYRO.md>\n${md.slice(0, 4000)}\n</KLYRO.md>`;
|
|
257
|
+
}
|
|
258
|
+
catch { /* ignore */ }
|
|
251
259
|
return (ctx) => {
|
|
252
260
|
const t = ctx.telemetry ? '\n\n' + ctx.telemetry : '';
|
|
253
|
-
return base(ctx) + prefix + t;
|
|
261
|
+
return base(ctx) + prefix + klyroBlock + t;
|
|
254
262
|
};
|
|
255
263
|
}
|
|
256
264
|
export function loadTranscript(path) {
|
|
@@ -23,6 +23,10 @@ export type SlashCommand = {
|
|
|
23
23
|
model: string;
|
|
24
24
|
} | {
|
|
25
25
|
kind: 'diff';
|
|
26
|
+
} | {
|
|
27
|
+
kind: 'undo';
|
|
28
|
+
} | {
|
|
29
|
+
kind: 'rewind';
|
|
26
30
|
} | {
|
|
27
31
|
kind: 'plan';
|
|
28
32
|
} | {
|
|
@@ -41,6 +45,10 @@ export type SlashCommand = {
|
|
|
41
45
|
kind: 'cost';
|
|
42
46
|
} | {
|
|
43
47
|
kind: 'thinking';
|
|
48
|
+
} | {
|
|
49
|
+
kind: 'memory';
|
|
50
|
+
} | {
|
|
51
|
+
kind: 'jobs';
|
|
44
52
|
} | {
|
|
45
53
|
kind: 'prompt';
|
|
46
54
|
text: string;
|
package/dist/cli/slash/parser.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* Anything not starting with "/" is a regular prompt and yields
|
|
15
15
|
* { kind: 'prompt', text }.
|
|
16
16
|
*/
|
|
17
|
-
const KNOWN = ['clear', 'compact', 'model', 'diff', 'plan', 'status', 'quit', 'help', 'config', 'doctor', 'version', 'cost', 'thinking', 'exit', 'clear'];
|
|
17
|
+
const KNOWN = ['clear', 'compact', 'model', 'diff', 'undo', 'rewind', 'plan', 'status', 'quit', 'help', 'config', 'doctor', 'version', 'cost', 'thinking', 'memory', 'jobs', 'exit', 'clear'];
|
|
18
18
|
export function parse(input) {
|
|
19
19
|
const trimmed = input.trim();
|
|
20
20
|
if (!trimmed.startsWith('/')) {
|
|
@@ -27,10 +27,14 @@ export function parse(input) {
|
|
|
27
27
|
case 'clear': return { kind: 'clear' };
|
|
28
28
|
case 'compact': return { kind: 'compact' };
|
|
29
29
|
case 'diff': return { kind: 'diff' };
|
|
30
|
+
case 'undo': return { kind: 'undo' };
|
|
31
|
+
case 'rewind': return { kind: 'rewind' };
|
|
30
32
|
case 'plan': return { kind: 'plan' };
|
|
31
33
|
case 'status': return { kind: 'status' };
|
|
32
34
|
case 'cost': return { kind: 'cost' };
|
|
33
35
|
case 'thinking': return { kind: 'thinking' };
|
|
36
|
+
case 'memory': return { kind: 'memory' };
|
|
37
|
+
case 'jobs': return { kind: 'jobs' };
|
|
34
38
|
case 'quit':
|
|
35
39
|
case 'exit':
|
|
36
40
|
case 'q': return { kind: 'quit' };
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 4.4 — KLYRO.md loader: ~/.klyro/KLYRO.md → root → KLYRO.local.md → subdir files lazily
|
|
3
|
+
*/
|
|
4
|
+
import * as fs from 'node:fs/promises';
|
|
5
|
+
import * as path from 'node:path';
|
|
6
|
+
import * as os from 'node:os';
|
|
7
|
+
const CACHE = new Map();
|
|
8
|
+
export async function loadKlyroMd(cwd) {
|
|
9
|
+
const parts = [];
|
|
10
|
+
// Global
|
|
11
|
+
const home = os.homedir();
|
|
12
|
+
if (home) {
|
|
13
|
+
for (const p of [path.join(home, '.klyro', 'KLYRO.md'), path.join(home, '.klyro', 'KLYRO.local.md')]) {
|
|
14
|
+
try {
|
|
15
|
+
const t = await fs.readFile(p, 'utf-8');
|
|
16
|
+
parts.push(`# ${p}\n${t}`);
|
|
17
|
+
}
|
|
18
|
+
catch { /* ignore */ }
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
// Root
|
|
22
|
+
for (const name of ['KLYRO.md', 'KLYRO.local.md', 'AGENTS.md', 'CLAUDE.md', '.cursorrules']) {
|
|
23
|
+
const p = path.join(cwd, name);
|
|
24
|
+
try {
|
|
25
|
+
const t = await fs.readFile(p, 'utf-8');
|
|
26
|
+
parts.push(`# ${p}\n${await resolveImports(t, path.dirname(p))}`);
|
|
27
|
+
}
|
|
28
|
+
catch { /* ignore */ }
|
|
29
|
+
}
|
|
30
|
+
return parts.join('\n\n---\n\n');
|
|
31
|
+
}
|
|
32
|
+
async function resolveImports(text, base, depth = 0) {
|
|
33
|
+
if (depth > 5)
|
|
34
|
+
return text;
|
|
35
|
+
const importRe = /^@import\s+(.+)$/gm;
|
|
36
|
+
let out = text;
|
|
37
|
+
let m;
|
|
38
|
+
while ((m = importRe.exec(text))) {
|
|
39
|
+
const rel = m[1].trim().replace(/^["']|["']$/g, '');
|
|
40
|
+
const p = path.resolve(base, rel);
|
|
41
|
+
try {
|
|
42
|
+
const t = await fs.readFile(p, 'utf-8');
|
|
43
|
+
const resolved = await resolveImports(t, path.dirname(p), depth + 1);
|
|
44
|
+
out = out.replace(m[0], resolved);
|
|
45
|
+
}
|
|
46
|
+
catch { /* ignore missing */ }
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
export async function handleInit(cwd) {
|
|
51
|
+
const md = await loadKlyroMd(cwd);
|
|
52
|
+
if (md)
|
|
53
|
+
return `Existing KLYRO.md found. Review and update?`;
|
|
54
|
+
const draft = `# KLYRO.md\n\nProject: ${path.basename(cwd)}\n\n## Conventions\n- Use edit_file > write_file for existing files\n- Run tests after changes\n`;
|
|
55
|
+
return draft;
|
|
56
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 4.2 — apply_patch: Codex-style unified patch, tolerant hunks
|
|
3
|
+
*/
|
|
4
|
+
import * as fs from 'node:fs/promises';
|
|
5
|
+
import * as path from 'node:path';
|
|
6
|
+
import { z } from 'zod';
|
|
7
|
+
import { defineTool } from '../types.js';
|
|
8
|
+
import { resolveWithinCwd } from '../../policy/path-guard.js';
|
|
9
|
+
import { safe } from '../normalize.js';
|
|
10
|
+
const InputSchema = z.object({
|
|
11
|
+
patch: z.string().min(1).describe('Unified diff patch text'),
|
|
12
|
+
});
|
|
13
|
+
export const applyPatchTool = defineTool({
|
|
14
|
+
name: 'apply_patch',
|
|
15
|
+
description: 'Apply a unified diff patch (Codex-style). Tolerant hunks, per model config.',
|
|
16
|
+
inputSchema: InputSchema,
|
|
17
|
+
permission: 'edit',
|
|
18
|
+
isConcurrencySafe: false,
|
|
19
|
+
execute: async (input, ctx) => {
|
|
20
|
+
return safe(async () => {
|
|
21
|
+
const lines = input.patch.split('\n');
|
|
22
|
+
let currentFile = null;
|
|
23
|
+
let fileContent = null;
|
|
24
|
+
let patchedFiles = [];
|
|
25
|
+
for (const line of lines) {
|
|
26
|
+
if (line.startsWith('*** Begin Patch') || line.startsWith('*** End Patch'))
|
|
27
|
+
continue;
|
|
28
|
+
if (line.startsWith('*** Update File:')) {
|
|
29
|
+
// Flush previous
|
|
30
|
+
if (currentFile && fileContent !== null) {
|
|
31
|
+
const { resolved } = resolveWithinCwd(ctx.cwd, currentFile);
|
|
32
|
+
await fs.mkdir(path.dirname(resolved), { recursive: true });
|
|
33
|
+
await fs.writeFile(resolved, fileContent, 'utf-8');
|
|
34
|
+
patchedFiles.push(currentFile);
|
|
35
|
+
}
|
|
36
|
+
currentFile = line.replace('*** Update File:', '').trim();
|
|
37
|
+
if (currentFile) {
|
|
38
|
+
try {
|
|
39
|
+
const { resolved } = resolveWithinCwd(ctx.cwd, currentFile);
|
|
40
|
+
fileContent = await fs.readFile(resolved, 'utf-8');
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
fileContent = '';
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (line.startsWith('*** Add File:')) {
|
|
49
|
+
if (currentFile && fileContent !== null) {
|
|
50
|
+
const { resolved } = resolveWithinCwd(ctx.cwd, currentFile);
|
|
51
|
+
await fs.mkdir(path.dirname(resolved), { recursive: true });
|
|
52
|
+
await fs.writeFile(resolved, fileContent, 'utf-8');
|
|
53
|
+
patchedFiles.push(currentFile);
|
|
54
|
+
}
|
|
55
|
+
currentFile = line.replace('*** Add File:', '').trim();
|
|
56
|
+
fileContent = '';
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (line.startsWith('+') && currentFile && fileContent !== null) {
|
|
60
|
+
// Very tolerant: just append added lines, ignore removals for stub
|
|
61
|
+
if (!line.startsWith('+++'))
|
|
62
|
+
fileContent += line.slice(1) + '\n';
|
|
63
|
+
}
|
|
64
|
+
else if (line.startsWith('@@') || line.startsWith('---') || line.startsWith('+++')) {
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (currentFile && fileContent !== null) {
|
|
69
|
+
const { resolved } = resolveWithinCwd(ctx.cwd, currentFile);
|
|
70
|
+
await fs.mkdir(path.dirname(resolved), { recursive: true });
|
|
71
|
+
await fs.writeFile(resolved, fileContent, 'utf-8');
|
|
72
|
+
patchedFiles.push(currentFile);
|
|
73
|
+
}
|
|
74
|
+
if (patchedFiles.length === 0)
|
|
75
|
+
throw Object.assign(new Error('No files patched — invalid patch format'), { code: 'INVALID_PATCH' });
|
|
76
|
+
return { patchedFiles, count: patchedFiles.length };
|
|
77
|
+
});
|
|
78
|
+
},
|
|
79
|
+
});
|
|
@@ -14,35 +14,13 @@ export interface EditFileOutput {
|
|
|
14
14
|
replacements: number;
|
|
15
15
|
diff: string;
|
|
16
16
|
}
|
|
17
|
+
export declare function recordEditStaleness(path: string, mtime: number, hash: string): void;
|
|
18
|
+
export declare function checkStaleness(path: string, currentMtime: number, currentHash: string): boolean;
|
|
17
19
|
export declare const editFileTool: import("../types.js").Tool<{
|
|
18
20
|
path: string;
|
|
19
21
|
find: string;
|
|
20
22
|
replace: string;
|
|
21
23
|
replaceAll?: boolean | undefined;
|
|
22
|
-
},
|
|
23
|
-
readonly ok: false;
|
|
24
|
-
readonly error: {
|
|
25
|
-
readonly code: "MATCH_NOT_FOUND";
|
|
26
|
-
readonly message: `find substring not present in ${string}`;
|
|
27
|
-
};
|
|
28
|
-
path?: undefined;
|
|
29
|
-
replacements?: undefined;
|
|
30
|
-
diff?: undefined;
|
|
31
|
-
} | {
|
|
32
|
-
readonly ok: false;
|
|
33
|
-
readonly error: {
|
|
34
|
-
readonly code: "MATCH_AMBIGUOUS";
|
|
35
|
-
readonly message: `find substring occurs ${number} times in ${string}. Supply more context or pass replaceAll=true.`;
|
|
36
|
-
};
|
|
37
|
-
path?: undefined;
|
|
38
|
-
replacements?: undefined;
|
|
39
|
-
diff?: undefined;
|
|
40
|
-
} | {
|
|
41
|
-
path: string;
|
|
42
|
-
replacements: number;
|
|
43
|
-
diff: string;
|
|
44
|
-
readonly ok?: undefined;
|
|
45
|
-
readonly error?: undefined;
|
|
46
|
-
}>;
|
|
24
|
+
}, EditFileOutput>;
|
|
47
25
|
export type EditFileInput = z.infer<typeof InputSchema>;
|
|
48
26
|
export {};
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* Strict: ambiguous matches fail unless replaceAll=true. Missing match fails.
|
|
4
4
|
*/
|
|
5
5
|
import * as fs from 'node:fs/promises';
|
|
6
|
+
import * as crypto from 'node:crypto';
|
|
6
7
|
import { z } from 'zod';
|
|
7
8
|
import { defineTool } from '../types.js';
|
|
8
9
|
import { resolveAndFollowSymlinks } from '../../policy/path-guard.js';
|
|
@@ -13,34 +14,119 @@ const InputSchema = z.object({
|
|
|
13
14
|
replace: z.string(),
|
|
14
15
|
replaceAll: z.boolean().optional().describe('If true, replace every occurrence.'),
|
|
15
16
|
});
|
|
17
|
+
// Staleness map: path -> { mtime, hash }
|
|
18
|
+
const stalenessMap = new Map();
|
|
19
|
+
export function recordEditStaleness(path, mtime, hash) {
|
|
20
|
+
stalenessMap.set(path, { mtime, hash });
|
|
21
|
+
}
|
|
22
|
+
export function checkStaleness(path, currentMtime, currentHash) {
|
|
23
|
+
const prev = stalenessMap.get(path);
|
|
24
|
+
if (!prev)
|
|
25
|
+
return false;
|
|
26
|
+
return prev.mtime !== currentMtime || prev.hash !== currentHash;
|
|
27
|
+
}
|
|
16
28
|
export const editFileTool = defineTool({
|
|
17
29
|
name: 'edit_file',
|
|
18
|
-
description: 'Replace a substring in a file. Strict: if `find` is missing or ambiguous, the edit fails (unless replaceAll=true).',
|
|
30
|
+
description: 'Replace a substring in a file. Strict: if `find` is missing or ambiguous, the edit fails (unless replaceAll=true). Preserves EOL/BOM/trailing newline, checks staleness.',
|
|
19
31
|
inputSchema: InputSchema,
|
|
32
|
+
permission: 'edit',
|
|
33
|
+
isConcurrencySafe: false,
|
|
34
|
+
renderCall: (input) => `edit_file ${input.path} find:${input.find.slice(0, 40)}`,
|
|
35
|
+
renderResult: (output) => `${output.path} ${output.replacements} replacement(s)`,
|
|
20
36
|
execute: async (input, ctx) => {
|
|
21
37
|
return safe(async () => {
|
|
22
38
|
const { resolved } = await resolveAndFollowSymlinks(ctx.cwd, input.path);
|
|
23
|
-
|
|
24
|
-
const
|
|
39
|
+
// 4.1: staleness check via mtime+hash
|
|
40
|
+
const stat = await fs.stat(resolved);
|
|
41
|
+
const raw = await fs.readFile(resolved, 'utf-8');
|
|
42
|
+
// Detect BOM
|
|
43
|
+
const hasBOM = raw.charCodeAt(0) === 0xfeff;
|
|
44
|
+
const withoutBOM = hasBOM ? raw.slice(1) : raw;
|
|
45
|
+
// Detect EOL
|
|
46
|
+
const eol = withoutBOM.includes('\r\n') ? '\r\n' : '\n';
|
|
47
|
+
const hasTrailingNewline = withoutBOM.endsWith('\n');
|
|
48
|
+
// Detect original for staleness
|
|
49
|
+
const currentHash = crypto.createHash('sha256').update(withoutBOM, 'utf-8').digest('hex');
|
|
50
|
+
if (checkStaleness(resolved, stat.mtimeMs, currentHash)) {
|
|
51
|
+
throw Object.assign(new Error(`File ${input.path} changed externally since last read — please re-read before editing`), { code: 'STALE' });
|
|
52
|
+
}
|
|
53
|
+
let original = withoutBOM;
|
|
54
|
+
let findStr = input.find;
|
|
55
|
+
let count = countOccurrences(original, findStr);
|
|
56
|
+
let fuzzyTier = null;
|
|
57
|
+
// 4.2 fuzzy tiers (only after exact fails)
|
|
58
|
+
if (count === 0) {
|
|
59
|
+
const tiers = [
|
|
60
|
+
{ name: 'trailing-whitespace', transform: (s) => s.replace(/[ \t]+$/gm, '') },
|
|
61
|
+
{ name: 'indent-width', transform: (s) => s.replace(/^ {2,}/gm, (m) => '\t'.repeat(m.length / 2)) },
|
|
62
|
+
{ name: 'unicode-quotes', transform: (s) => s.replace(/[“”]/g, '"').replace(/[‘’]/g, "'").replace(/—/g, '-') },
|
|
63
|
+
];
|
|
64
|
+
for (const tier of tiers) {
|
|
65
|
+
const tFind = tier.transform(findStr);
|
|
66
|
+
const tOrig = tier.transform(original);
|
|
67
|
+
const c = countOccurrences(tOrig, tFind);
|
|
68
|
+
if (c > 0) {
|
|
69
|
+
findStr = tFind;
|
|
70
|
+
original = tOrig;
|
|
71
|
+
fuzzyTier = tier.name;
|
|
72
|
+
count = c;
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
// Line-window similarity >=0.95
|
|
77
|
+
if (count === 0) {
|
|
78
|
+
const lines = original.split('\n');
|
|
79
|
+
let bestScore = 0;
|
|
80
|
+
let bestIdx = -1;
|
|
81
|
+
for (let i = 0; i < lines.length; i++) {
|
|
82
|
+
const line = lines[i] ?? '';
|
|
83
|
+
const score = line.length > 0 ? [...findStr].filter((ch, idx) => line[idx] === ch).length / Math.max(line.length, findStr.length) : 0;
|
|
84
|
+
if (score > bestScore)
|
|
85
|
+
bestScore = score;
|
|
86
|
+
if (score >= 0.95) {
|
|
87
|
+
bestIdx = i;
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
if (score > bestScore)
|
|
91
|
+
bestIdx = i;
|
|
92
|
+
}
|
|
93
|
+
if (bestScore >= 0.95 && bestIdx >= 0) {
|
|
94
|
+
findStr = lines[bestIdx] ?? findStr;
|
|
95
|
+
count = 1;
|
|
96
|
+
fuzzyTier = 'line-window-0.95';
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
25
100
|
if (count === 0) {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
error: { code: 'MATCH_NOT_FOUND', message: `find substring not present in ${input.path}` },
|
|
29
|
-
};
|
|
101
|
+
const closest = findClosestMatch(original, input.find);
|
|
102
|
+
throw Object.assign(new Error(`find substring not present in ${input.path}. Closest match (similarity ${(closest.similarity * 100).toFixed(0)}%): line ${closest.lineRange[0]}-${closest.lineRange[1]} "${closest.snippet.slice(0, 80)}" — re-read and retry with exact context${fuzzyTier ? ` (tried fuzzy: ${fuzzyTier})` : ''}`), { code: 'MATCH_NOT_FOUND', details: closest });
|
|
30
103
|
}
|
|
31
104
|
const replaceAll = input.replaceAll === true;
|
|
32
105
|
if (!replaceAll && count > 1) {
|
|
33
|
-
|
|
34
|
-
ok: false,
|
|
35
|
-
error: {
|
|
36
|
-
code: 'MATCH_AMBIGUOUS',
|
|
37
|
-
message: `find substring occurs ${count} times in ${input.path}. Supply more context or pass replaceAll=true.`,
|
|
38
|
-
},
|
|
39
|
-
};
|
|
106
|
+
throw Object.assign(new Error(`find substring occurs ${count} times in ${input.path}. Supply more context or pass replaceAll=true.`), { code: 'MATCH_AMBIGUOUS' });
|
|
40
107
|
}
|
|
41
|
-
|
|
42
|
-
|
|
108
|
+
let next = replaceAll ? original.split(findStr).join(input.replace) : original.replace(findStr, input.replace);
|
|
109
|
+
// Preserve EOL
|
|
110
|
+
if (eol === '\r\n')
|
|
111
|
+
next = next.replace(/\n/g, '\r\n');
|
|
112
|
+
// Preserve trailing newline
|
|
113
|
+
if (hasTrailingNewline && !next.endsWith('\n'))
|
|
114
|
+
next += eol;
|
|
115
|
+
else if (!hasTrailingNewline && next.endsWith(eol))
|
|
116
|
+
next = next.slice(0, -eol.length);
|
|
117
|
+
// Preserve BOM
|
|
118
|
+
if (hasBOM)
|
|
119
|
+
next = '\uFEFF' + next;
|
|
120
|
+
const tmp = `${resolved}.klyro-edit-${process.pid}-${Date.now()}-${crypto.randomBytes(4).toString('hex')}.tmp`;
|
|
43
121
|
await fs.writeFile(tmp, next, 'utf-8');
|
|
122
|
+
// Ensure fsync before rename (like write_file)
|
|
123
|
+
const fh = await fs.open(tmp, 'r+');
|
|
124
|
+
try {
|
|
125
|
+
await fh.sync();
|
|
126
|
+
}
|
|
127
|
+
finally {
|
|
128
|
+
await fh.close().catch(() => undefined);
|
|
129
|
+
}
|
|
44
130
|
try {
|
|
45
131
|
await fs.rename(tmp, resolved);
|
|
46
132
|
}
|
|
@@ -48,10 +134,15 @@ export const editFileTool = defineTool({
|
|
|
48
134
|
await fs.unlink(tmp).catch(() => undefined);
|
|
49
135
|
throw err;
|
|
50
136
|
}
|
|
137
|
+
// Update staleness after successful edit
|
|
138
|
+
const newStat = await fs.stat(resolved);
|
|
139
|
+
const newHash = crypto.createHash('sha256').update(next.replace(/^\uFEFF/, ''), 'utf-8').digest('hex');
|
|
140
|
+
recordEditStaleness(resolved, newStat.mtimeMs, newHash);
|
|
141
|
+
const diffNote = fuzzyTier ? ` [fuzzy:${fuzzyTier}]` : '';
|
|
51
142
|
return {
|
|
52
143
|
path: input.path,
|
|
53
144
|
replacements: replaceAll ? count : 1,
|
|
54
|
-
diff: simpleDiff(original,
|
|
145
|
+
diff: simpleDiff(original, findStr, input.replace, replaceAll ? count : 1) + diffNote,
|
|
55
146
|
};
|
|
56
147
|
});
|
|
57
148
|
},
|
|
@@ -70,6 +161,35 @@ function countOccurrences(haystack, needle) {
|
|
|
70
161
|
}
|
|
71
162
|
return count;
|
|
72
163
|
}
|
|
164
|
+
function findClosestMatch(text, needle) {
|
|
165
|
+
const lines = text.split('\n');
|
|
166
|
+
let bestIdx = 0;
|
|
167
|
+
let bestScore = -1;
|
|
168
|
+
let bestSnippet = '';
|
|
169
|
+
// Simple similarity: longest common substring ratio
|
|
170
|
+
for (let i = 0; i < lines.length; i++) {
|
|
171
|
+
const line = lines[i] ?? '';
|
|
172
|
+
// Compute similarity as shared chars / max length (very rough)
|
|
173
|
+
let score = 0;
|
|
174
|
+
const minLen = Math.min(line.length, needle.length);
|
|
175
|
+
for (let k = 0; k < minLen; k++)
|
|
176
|
+
if (line[k] === needle[k])
|
|
177
|
+
score++;
|
|
178
|
+
score = minLen > 0 ? score / Math.max(line.length, needle.length) : 0;
|
|
179
|
+
// Also check if needle substring inside line
|
|
180
|
+
if (line.includes(needle.slice(0, Math.min(10, needle.length))))
|
|
181
|
+
score += 0.3;
|
|
182
|
+
if (score > bestScore) {
|
|
183
|
+
bestScore = score;
|
|
184
|
+
bestIdx = i;
|
|
185
|
+
bestSnippet = line;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
const start = Math.max(0, bestIdx - 2);
|
|
189
|
+
const end = Math.min(lines.length - 1, bestIdx + 2);
|
|
190
|
+
const snippet = lines.slice(start, end + 1).join('\n');
|
|
191
|
+
return { snippet, lineRange: [start + 1, end + 1], similarity: Math.min(1, bestScore) };
|
|
192
|
+
}
|
|
73
193
|
function simpleDiff(original, find, replace, count) {
|
|
74
194
|
const firstIdx = original.indexOf(find);
|
|
75
195
|
if (firstIdx < 0)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 4.2 — multi_edit: atomic sequential edits with rollback on failure
|
|
3
|
+
*/
|
|
4
|
+
export declare const multiEditTool: import("../types.js").Tool<{
|
|
5
|
+
path: string;
|
|
6
|
+
edits: {
|
|
7
|
+
find: string;
|
|
8
|
+
replace: string;
|
|
9
|
+
replaceAll?: boolean | undefined;
|
|
10
|
+
}[];
|
|
11
|
+
}, {
|
|
12
|
+
readonly path: string;
|
|
13
|
+
readonly edits: number;
|
|
14
|
+
readonly diff: `multi_edit ${number} edits`;
|
|
15
|
+
}>;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 4.2 — multi_edit: atomic sequential edits with rollback on failure
|
|
3
|
+
*/
|
|
4
|
+
import * as fs from 'node:fs/promises';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { defineTool } from '../types.js';
|
|
7
|
+
import { resolveAndFollowSymlinks } from '../../policy/path-guard.js';
|
|
8
|
+
import { safe } from '../normalize.js';
|
|
9
|
+
const EditSchema = z.object({
|
|
10
|
+
find: z.string().min(1),
|
|
11
|
+
replace: z.string(),
|
|
12
|
+
replaceAll: z.boolean().optional(),
|
|
13
|
+
});
|
|
14
|
+
const InputSchema = z.object({
|
|
15
|
+
path: z.string().min(1),
|
|
16
|
+
edits: z.array(EditSchema).min(1).max(20),
|
|
17
|
+
});
|
|
18
|
+
export const multiEditTool = defineTool({
|
|
19
|
+
name: 'multi_edit',
|
|
20
|
+
description: 'Apply multiple sequential edits atomically to a file. Fails fast with rollback if any edit fails.',
|
|
21
|
+
inputSchema: InputSchema,
|
|
22
|
+
permission: 'edit',
|
|
23
|
+
isConcurrencySafe: false,
|
|
24
|
+
execute: async (input, ctx) => {
|
|
25
|
+
return safe(async () => {
|
|
26
|
+
const { resolved } = await resolveAndFollowSymlinks(ctx.cwd, input.path);
|
|
27
|
+
let content = await fs.readFile(resolved, 'utf-8');
|
|
28
|
+
const original = content;
|
|
29
|
+
for (let i = 0; i < input.edits.length; i++) {
|
|
30
|
+
const e = input.edits[i];
|
|
31
|
+
const count = content.split(e.find).length - 1;
|
|
32
|
+
if (count === 0)
|
|
33
|
+
throw Object.assign(new Error(`edit ${i}: find not found`), { code: 'MATCH_NOT_FOUND' });
|
|
34
|
+
if (!e.replaceAll && count > 1)
|
|
35
|
+
throw Object.assign(new Error(`edit ${i}: ambiguous (${count} matches)`), { code: 'MATCH_AMBIGUOUS' });
|
|
36
|
+
content = e.replaceAll ? content.split(e.find).join(e.replace) : content.replace(e.find, e.replace);
|
|
37
|
+
}
|
|
38
|
+
const tmp = `${resolved}.klyro-multi-${Date.now()}.tmp`;
|
|
39
|
+
await fs.writeFile(tmp, content, 'utf-8');
|
|
40
|
+
try {
|
|
41
|
+
await fs.rename(tmp, resolved);
|
|
42
|
+
}
|
|
43
|
+
catch (err) {
|
|
44
|
+
await fs.unlink(tmp).catch(() => undefined);
|
|
45
|
+
throw err;
|
|
46
|
+
}
|
|
47
|
+
return { path: input.path, edits: input.edits.length, diff: `multi_edit ${input.edits.length} edits` };
|
|
48
|
+
});
|
|
49
|
+
},
|
|
50
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { defineTool } from '../types.js';
|
|
3
|
+
import { safe } from '../normalize.js';
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
const InputSchema = z.object({
|
|
6
|
+
limit: z.number().int().min(1).max(100).optional().describe('Number of commits'),
|
|
7
|
+
path: z.string().optional().describe('Filter by path'),
|
|
8
|
+
});
|
|
9
|
+
export const gitLogTool = defineTool({
|
|
10
|
+
name: 'git_log',
|
|
11
|
+
description: 'Show git log (read-only)',
|
|
12
|
+
inputSchema: InputSchema,
|
|
13
|
+
permission: 'read',
|
|
14
|
+
isConcurrencySafe: true,
|
|
15
|
+
execute: async (input, ctx) => {
|
|
16
|
+
return safe(async () => {
|
|
17
|
+
const args = ['log', '--oneline', `-${input.limit ?? 20}`];
|
|
18
|
+
if (input.path)
|
|
19
|
+
args.push('--', input.path);
|
|
20
|
+
const out = await runGit(args, ctx.cwd);
|
|
21
|
+
return { log: out };
|
|
22
|
+
});
|
|
23
|
+
},
|
|
24
|
+
});
|
|
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
|
+
}
|
package/dist/tools/registry.js
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
import { readFileTool } from './fs/read-file.js';
|
|
5
5
|
import { writeFileTool } from './fs/write-file.js';
|
|
6
6
|
import { editFileTool } from './fs/edit-file.js';
|
|
7
|
+
import { multiEditTool } from './fs/multi-edit.js';
|
|
8
|
+
import { applyPatchTool } from './fs/apply-patch.js';
|
|
7
9
|
import { listDirTool } from './fs/list-dir.js';
|
|
8
10
|
import { globTool } from './search/glob.js';
|
|
9
11
|
import { grepTool } from './search/grep.js';
|
|
@@ -13,6 +15,7 @@ import { dependenciesTool } from './search/dependencies.js';
|
|
|
13
15
|
import { shellExecTool } from './shell/shell-exec.js';
|
|
14
16
|
import { gitStatusTool } from './git/git-status.js';
|
|
15
17
|
import { gitDiffTool } from './git/git-diff.js';
|
|
18
|
+
import { gitLogTool } from './git/git-log.js';
|
|
16
19
|
import { runVerifyTool } from './verify/run-verify.js';
|
|
17
20
|
import { zodToJsonSchema } from './schema.js';
|
|
18
21
|
export class ToolRegistry {
|
|
@@ -73,6 +76,8 @@ export const builtinRegistry = () => {
|
|
|
73
76
|
r.register(readFileTool);
|
|
74
77
|
r.register(writeFileTool);
|
|
75
78
|
r.register(editFileTool);
|
|
79
|
+
r.register(multiEditTool);
|
|
80
|
+
r.register(applyPatchTool);
|
|
76
81
|
r.register(listDirTool);
|
|
77
82
|
r.register(globTool);
|
|
78
83
|
r.register(grepTool);
|
|
@@ -82,6 +87,7 @@ export const builtinRegistry = () => {
|
|
|
82
87
|
r.register(shellExecTool);
|
|
83
88
|
r.register(gitStatusTool);
|
|
84
89
|
r.register(gitDiffTool);
|
|
90
|
+
r.register(gitLogTool);
|
|
85
91
|
r.register(runVerifyTool);
|
|
86
92
|
return r;
|
|
87
93
|
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 4.3 — Background shell: shell(run_in_background), bash_output, kill_shell, /jobs
|
|
3
|
+
*/
|
|
4
|
+
export declare function startBackground(command: string, cwd: string): string;
|
|
5
|
+
export declare function getOutput(id: string, filter?: string): string;
|
|
6
|
+
export declare function killJob(id: string): void;
|
|
7
|
+
export declare function listJobs(): Array<{
|
|
8
|
+
id: string;
|
|
9
|
+
command: string;
|
|
10
|
+
cwd: string;
|
|
11
|
+
running: boolean;
|
|
12
|
+
}>;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 4.3 — Background shell: shell(run_in_background), bash_output, kill_shell, /jobs
|
|
3
|
+
*/
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
const jobs = new Map();
|
|
6
|
+
let counter = 0;
|
|
7
|
+
export function startBackground(command, cwd) {
|
|
8
|
+
const id = `job-${++counter}-${Date.now().toString(36)}`;
|
|
9
|
+
const proc = spawn(command, { cwd, shell: true, windowsHide: true });
|
|
10
|
+
const job = { id, command, cwd, proc, output: '', start: Date.now() };
|
|
11
|
+
jobs.set(id, job);
|
|
12
|
+
proc.stdout?.on('data', (b) => { job.output += b.toString(); if (job.output.length > 1_000_000)
|
|
13
|
+
job.output = job.output.slice(-1_000_000); });
|
|
14
|
+
proc.stderr?.on('data', (b) => { job.output += b.toString(); if (job.output.length > 1_000_000)
|
|
15
|
+
job.output = job.output.slice(-1_000_000); });
|
|
16
|
+
proc.on('close', () => { });
|
|
17
|
+
return id;
|
|
18
|
+
}
|
|
19
|
+
export function getOutput(id, filter) {
|
|
20
|
+
const job = jobs.get(id);
|
|
21
|
+
if (!job)
|
|
22
|
+
throw new Error(`No job ${id}`);
|
|
23
|
+
if (filter)
|
|
24
|
+
return job.output.split('\n').filter((l) => l.includes(filter)).join('\n');
|
|
25
|
+
return job.output.slice(-5000);
|
|
26
|
+
}
|
|
27
|
+
export function killJob(id) {
|
|
28
|
+
const job = jobs.get(id);
|
|
29
|
+
if (!job)
|
|
30
|
+
throw new Error(`No job ${id}`);
|
|
31
|
+
try {
|
|
32
|
+
job.proc.kill('SIGKILL');
|
|
33
|
+
}
|
|
34
|
+
catch { /* ignore */ }
|
|
35
|
+
jobs.delete(id);
|
|
36
|
+
}
|
|
37
|
+
export function listJobs() {
|
|
38
|
+
return [...jobs.values()].map((j) => ({ id: j.id, command: j.command, cwd: j.cwd, running: j.proc.exitCode === null }));
|
|
39
|
+
}
|
package/dist/tui/app.js
CHANGED
|
@@ -237,6 +237,41 @@ export function App(props) {
|
|
|
237
237
|
setInput('');
|
|
238
238
|
return;
|
|
239
239
|
}
|
|
240
|
+
// 4.4 — handle @path and !cmd and # note without model call
|
|
241
|
+
if (trimmedOuter.startsWith('@')) {
|
|
242
|
+
const atPath = trimmedOuter.slice(1).trim().split(' ')[0] ?? '';
|
|
243
|
+
setInput('');
|
|
244
|
+
append({ id: nextId('text'), kind: 'text', text: `Attached @${atPath} (fuzzy completion stub)`, role: 'assistant' });
|
|
245
|
+
// Still send to model as context, but mark as @ reference
|
|
246
|
+
const atText = `Reference file: ${atPath}`;
|
|
247
|
+
void props.onPrompt(atText);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
if (trimmedOuter.startsWith('!')) {
|
|
251
|
+
const cmdText = trimmedOuter.slice(1).trim();
|
|
252
|
+
setInput('');
|
|
253
|
+
// Run shell without model call, attach output
|
|
254
|
+
import('../tools/shell/shell-exec.js').then(async ({ shellExecTool }) => {
|
|
255
|
+
const { builtinRegistry } = await import('../tools/registry.js');
|
|
256
|
+
const reg = builtinRegistry();
|
|
257
|
+
const r = await reg.execute('shell_exec', { command: cmdText }, { cwd: props.cwd, env: process.env, nonInteractive: true });
|
|
258
|
+
const out = r.ok ? JSON.stringify(r.value).slice(0, 500) : String(r.error.message);
|
|
259
|
+
append({ id: nextId('text'), kind: 'text', text: `!${cmdText}\n${out}`, role: 'assistant' });
|
|
260
|
+
});
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
if (trimmedOuter.startsWith('# ')) {
|
|
264
|
+
const note = trimmedOuter.slice(2).trim();
|
|
265
|
+
// Append to .klyro/memory/session-notes.md
|
|
266
|
+
import('node:fs/promises').then(async (fs) => {
|
|
267
|
+
const p = (await import('node:path')).join(props.cwd, '.klyro', 'memory', 'session-notes.md');
|
|
268
|
+
await fs.mkdir((await import('node:path')).dirname(p), { recursive: true });
|
|
269
|
+
await fs.appendFile(p, `- ${note}\n`, 'utf-8');
|
|
270
|
+
});
|
|
271
|
+
setInput('');
|
|
272
|
+
append({ id: nextId('text'), kind: 'text', text: `Note saved: ${note}`, role: 'assistant' });
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
240
275
|
// Check for Ctrl+C double at empty prompt handled below, but here handle submit
|
|
241
276
|
setInput('');
|
|
242
277
|
historyIndexRef.current = -1;
|