klyro 0.1.53 → 0.1.55
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/anthropic-adapter.js +22 -2
- package/dist/agent/registry.js +11 -1
- package/dist/agent/retry.d.ts +5 -0
- package/dist/agent/retry.js +28 -1
- package/dist/agent/runtime.d.ts +2 -0
- package/dist/agent/runtime.js +48 -35
- package/dist/chat.js +2 -2
- package/dist/checkpoints/store.js +86 -7
- package/dist/cli/config.js +13 -1
- package/dist/cli/repl.js +200 -36
- package/dist/cli/run.js +88 -69
- package/dist/context/klyro-md.d.ts +5 -0
- package/dist/context/klyro-md.js +28 -6
- package/dist/context/tokenizer.js +8 -1
- package/dist/index.js +49 -14
- package/dist/persistence/session.d.ts +2 -0
- package/dist/persistence/session.js +11 -5
- package/dist/persistence/store.d.ts +7 -0
- package/dist/persistence/store.js +77 -28
- package/dist/policy/engine.d.ts +10 -1
- package/dist/policy/engine.js +33 -2
- package/dist/tools/fs/apply-patch.d.ts +16 -1
- package/dist/tools/fs/apply-patch.js +163 -75
- package/dist/tools/fs/edit-file.d.ts +13 -0
- package/dist/tools/fs/edit-file.js +96 -54
- package/dist/tools/fs/list-dir.d.ts +2 -0
- package/dist/tools/fs/list-dir.js +7 -5
- package/dist/tools/fs/multi-edit.js +12 -1
- package/dist/tools/fs/read-file.js +1 -1
- package/dist/tools/fs/read-history.d.ts +2 -2
- package/dist/tools/fs/read-history.js +39 -7
- package/dist/tools/fs/write-file.js +1 -1
- package/dist/tools/normalize.js +11 -1
- package/dist/tools/shell/background.d.ts +5 -0
- package/dist/tools/shell/background.js +29 -0
- package/dist/tools/shell/shell-exec.d.ts +9 -0
- package/dist/tools/shell/shell-exec.js +54 -8
- package/dist/tools/verify/run-verify.js +2 -2
- package/dist/tui/app.js +10 -5
- package/dist/tui/app.test.js +42 -0
- package/dist/tui/approval.d.ts +1 -1
- package/dist/tui/approval.js +20 -5
- package/dist/tui/diff.d.ts +5 -19
- package/dist/tui/diff.js +9 -25
- package/dist/tui/status.d.ts +4 -8
- package/dist/tui/status.js +8 -17
- package/dist/tui/transcript.d.ts +13 -20
- package/dist/tui/transcript.js +9 -74
- package/dist/verification/engine.js +4 -4
- package/package.json +1 -1
package/dist/cli/repl.js
CHANGED
|
@@ -14,7 +14,7 @@ import { httpChatAdapter } from '../agent/provider-adapter.js';
|
|
|
14
14
|
import { anthropicAdapter } from '../agent/anthropic-adapter.js';
|
|
15
15
|
import { run } from '../agent/runtime.js';
|
|
16
16
|
import { builtinRegistry } from '../tools/registry.js';
|
|
17
|
-
import { builtinRules,
|
|
17
|
+
import { builtinRules, clonePolicyConfig, PolicyEngine } from '../policy/engine.js';
|
|
18
18
|
import { buildLevel6Context } from '../context/level6.js';
|
|
19
19
|
import { DenyAllApprovalPrompt, StdinApprovalPrompt } from '../policy/approval.js';
|
|
20
20
|
import { TuiApprovalBridge } from '../tui/approval.js';
|
|
@@ -68,7 +68,9 @@ export async function startRepl(opts = {}) {
|
|
|
68
68
|
let model = opts.model ?? resolved.model;
|
|
69
69
|
let cwd = opts.cwd ?? process.cwd();
|
|
70
70
|
const registry = builtinRegistry();
|
|
71
|
-
|
|
71
|
+
// Clone: /mode and /sandbox mutate this config — it must never leak into
|
|
72
|
+
// the shared DEFAULT_POLICY_CONFIG across sessions.
|
|
73
|
+
const policy = new PolicyEngine(builtinRules(), clonePolicyConfig());
|
|
72
74
|
const providerKind = inferProviderFromBaseURL(baseUrl);
|
|
73
75
|
// Local Ollama exposes OpenAI-compat but hostname could contain "anthropic"
|
|
74
76
|
// via proxy — don't try anthropic adapter with empty key (would 401).
|
|
@@ -252,7 +254,15 @@ export async function startRepl(opts = {}) {
|
|
|
252
254
|
const home = process.env.HOME ?? process.env.USERPROFILE ?? cwd;
|
|
253
255
|
const dir = nodePath.join(home, '.klyro');
|
|
254
256
|
fsSync.mkdirSync(dir, { recursive: true });
|
|
255
|
-
|
|
257
|
+
const logFile = nodePath.join(dir, 'debug.log');
|
|
258
|
+
// Rotation: never let the debug log grow without bound (2 MiB cap).
|
|
259
|
+
try {
|
|
260
|
+
if (fsSync.statSync(logFile).size > 2 * 1024 * 1024) {
|
|
261
|
+
fsSync.renameSync(logFile, `${logFile}.1`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
catch { /* missing — fine */ }
|
|
265
|
+
fsSync.appendFileSync(logFile, line + '\n');
|
|
256
266
|
}
|
|
257
267
|
catch { /* ignore */ }
|
|
258
268
|
};
|
|
@@ -327,6 +337,42 @@ export async function startRepl(opts = {}) {
|
|
|
327
337
|
}
|
|
328
338
|
catch { /* best-effort */ }
|
|
329
339
|
}
|
|
340
|
+
/** Extract display text from a read_file result ({lines[]} shape). */
|
|
341
|
+
function readFileBody(value) {
|
|
342
|
+
const v = value;
|
|
343
|
+
if (typeof v?.content === 'string')
|
|
344
|
+
return v.content;
|
|
345
|
+
if (Array.isArray(v?.lines))
|
|
346
|
+
return v.lines.join('\n');
|
|
347
|
+
if (typeof v?.text === 'string')
|
|
348
|
+
return v.text;
|
|
349
|
+
return JSON.stringify(v ?? '');
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Build the attached-files context block (/attach, @path, /mention feed
|
|
353
|
+
* this — previously they were display-only and never reached the agent).
|
|
354
|
+
* Re-read fresh at prompt time (5 files, 2k chars each, 8k total cap).
|
|
355
|
+
*/
|
|
356
|
+
async function buildAttachedBlock() {
|
|
357
|
+
if (attachedFiles.size === 0)
|
|
358
|
+
return '';
|
|
359
|
+
const parts = [];
|
|
360
|
+
let total = 0;
|
|
361
|
+
for (const file of [...attachedFiles.keys()].slice(0, 5)) {
|
|
362
|
+
try {
|
|
363
|
+
const r = await registry.execute('read_file', { path: file }, { cwd, env: process.env, nonInteractive: true });
|
|
364
|
+
if (!r.ok)
|
|
365
|
+
continue;
|
|
366
|
+
const body = readFileBody(r.value).slice(0, 2000);
|
|
367
|
+
if (total + body.length > 8000)
|
|
368
|
+
break;
|
|
369
|
+
total += body.length;
|
|
370
|
+
parts.push(`<attached file="${file}">\n${body}\n</attached>`);
|
|
371
|
+
}
|
|
372
|
+
catch { /* ignore */ }
|
|
373
|
+
}
|
|
374
|
+
return parts.length > 0 ? `\n\n${parts.join('\n\n')}` : '';
|
|
375
|
+
}
|
|
330
376
|
/** Truncation budget honoring /verbose and /raw. */
|
|
331
377
|
function outCap(s) {
|
|
332
378
|
const cap = rawMode ? 12000 : verboseMode ? 8000 : 4000;
|
|
@@ -338,10 +384,11 @@ export async function startRepl(opts = {}) {
|
|
|
338
384
|
throw new Error(r.error.message ?? 'shell failed');
|
|
339
385
|
return r.value;
|
|
340
386
|
}
|
|
341
|
-
/** Read-only LLM answer (no tools) — powers /ask
|
|
387
|
+
/** Read-only LLM answer (no tools) — powers /ask, /explain, /compact. */
|
|
342
388
|
async function answerReadOnly(question, context) {
|
|
343
389
|
const sys = systemPromptFn({ cwd, telemetry: '' }) + '\n\nAnswer read-only: do not call tools, do not edit files.';
|
|
344
|
-
const
|
|
390
|
+
const attached = await buildAttachedBlock();
|
|
391
|
+
const userText = (context ? `${question}\n\n<context>\n${context.slice(0, 6000)}\n</context>` : question) + attached;
|
|
345
392
|
const req = {
|
|
346
393
|
model,
|
|
347
394
|
system: sys,
|
|
@@ -362,11 +409,13 @@ export async function startRepl(opts = {}) {
|
|
|
362
409
|
}
|
|
363
410
|
lastAssistantText = text;
|
|
364
411
|
queuedStatus({ status: 'done' });
|
|
412
|
+
return text;
|
|
365
413
|
}
|
|
366
414
|
catch (err) {
|
|
367
415
|
const msg = err instanceof Error ? err.message : String(err);
|
|
368
416
|
queuedAppend({ id: `ro-err-${Date.now()}`, kind: 'error', message: msg });
|
|
369
417
|
queuedStatus({ status: 'error', errorMessage: msg });
|
|
418
|
+
return '';
|
|
370
419
|
}
|
|
371
420
|
}
|
|
372
421
|
function queuedClear() {
|
|
@@ -430,9 +479,16 @@ export async function startRepl(opts = {}) {
|
|
|
430
479
|
queuedStatus({ status: 'error', errorMessage: 'no model configured' });
|
|
431
480
|
return;
|
|
432
481
|
}
|
|
482
|
+
// Fresh read-guard scope per task: reads from a previous task must not
|
|
483
|
+
// satisfy write guards in this one (same process, shared module state).
|
|
484
|
+
try {
|
|
485
|
+
const { clearReadHistory } = await import('../tools/fs/read-history.js');
|
|
486
|
+
clearReadHistory();
|
|
487
|
+
}
|
|
488
|
+
catch { /* ignore */ }
|
|
433
489
|
// 2.3 image handling: extract @img refs
|
|
434
490
|
const { text: cleanText, images } = parseImageInput(text);
|
|
435
|
-
|
|
491
|
+
let taskText = images.length > 0 ? `${cleanText}\n\n[images: ${images.join(', ')}]` : cleanText;
|
|
436
492
|
// Only create session for non-trivial tasks (with tools/verify) — plain chat like "hello" is not a persisted session
|
|
437
493
|
const isSimpleChat = taskText.trim().split(/\s+/).length <= 5 && !taskText.toLowerCase().includes('fix') && !taskText.toLowerCase().includes('add') && !taskText.toLowerCase().includes('create');
|
|
438
494
|
let sessionId;
|
|
@@ -449,6 +505,8 @@ export async function startRepl(opts = {}) {
|
|
|
449
505
|
}
|
|
450
506
|
}
|
|
451
507
|
queuedStatus({ status: 'running', step: 0, model });
|
|
508
|
+
// Attached files ride along (fresh re-read, capped) on both paths.
|
|
509
|
+
taskText += await buildAttachedBlock();
|
|
452
510
|
// Simple chat like "hello" must NOT trigger tool calls — direct LLM chat, no tools, no files
|
|
453
511
|
if (isSimpleChat) {
|
|
454
512
|
try {
|
|
@@ -844,15 +902,57 @@ export async function startRepl(opts = {}) {
|
|
|
844
902
|
return;
|
|
845
903
|
}
|
|
846
904
|
case 'compact': {
|
|
905
|
+
// Real compaction (not a destructive clear): summarize the persisted
|
|
906
|
+
// session via a read-only pass, then clear the view and retain the
|
|
907
|
+
// summary as the new context anchor.
|
|
847
908
|
const focus = cmd.focus?.trim();
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
909
|
+
const compactFallback = () => {
|
|
910
|
+
queuedClear();
|
|
911
|
+
queuedAppend({
|
|
912
|
+
id: `compact-${Date.now()}`,
|
|
913
|
+
kind: 'text',
|
|
914
|
+
text: focus ? `Context compacted — transcript cleared (focus: ${focus}). Continuing fresh.` : 'Context compacted — transcript cleared. Continuing fresh.',
|
|
915
|
+
role: 'assistant',
|
|
916
|
+
});
|
|
917
|
+
queuedStatus({ status: 'done' });
|
|
918
|
+
};
|
|
919
|
+
if (!tuiSessionId) {
|
|
920
|
+
compactFallback();
|
|
921
|
+
return;
|
|
922
|
+
}
|
|
923
|
+
try {
|
|
924
|
+
const msgs = await tuiStore.loadMessages(tuiSessionId);
|
|
925
|
+
if (msgs.length === 0) {
|
|
926
|
+
compactFallback();
|
|
927
|
+
return;
|
|
928
|
+
}
|
|
929
|
+
const excerpt = msgs
|
|
930
|
+
.slice(-60)
|
|
931
|
+
.map((m) => {
|
|
932
|
+
const c = m.content;
|
|
933
|
+
const s = typeof c === 'string' ? c : JSON.stringify(c);
|
|
934
|
+
return `[${m.role}] ${s.slice(0, 500)}`;
|
|
935
|
+
})
|
|
936
|
+
.join('\n')
|
|
937
|
+
.slice(0, 12000);
|
|
938
|
+
queuedAppend({ id: `compact-run-${Date.now()}`, kind: 'text', text: 'Compacting context (summarizing session)…', role: 'assistant' });
|
|
939
|
+
const summary = await answerReadOnly(`Summarize this coding session in 10-15 lines for context retention: goal, key decisions, files changed, current state, next steps.${focus ? ` Focus on: ${focus}` : ''}\n\nSession:\n${excerpt}`);
|
|
940
|
+
if (!summary.trim()) {
|
|
941
|
+
compactFallback();
|
|
942
|
+
return;
|
|
943
|
+
}
|
|
944
|
+
queuedClear();
|
|
945
|
+
queuedAppend({
|
|
946
|
+
id: `compact-done-${Date.now()}`,
|
|
947
|
+
kind: 'text',
|
|
948
|
+
text: `Context compacted — retained summary:\n\n${summary.slice(0, 4000)}`,
|
|
949
|
+
role: 'assistant',
|
|
950
|
+
});
|
|
951
|
+
queuedStatus({ status: 'done' });
|
|
952
|
+
}
|
|
953
|
+
catch {
|
|
954
|
+
compactFallback();
|
|
955
|
+
}
|
|
856
956
|
return;
|
|
857
957
|
}
|
|
858
958
|
case 'model': {
|
|
@@ -1069,7 +1169,11 @@ export async function startRepl(opts = {}) {
|
|
|
1069
1169
|
const cfg = policy.config;
|
|
1070
1170
|
const p = cmd.policy?.trim();
|
|
1071
1171
|
if (!p) {
|
|
1072
|
-
queuedAppend({ id: `sb-${Date.now()}`, kind: 'text', text: `sandbox dirs: ${JSON.stringify(cfg.additionalDirs ?? [])}\nusage: /sandbox <dir> (
|
|
1172
|
+
queuedAppend({ id: `sb-${Date.now()}`, kind: 'text', text: `sandbox dirs: ${JSON.stringify(cfg.additionalDirs ?? [])}\nusage: /sandbox <dir> (add) | /sandbox reset (clear)`, role: 'assistant' });
|
|
1173
|
+
}
|
|
1174
|
+
else if (p === 'reset') {
|
|
1175
|
+
cfg.additionalDirs = [];
|
|
1176
|
+
queuedAppend({ id: `sb3-${Date.now()}`, kind: 'text', text: 'sandbox: cleared allowed dirs', role: 'assistant' });
|
|
1073
1177
|
}
|
|
1074
1178
|
else {
|
|
1075
1179
|
cfg.additionalDirs = [...(cfg.additionalDirs ?? []), p];
|
|
@@ -1089,11 +1193,23 @@ export async function startRepl(opts = {}) {
|
|
|
1089
1193
|
}
|
|
1090
1194
|
case 'resume': {
|
|
1091
1195
|
try {
|
|
1092
|
-
const { resolveSessionId } = await import('../persistence/session.js');
|
|
1196
|
+
const { resolveSessionId, matchSessionIds } = await import('../persistence/session.js');
|
|
1093
1197
|
const all = (await tuiStore.list()).filter((r) => r.cwd === cwd).sort((a, b) => b.updatedAt - a.updatedAt);
|
|
1094
1198
|
const full = cmd.id ? await resolveSessionId(tuiStore, cmd.id) : all[0]?.id;
|
|
1095
1199
|
if (!full) {
|
|
1096
|
-
|
|
1200
|
+
if (cmd.id) {
|
|
1201
|
+
const matches = await matchSessionIds(tuiStore, cmd.id);
|
|
1202
|
+
queuedAppend({
|
|
1203
|
+
id: `resume-err-${Date.now()}`,
|
|
1204
|
+
kind: 'error',
|
|
1205
|
+
message: matches.length > 1
|
|
1206
|
+
? `ambiguous id "${cmd.id}" matches:\n${matches.map((r) => ` ${r.id.slice(0, 8)} ${r.task.slice(0, 50)}`).join('\n')}`
|
|
1207
|
+
: `session not found: ${cmd.id}`,
|
|
1208
|
+
});
|
|
1209
|
+
}
|
|
1210
|
+
else {
|
|
1211
|
+
queuedAppend({ id: `resume-err-${Date.now()}`, kind: 'error', message: 'no previous session in this cwd' });
|
|
1212
|
+
}
|
|
1097
1213
|
return;
|
|
1098
1214
|
}
|
|
1099
1215
|
const rec = await tuiStore.get(full);
|
|
@@ -1137,10 +1253,19 @@ export async function startRepl(opts = {}) {
|
|
|
1137
1253
|
}
|
|
1138
1254
|
case 'fork': {
|
|
1139
1255
|
try {
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1256
|
+
if (tuiSessionId) {
|
|
1257
|
+
const forked = await tuiStore.fork(tuiSessionId);
|
|
1258
|
+
const msgs = await tuiStore.loadMessages(forked.id);
|
|
1259
|
+
tuiSessionId = forked.id;
|
|
1260
|
+
sessionLabel = forked.task.slice(0, 40);
|
|
1261
|
+
queuedAppend({ id: `fork-${Date.now()}`, kind: 'text', text: `forked → session ${forked.id.slice(0, 8)} (${msgs.length} messages carried over)`, role: 'assistant' });
|
|
1262
|
+
}
|
|
1263
|
+
else {
|
|
1264
|
+
const base = sessionLabel || 'session';
|
|
1265
|
+
const rec = await tuiStore.create({ cwd, task: `${base} (fork)${cmd.prompt ? `: ${cmd.prompt}` : ''}`, config: { model, maxSteps: currentMaxSteps } });
|
|
1266
|
+
tuiSessionId = rec.id;
|
|
1267
|
+
queuedAppend({ id: `fork-${Date.now()}`, kind: 'text', text: `forked → session ${rec.id.slice(0, 8)}`, role: 'assistant' });
|
|
1268
|
+
}
|
|
1144
1269
|
}
|
|
1145
1270
|
catch (err) {
|
|
1146
1271
|
queuedAppend({ id: `fork-err-${Date.now()}`, kind: 'error', message: `fork failed: ${err instanceof Error ? err.message : String(err)}` });
|
|
@@ -1254,7 +1379,7 @@ export async function startRepl(opts = {}) {
|
|
|
1254
1379
|
}
|
|
1255
1380
|
else {
|
|
1256
1381
|
const v = r.value;
|
|
1257
|
-
const body =
|
|
1382
|
+
const body = readFileBody(v).slice(0, 6000);
|
|
1258
1383
|
queuedAppend({ id: `men2-${Date.now()}`, kind: 'text', text: `attached ${cmd.path} (${body.length} chars):\n${body}`, role: 'assistant' });
|
|
1259
1384
|
}
|
|
1260
1385
|
}
|
|
@@ -1287,7 +1412,7 @@ export async function startRepl(opts = {}) {
|
|
|
1287
1412
|
try {
|
|
1288
1413
|
const fr = await registry.execute('read_file', { path: cmd.target }, { cwd, env: process.env, nonInteractive: true });
|
|
1289
1414
|
if (fr.ok)
|
|
1290
|
-
extra = `\n\n--- ${cmd.target} ---\n${
|
|
1415
|
+
extra = `\n\n--- ${cmd.target} ---\n${readFileBody(fr.value).slice(0, 2000)}`;
|
|
1291
1416
|
}
|
|
1292
1417
|
catch { /* ignore */ }
|
|
1293
1418
|
}
|
|
@@ -1445,7 +1570,7 @@ export async function startRepl(opts = {}) {
|
|
|
1445
1570
|
try {
|
|
1446
1571
|
const fr = await registry.execute('read_file', { path: cmd.target }, { cwd, env: process.env, nonInteractive: true });
|
|
1447
1572
|
if (fr.ok)
|
|
1448
|
-
ctx = `File ${cmd.target}:\n${
|
|
1573
|
+
ctx = `File ${cmd.target}:\n${readFileBody(fr.value).slice(0, 6000)}`;
|
|
1449
1574
|
}
|
|
1450
1575
|
catch { /* symbol — answer without file context */ }
|
|
1451
1576
|
await answerReadOnly(`Explain ${cmd.target}: what it does, key logic, and gotchas.`, ctx || undefined);
|
|
@@ -1465,7 +1590,10 @@ export async function startRepl(opts = {}) {
|
|
|
1465
1590
|
queuedAppend({ id: `fmt2-${Date.now()}`, kind: 'text', text: 'prettier not installed — run `npm i -D prettier` first.', role: 'assistant' });
|
|
1466
1591
|
}
|
|
1467
1592
|
else {
|
|
1468
|
-
|
|
1593
|
+
// Strip double quotes from paths (" is illegal in Windows
|
|
1594
|
+
// filenames and a shell escape vector everywhere else).
|
|
1595
|
+
const safeFiles = files.map((f) => f.replace(/"/g, ''));
|
|
1596
|
+
const fv = await execShell(`npx prettier --write ${safeFiles.map((f) => `"${f}"`).join(' ')}`);
|
|
1469
1597
|
queuedAppend({ id: `fmt3-${Date.now()}`, kind: 'text', text: fv.exitCode === 0 ? `[format] formatted ${files.length} file(s)` : `[format] failed:\n${outCap(fv.stderr || fv.stdout)}`, role: 'assistant' });
|
|
1470
1598
|
}
|
|
1471
1599
|
}
|
|
@@ -1727,7 +1855,11 @@ export async function startRepl(opts = {}) {
|
|
|
1727
1855
|
}
|
|
1728
1856
|
else {
|
|
1729
1857
|
cwd = abs;
|
|
1730
|
-
|
|
1858
|
+
const { resetPersistentCwd } = await import('../tools/shell/shell-exec.js');
|
|
1859
|
+
resetPersistentCwd();
|
|
1860
|
+
const { clearReadHistory } = await import('../tools/fs/read-history.js');
|
|
1861
|
+
clearReadHistory();
|
|
1862
|
+
queuedAppend({ id: `cd2-${Date.now()}`, kind: 'text', text: `cwd → ${abs} (shell cwd + read guards reset; header refreshes on restart)`, role: 'assistant' });
|
|
1731
1863
|
}
|
|
1732
1864
|
}
|
|
1733
1865
|
return;
|
|
@@ -1743,7 +1875,7 @@ export async function startRepl(opts = {}) {
|
|
|
1743
1875
|
queuedAppend({ id: `att-err-${Date.now()}`, kind: 'error', message: `attach failed: ${r.error.message ?? 'read error'}` });
|
|
1744
1876
|
}
|
|
1745
1877
|
else {
|
|
1746
|
-
const body =
|
|
1878
|
+
const body = readFileBody(r.value);
|
|
1747
1879
|
attachedFiles.set(cmd.file, body.length);
|
|
1748
1880
|
queuedAppend({ id: `att2-${Date.now()}`, kind: 'text', text: `attached ${cmd.file} (${body.length} chars) — in context for future prompts`, role: 'assistant' });
|
|
1749
1881
|
}
|
|
@@ -1886,8 +2018,21 @@ export async function startRepl(opts = {}) {
|
|
|
1886
2018
|
const t = setTimeout(() => ctrl.abort(), 15_000);
|
|
1887
2019
|
const res = await fetch(cmd.url, { signal: ctrl.signal });
|
|
1888
2020
|
clearTimeout(t);
|
|
1889
|
-
|
|
1890
|
-
|
|
2021
|
+
// SSRF/OOM guard: refuse huge bodies before buffering.
|
|
2022
|
+
const declared = Number(res.headers.get('content-length') ?? 0);
|
|
2023
|
+
if (declared > 2 * 1024 * 1024) {
|
|
2024
|
+
queuedAppend({ id: `web-err-${Date.now()}`, kind: 'error', message: `refusing to fetch ${declared} byte body (2 MiB cap)` });
|
|
2025
|
+
return;
|
|
2026
|
+
}
|
|
2027
|
+
const raw = await res.text();
|
|
2028
|
+
const capped = raw.length > 256 * 1024;
|
|
2029
|
+
const text = raw
|
|
2030
|
+
.slice(0, 256 * 1024)
|
|
2031
|
+
.replace(/<[^>]+>/g, ' ')
|
|
2032
|
+
.replace(/\s+/g, ' ')
|
|
2033
|
+
.trim()
|
|
2034
|
+
.slice(0, 6000);
|
|
2035
|
+
queuedAppend({ id: `web2-${Date.now()}`, kind: 'text', text: `${cmd.url} [${res.status}]${capped ? ' [body capped at 256 KiB]' : ''}:\n${text || '(no text content)'}`, role: 'assistant' });
|
|
1891
2036
|
}
|
|
1892
2037
|
catch (err) {
|
|
1893
2038
|
queuedAppend({ id: `web-err-${Date.now()}`, kind: 'error', message: `fetch failed: ${err instanceof Error ? err.message : String(err)}` });
|
|
@@ -1906,7 +2051,7 @@ export async function startRepl(opts = {}) {
|
|
|
1906
2051
|
queuedAppend({ id: `read-err-${Date.now()}`, kind: 'error', message: `read failed: ${r.error.message ?? 'error'}` });
|
|
1907
2052
|
}
|
|
1908
2053
|
else {
|
|
1909
|
-
queuedAppend({ id: `read2-${Date.now()}`, kind: 'text', text: `${cmd.path}:\n${outCap(
|
|
2054
|
+
queuedAppend({ id: `read2-${Date.now()}`, kind: 'text', text: `${cmd.path}:\n${outCap(readFileBody(r.value))}`, role: 'assistant' });
|
|
1910
2055
|
}
|
|
1911
2056
|
}
|
|
1912
2057
|
catch (err) {
|
|
@@ -1952,9 +2097,19 @@ export async function startRepl(opts = {}) {
|
|
|
1952
2097
|
queuedAppend({ id: `commit2-${Date.now()}`, kind: 'text', text: 'Nothing staged — stage changes with `git add` first.', role: 'assistant' });
|
|
1953
2098
|
}
|
|
1954
2099
|
else {
|
|
1955
|
-
|
|
1956
|
-
const
|
|
1957
|
-
|
|
2100
|
+
// Message via temp file (git commit -F): zero shell interpolation.
|
|
2101
|
+
const { writeFile, unlink } = await import('node:fs/promises');
|
|
2102
|
+
const { join } = await import('node:path');
|
|
2103
|
+
const { tmpdir } = await import('node:os');
|
|
2104
|
+
const msgFile = join(tmpdir(), `klyro-commit-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.txt`);
|
|
2105
|
+
await writeFile(msgFile, cmd.message, 'utf-8');
|
|
2106
|
+
try {
|
|
2107
|
+
const v = await execShell(`git commit -F "${msgFile}"`);
|
|
2108
|
+
queuedAppend({ id: `commit3-${Date.now()}`, kind: 'text', text: v.exitCode === 0 ? `committed ${staged.length} file(s):\n${outCap(v.stdout)}` : `commit failed:\n${outCap(v.stderr || v.stdout)}`, role: 'assistant' });
|
|
2109
|
+
}
|
|
2110
|
+
finally {
|
|
2111
|
+
await unlink(msgFile).catch(() => undefined);
|
|
2112
|
+
}
|
|
1958
2113
|
}
|
|
1959
2114
|
}
|
|
1960
2115
|
catch (err) {
|
|
@@ -2024,11 +2179,16 @@ export async function startRepl(opts = {}) {
|
|
|
2024
2179
|
}
|
|
2025
2180
|
else {
|
|
2026
2181
|
try {
|
|
2027
|
-
const {
|
|
2182
|
+
const { startBackgroundArgv } = await import('../tools/shell/background.js');
|
|
2028
2183
|
const { resolve } = await import('node:path');
|
|
2029
2184
|
const abs = resolve(cwd, file);
|
|
2030
|
-
|
|
2031
|
-
|
|
2185
|
+
// argv form (shell:false) — the user-supplied path is never interpolated.
|
|
2186
|
+
const argv = process.platform === 'win32'
|
|
2187
|
+
? ['cmd', '/c', 'start', '', abs]
|
|
2188
|
+
: process.platform === 'darwin'
|
|
2189
|
+
? ['open', abs]
|
|
2190
|
+
: ['xdg-open', abs];
|
|
2191
|
+
startBackgroundArgv(argv, cwd);
|
|
2032
2192
|
queuedAppend({ id: `ed2-${Date.now()}`, kind: 'text', text: `opened ${abs} in background`, role: 'assistant' });
|
|
2033
2193
|
}
|
|
2034
2194
|
catch (err) {
|
|
@@ -2227,6 +2387,10 @@ export async function startRepl(opts = {}) {
|
|
|
2227
2387
|
if (!m) {
|
|
2228
2388
|
queuedAppend({ id: `env-err-${Date.now()}`, kind: 'error', message: 'usage: /env KEY=value' });
|
|
2229
2389
|
}
|
|
2390
|
+
else if (['NODE_OPTIONS', 'LD_PRELOAD', 'LD_LIBRARY_PATH'].includes(m[1])) {
|
|
2391
|
+
// Same injection-vector blocklist as shell_exec's filteredEnv.
|
|
2392
|
+
queuedAppend({ id: `env-err2-${Date.now()}`, kind: 'error', message: `refusing to set ${m[1]} (process injection vector)` });
|
|
2393
|
+
}
|
|
2230
2394
|
else {
|
|
2231
2395
|
process.env[m[1]] = m[2];
|
|
2232
2396
|
queuedAppend({ id: `env2-${Date.now()}`, kind: 'text', text: `set ${m[1]} (session-only)`, role: 'assistant' });
|
package/dist/cli/run.js
CHANGED
|
@@ -12,7 +12,7 @@ import { httpChatAdapter } from '../agent/provider-adapter.js';
|
|
|
12
12
|
import { anthropicAdapter } from '../agent/anthropic-adapter.js';
|
|
13
13
|
import { run } from '../agent/runtime.js';
|
|
14
14
|
import { builtinRegistry } from '../tools/registry.js';
|
|
15
|
-
import { builtinRules,
|
|
15
|
+
import { builtinRules, clonePolicyConfig, PolicyEngine } from '../policy/engine.js';
|
|
16
16
|
import { DenyAllApprovalPrompt } from '../policy/approval.js';
|
|
17
17
|
import { buildLevel6Context } from '../context/level6.js';
|
|
18
18
|
import { resolveSessionId } from '../persistence/session.js';
|
|
@@ -24,8 +24,14 @@ function readEnv(name, fallback) {
|
|
|
24
24
|
export async function runOnce(opts) {
|
|
25
25
|
const output = opts.output ?? 'human';
|
|
26
26
|
if (opts.dryRun) {
|
|
27
|
-
return dryRunReport(opts);
|
|
27
|
+
return await dryRunReport(opts);
|
|
28
28
|
}
|
|
29
|
+
// Fresh read-guard scope per run (shared module state otherwise leaks across runs).
|
|
30
|
+
try {
|
|
31
|
+
const { clearReadHistory } = await import('../tools/fs/read-history.js');
|
|
32
|
+
clearReadHistory();
|
|
33
|
+
}
|
|
34
|
+
catch { /* ignore */ }
|
|
29
35
|
let adapter = opts.adapter;
|
|
30
36
|
if (!adapter) {
|
|
31
37
|
const provider = opts.provider ?? 'openai';
|
|
@@ -52,7 +58,7 @@ export async function runOnce(opts) {
|
|
|
52
58
|
}
|
|
53
59
|
}
|
|
54
60
|
const registry = builtinRegistry();
|
|
55
|
-
const policy = new PolicyEngine(builtinRules(),
|
|
61
|
+
const policy = new PolicyEngine(builtinRules(), clonePolicyConfig());
|
|
56
62
|
const systemPrompt = await makeRunSystemPrompt(opts.cwd, opts.systemPrompt ?? defaultRunSystemPrompt);
|
|
57
63
|
// Level 9 — session setup (create or resume)
|
|
58
64
|
const persistEnabled = opts.persist !== false;
|
|
@@ -97,12 +103,16 @@ export async function runOnce(opts) {
|
|
|
97
103
|
initialTranscript = loadTranscript(opts.resumePath);
|
|
98
104
|
}
|
|
99
105
|
const ac = new AbortController();
|
|
106
|
+
const onSigint = () => {
|
|
107
|
+
stderr.write('\nklyro: SIGINT — aborting\n');
|
|
108
|
+
ac.abort();
|
|
109
|
+
};
|
|
100
110
|
if (opts.abortOnSigint !== false) {
|
|
101
|
-
process.
|
|
102
|
-
stderr.write('\nklyro: SIGINT — aborting\n');
|
|
103
|
-
ac.abort();
|
|
104
|
-
});
|
|
111
|
+
process.once('SIGINT', onSigint);
|
|
105
112
|
}
|
|
113
|
+
const doneSigint = () => {
|
|
114
|
+
process.removeListener('SIGINT', onSigint);
|
|
115
|
+
};
|
|
106
116
|
const verifyOpts = opts.verify === false
|
|
107
117
|
? { enabled: false }
|
|
108
118
|
: {
|
|
@@ -112,63 +122,69 @@ export async function runOnce(opts) {
|
|
|
112
122
|
timeoutMs: opts.verifyTimeoutMs,
|
|
113
123
|
requireVerify: opts.requireVerify,
|
|
114
124
|
};
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
125
|
+
let result;
|
|
126
|
+
try {
|
|
127
|
+
result = await run({
|
|
128
|
+
task: opts.task,
|
|
129
|
+
cwd: opts.cwd,
|
|
130
|
+
model: opts.model,
|
|
131
|
+
maxSteps: opts.maxSteps,
|
|
132
|
+
maxTokens: opts.maxTokens,
|
|
133
|
+
temperature: opts.temperature,
|
|
134
|
+
signal: ac.signal,
|
|
135
|
+
nonInteractive: true,
|
|
136
|
+
initialTranscript,
|
|
137
|
+
verify: verifyOpts,
|
|
138
|
+
persist: store && sessionId ? { store, sessionId } : undefined,
|
|
139
|
+
onEvent: (ev) => {
|
|
140
|
+
if (output === 'json') {
|
|
141
|
+
stdout.write(JSON.stringify(ev) + '\n');
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (output === 'silent') {
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
// human mode
|
|
148
|
+
if (ev.kind === 'text_delta')
|
|
149
|
+
stdout.write(ev.text);
|
|
150
|
+
else if (ev.kind === 'tool_call_start') {
|
|
151
|
+
stderr.write(`\n[tool] ${ev.name} {\n`);
|
|
152
|
+
}
|
|
153
|
+
else if (ev.kind === 'tool_call_end') {
|
|
154
|
+
stderr.write(`\n}\n`);
|
|
155
|
+
}
|
|
156
|
+
else if (ev.kind === 'tool_result') {
|
|
157
|
+
const summary = typeof ev.output === 'string' ? ev.output.slice(0, 200) : JSON.stringify(ev.output).slice(0, 200);
|
|
158
|
+
stderr.write(` -> ${ev.isError ? 'ERR ' : 'ok '}(${ev.latencyMs}ms) ${summary}\n`);
|
|
159
|
+
}
|
|
160
|
+
else if (ev.kind === 'policy_decision' && ev.action !== 'allow') {
|
|
161
|
+
stderr.write(` policy: ${ev.action}${ev.reason ? ` — ${ev.reason}` : ''}\n`);
|
|
162
|
+
}
|
|
163
|
+
else if (ev.kind === 'step_start') {
|
|
164
|
+
stderr.write(`\n[step ${ev.step}]\n`);
|
|
165
|
+
}
|
|
166
|
+
else if (ev.kind === 'verification_started') {
|
|
167
|
+
stderr.write(`\n[verify] running \`${ev.command}\`\n`);
|
|
168
|
+
}
|
|
169
|
+
else if (ev.kind === 'verification_succeeded') {
|
|
170
|
+
stderr.write(`[verify] passed\n`);
|
|
171
|
+
}
|
|
172
|
+
else if (ev.kind === 'verification_failed') {
|
|
173
|
+
stderr.write(`[verify] failed — ${ev.reason.slice(0, 200)}\n`);
|
|
174
|
+
}
|
|
175
|
+
else if (ev.kind === 'repair_started') {
|
|
176
|
+
stderr.write(`[repair] attempt ${ev.attempt}/${ev.maxAttempts}\n`);
|
|
177
|
+
}
|
|
178
|
+
else if (ev.kind === 'checkpoint_saved') {
|
|
179
|
+
if (output === 'human')
|
|
180
|
+
stderr.write(`[session ${ev.sessionId.slice(0, 8)} checkpoint]\n`);
|
|
181
|
+
}
|
|
182
|
+
},
|
|
183
|
+
}, { adapter, registry, policy, approval: new DenyAllApprovalPrompt(), systemPrompt });
|
|
184
|
+
}
|
|
185
|
+
finally {
|
|
186
|
+
doneSigint();
|
|
187
|
+
}
|
|
172
188
|
if (store && sessionId) {
|
|
173
189
|
// Finalize session status
|
|
174
190
|
const statusMap = {
|
|
@@ -189,7 +205,7 @@ export async function runOnce(opts) {
|
|
|
189
205
|
stderr.write('\n');
|
|
190
206
|
}
|
|
191
207
|
}
|
|
192
|
-
if (output
|
|
208
|
+
if (output === 'human')
|
|
193
209
|
stdout.write('\n');
|
|
194
210
|
if (result.status === 'max_steps') {
|
|
195
211
|
if (output === 'json')
|
|
@@ -221,8 +237,8 @@ export async function runOnce(opts) {
|
|
|
221
237
|
stderr.write('klyro: --require-verify: verification required but not passed\n');
|
|
222
238
|
return 8;
|
|
223
239
|
}
|
|
224
|
-
if (opts.requireVerify && !result.verification) {
|
|
225
|
-
//
|
|
240
|
+
if (opts.requireVerify && !result.verification && result.hasEdits) {
|
|
241
|
+
// Edits were made but no verification command found
|
|
226
242
|
if (output === 'json')
|
|
227
243
|
stdout.write(JSON.stringify({ kind: 'final', status: 'require_verify_missing' }) + '\n');
|
|
228
244
|
else
|
|
@@ -233,8 +249,11 @@ export async function runOnce(opts) {
|
|
|
233
249
|
stdout.write(JSON.stringify({ kind: 'final', status: 'ok', text: result.finalText }) + '\n');
|
|
234
250
|
return 0;
|
|
235
251
|
}
|
|
236
|
-
function dryRunReport(opts) {
|
|
237
|
-
|
|
252
|
+
async function dryRunReport(opts) {
|
|
253
|
+
// Assemble the REAL prompt (Level-6 context + KLYRO.md), not the bare base —
|
|
254
|
+
// otherwise dry-run shows a different prompt than production runs use.
|
|
255
|
+
const systemPromptFn = await makeRunSystemPrompt(opts.cwd, opts.systemPrompt ?? defaultRunSystemPrompt);
|
|
256
|
+
const systemPrompt = systemPromptFn({ cwd: opts.cwd });
|
|
238
257
|
const registry = builtinRegistry();
|
|
239
258
|
const rules = builtinRules();
|
|
240
259
|
const report = {
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 4.4 — KLYRO.md loader: ~/.klyro/KLYRO.md → root → KLYRO.local.md → subdir files lazily
|
|
3
|
+
*
|
|
4
|
+
* Safety: @import targets are contained to the project cwd (escapes are
|
|
5
|
+
* skipped, not read — a malicious KLYRO.md must not pull /etc/passwd or
|
|
6
|
+
* secret files into context), each file is capped, and total output is
|
|
7
|
+
* capped so a giant monorepo doc can't blow the context budget.
|
|
3
8
|
*/
|
|
4
9
|
export declare function loadKlyroMd(cwd: string): Promise<string>;
|
|
5
10
|
export declare function handleInit(cwd: string): Promise<string>;
|