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.
@@ -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;
@@ -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,5 @@
1
+ /**
2
+ * 4.4 — KLYRO.md loader: ~/.klyro/KLYRO.md → root → KLYRO.local.md → subdir files lazily
3
+ */
4
+ export declare function loadKlyroMd(cwd: string): Promise<string>;
5
+ export declare function handleInit(cwd: string): Promise<string>;
@@ -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,9 @@
1
+ /**
2
+ * 4.2 — apply_patch: Codex-style unified patch, tolerant hunks
3
+ */
4
+ export declare const applyPatchTool: import("../types.js").Tool<{
5
+ patch: string;
6
+ }, {
7
+ readonly patchedFiles: string[];
8
+ readonly count: number;
9
+ }>;
@@ -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
- const original = await fs.readFile(resolved, 'utf-8');
24
- const count = countOccurrences(original, input.find);
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
- return {
27
- ok: false,
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
- return {
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
- const next = replaceAll ? original.split(input.find).join(input.replace) : original.replace(input.find, input.replace);
42
- const tmp = `${resolved}.klyro-edit-${process.pid}-${Date.now()}.tmp`;
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, input.find, input.replace, replaceAll ? count : 1),
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,6 @@
1
+ export declare const gitLogTool: import("../types.js").Tool<{
2
+ limit?: number | undefined;
3
+ path?: string | undefined;
4
+ }, {
5
+ readonly log: string;
6
+ }>;
@@ -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
+ }
@@ -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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klyro",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "description": "Klyro — autonomous coding harness CLI that streams from any OpenAI-compatible or Anthropic LLM endpoint.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",