klyro 0.1.63 → 1.0.1
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.d.ts +36 -0
- package/dist/agent/anthropic-adapter.js +73 -16
- package/dist/agent/capabilities.d.ts +145 -0
- package/dist/agent/capabilities.js +191 -0
- package/dist/agent/child-worker.d.ts +104 -0
- package/dist/agent/child-worker.js +250 -0
- package/dist/agent/orchestrator.d.ts +232 -0
- package/dist/agent/orchestrator.js +589 -0
- package/dist/agent/provider-adapter.d.ts +17 -0
- package/dist/agent/provider-adapter.js +35 -3
- package/dist/agent/registry.d.ts +1 -0
- package/dist/agent/registry.js +1 -0
- package/dist/agent/retry.d.ts +13 -2
- package/dist/agent/retry.js +21 -3
- package/dist/agent/runtime.d.ts +70 -8
- package/dist/agent/runtime.js +205 -34
- package/dist/agent/scoped-registry.d.ts +22 -0
- package/dist/agent/scoped-registry.js +42 -0
- package/dist/agent/task-manager.d.ts +115 -0
- package/dist/agent/task-manager.js +250 -0
- package/dist/agent/worker-spawner.d.ts +17 -12
- package/dist/agent/worker-spawner.js +26 -20
- package/dist/agent/worktree-manager.d.ts +74 -0
- package/dist/agent/worktree-manager.js +189 -0
- package/dist/checkpoints/store.js +30 -5
- package/dist/cli/auth.js +16 -1
- package/dist/cli/config.d.ts +9 -3
- package/dist/cli/config.js +64 -3
- package/dist/cli/dotenv.d.ts +3 -0
- package/dist/cli/dotenv.js +57 -0
- package/dist/cli/eval.d.ts +6 -1
- package/dist/cli/eval.js +9 -0
- package/dist/cli/repl.js +183 -17
- package/dist/cli/run.d.ts +10 -11
- package/dist/cli/run.js +176 -14
- package/dist/cli/update.d.ts +5 -0
- package/dist/cli/update.js +62 -10
- package/dist/context/import-graph.d.ts +2 -0
- package/dist/context/import-graph.js +31 -3
- package/dist/context/klyro-md.d.ts +6 -0
- package/dist/context/klyro-md.js +25 -16
- package/dist/context/memory.d.ts +8 -0
- package/dist/context/memory.js +50 -2
- package/dist/context/project-map.d.ts +6 -0
- package/dist/context/project-map.js +50 -2
- package/dist/context/repo-map.d.ts +2 -0
- package/dist/context/repo-map.js +31 -1
- package/dist/context/trust.d.ts +42 -0
- package/dist/context/trust.js +111 -0
- package/dist/events/catalog.d.ts +99 -0
- package/dist/index.js +93 -4
- package/dist/mcp/client.d.ts +55 -0
- package/dist/mcp/client.js +294 -0
- package/dist/mcp/config.d.ts +40 -0
- package/dist/mcp/config.js +99 -0
- package/dist/mcp/policy.d.ts +13 -0
- package/dist/mcp/policy.js +12 -0
- package/dist/mcp/registry.d.ts +72 -0
- package/dist/mcp/registry.js +244 -0
- package/dist/mcp/schema.d.ts +18 -0
- package/dist/mcp/schema.js +57 -0
- package/dist/mcp/trust.d.ts +20 -0
- package/dist/mcp/trust.js +74 -0
- package/dist/persistence/audit.d.ts +28 -0
- package/dist/persistence/audit.js +101 -1
- package/dist/persistence/store.d.ts +26 -2
- package/dist/persistence/store.js +140 -13
- package/dist/policy/approval.d.ts +14 -0
- package/dist/policy/approval.js +44 -2
- package/dist/policy/engine.d.ts +1 -0
- package/dist/policy/engine.js +91 -10
- package/dist/policy/secret-redactor.js +4 -0
- package/dist/providers/model-info.d.ts +17 -0
- package/dist/providers/model-info.js +35 -2
- package/dist/repl.d.ts +6 -0
- package/dist/repl.js +12 -7
- package/dist/tools/agent/spawn-agent.d.ts +9 -0
- package/dist/tools/agent/spawn-agent.js +50 -0
- package/dist/tools/agent/task-apply.d.ts +4 -0
- package/dist/tools/agent/task-apply.js +44 -0
- package/dist/tools/agent/task-get.d.ts +8 -0
- package/dist/tools/agent/task-get.js +40 -0
- package/dist/tools/agent/task-list.d.ts +4 -0
- package/dist/tools/agent/task-list.js +41 -0
- package/dist/tools/agent/task-stop.d.ts +6 -0
- package/dist/tools/agent/task-stop.js +39 -0
- package/dist/tools/agent/task-wait.d.ts +17 -0
- package/dist/tools/agent/task-wait.js +79 -0
- package/dist/tools/fs/apply-patch.js +71 -0
- package/dist/tools/fs/edit-file.js +65 -0
- package/dist/tools/fs/multi-edit.d.ts +4 -0
- package/dist/tools/fs/multi-edit.js +66 -0
- package/dist/tools/fs/write-file.js +67 -0
- package/dist/tools/plan/todo-write.d.ts +1 -1
- package/dist/tools/registry.js +12 -0
- package/dist/tools/shell/background.js +6 -3
- package/dist/tools/shell/sandbox.d.ts +51 -0
- package/dist/tools/shell/sandbox.js +143 -0
- package/dist/tools/shell/shell-exec.d.ts +1 -0
- package/dist/tools/shell/shell-exec.js +83 -11
- package/dist/tools/shell/worker-entry.d.ts +12 -0
- package/dist/tools/shell/worker-entry.js +43 -0
- package/dist/tools/types.d.ts +18 -0
- package/dist/tools/verify/run-verify.js +3 -1
- package/dist/trace/writer.d.ts +13 -0
- package/dist/trace/writer.js +55 -4
- package/dist/tui/app.js +1 -1
- package/dist/tui/approval.js +20 -21
- package/dist/util.d.ts +1 -0
- package/dist/util.js +1 -0
- package/dist/verification/baseline.js +17 -3
- package/dist/verification/classify.js +23 -12
- package/dist/verification/engine.js +3 -1
- package/dist/verification/registry.d.ts +2 -0
- package/dist/verification/registry.js +33 -0
- package/dist/verification/scoped.js +28 -6
- package/package.json +1 -1
|
@@ -7,6 +7,19 @@ import * as crypto from 'node:crypto';
|
|
|
7
7
|
function ckptDir(cwd) {
|
|
8
8
|
return path.join(cwd, '.klyro', 'checkpoints');
|
|
9
9
|
}
|
|
10
|
+
/** Best-effort fsync of a just-written file (crash safety). */
|
|
11
|
+
async function fsyncFile(p) {
|
|
12
|
+
try {
|
|
13
|
+
const fh = await fs.open(p, 'r+');
|
|
14
|
+
try {
|
|
15
|
+
await fh.sync();
|
|
16
|
+
}
|
|
17
|
+
finally {
|
|
18
|
+
await fh.close();
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
catch { /* ignore on Windows */ }
|
|
22
|
+
}
|
|
10
23
|
/**
|
|
11
24
|
* Resolve a checkpoint file list entry inside cwd. Returns null for anything
|
|
12
25
|
* escaping the project (no arbitrary read/write outside cwd, via either the
|
|
@@ -39,6 +52,7 @@ export async function snapshot(cwd, files) {
|
|
|
39
52
|
continue;
|
|
40
53
|
await fs.mkdir(path.dirname(out), { recursive: true });
|
|
41
54
|
await fs.writeFile(out, data);
|
|
55
|
+
await fsyncFile(out);
|
|
42
56
|
kept.push(rel);
|
|
43
57
|
}
|
|
44
58
|
catch (e) {
|
|
@@ -47,8 +61,11 @@ export async function snapshot(cwd, files) {
|
|
|
47
61
|
missing.push(f);
|
|
48
62
|
}
|
|
49
63
|
}
|
|
50
|
-
// Save meta
|
|
51
|
-
|
|
64
|
+
// Save meta (fsync before the checkpoint is visible — mirrors the
|
|
65
|
+
// SessionStore.writeIndex atomic pattern).
|
|
66
|
+
const metaPath = path.join(dest, '.meta.json');
|
|
67
|
+
await fs.writeFile(metaPath, JSON.stringify({ id, files: kept, missing, ts: Date.now() }, null, 2));
|
|
68
|
+
await fsyncFile(metaPath);
|
|
52
69
|
// Best-effort last.diff for the repair guard (guardRepair reads it).
|
|
53
70
|
try {
|
|
54
71
|
const { spawn } = await import('node:child_process');
|
|
@@ -86,8 +103,15 @@ export async function snapshot(cwd, files) {
|
|
|
86
103
|
resolve('');
|
|
87
104
|
});
|
|
88
105
|
});
|
|
89
|
-
if (diffText)
|
|
106
|
+
if (diffText) {
|
|
90
107
|
await fs.writeFile(path.join(dir, 'last.diff'), diffText, 'utf-8');
|
|
108
|
+
// Per-checkpoint diff file (best-effort); the repair guard keeps
|
|
109
|
+
// reading last.diff, so its behavior is unchanged.
|
|
110
|
+
try {
|
|
111
|
+
await fs.writeFile(path.join(dir, `${id}.diff`), diffText, 'utf-8');
|
|
112
|
+
}
|
|
113
|
+
catch { /* best-effort only */ }
|
|
114
|
+
}
|
|
91
115
|
}
|
|
92
116
|
catch { /* best-effort only */ }
|
|
93
117
|
return id;
|
|
@@ -96,8 +120,9 @@ export async function listCheckpoints(cwd) {
|
|
|
96
120
|
const dir = ckptDir(cwd);
|
|
97
121
|
try {
|
|
98
122
|
const entries = await fs.readdir(dir);
|
|
99
|
-
// last.diff is a guard artifact
|
|
100
|
-
|
|
123
|
+
// last.diff is a guard artifact and <id>.diff files are per-checkpoint
|
|
124
|
+
// diffs — neither is a checkpoint (must never be an undo target).
|
|
125
|
+
return entries.filter((e) => !e.startsWith('.') && e !== 'last.diff' && !e.endsWith('.diff')).sort();
|
|
101
126
|
}
|
|
102
127
|
catch {
|
|
103
128
|
return [];
|
package/dist/cli/auth.js
CHANGED
|
@@ -111,7 +111,11 @@ export async function runLogout(provider) {
|
|
|
111
111
|
const creds = JSON.parse(raw);
|
|
112
112
|
if (provider) {
|
|
113
113
|
delete creds[provider];
|
|
114
|
-
await fs.writeFile(credPath(), JSON.stringify(creds, null, 2),
|
|
114
|
+
await fs.writeFile(credPath(), JSON.stringify(creds, null, 2), { mode: 0o600 });
|
|
115
|
+
try {
|
|
116
|
+
await fs.chmod(credPath(), 0o600);
|
|
117
|
+
}
|
|
118
|
+
catch { /* ignore on Windows */ }
|
|
115
119
|
process.stdout.write(`Removed ${provider} key\n`);
|
|
116
120
|
}
|
|
117
121
|
else {
|
|
@@ -128,6 +132,17 @@ export async function runLogout(provider) {
|
|
|
128
132
|
}
|
|
129
133
|
export function getStoredKey(provider) {
|
|
130
134
|
try {
|
|
135
|
+
// Warn (don't refuse) when the credentials file is group/other-readable.
|
|
136
|
+
// Refusing would lock out existing users with old umasks; warn instead.
|
|
137
|
+
if (process.platform !== 'win32') {
|
|
138
|
+
try {
|
|
139
|
+
const st = fsSync.statSync(credPath());
|
|
140
|
+
if ((st.mode & 0o077) !== 0) {
|
|
141
|
+
process.stderr.write(`warning: credentials file ${credPath()} is group/other-readable (mode ${(st.mode & 0o777).toString(8)}) — run chmod 600 on it\n`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
catch { /* missing file → no warning */ }
|
|
145
|
+
}
|
|
131
146
|
const raw = fsSync.readFileSync(credPath(), 'utf-8');
|
|
132
147
|
const creds = JSON.parse(raw);
|
|
133
148
|
const v = creds[provider];
|
package/dist/cli/config.d.ts
CHANGED
|
@@ -46,12 +46,18 @@ export interface PermissionRules {
|
|
|
46
46
|
* without re-prompting.
|
|
47
47
|
*/
|
|
48
48
|
export declare function loadPermissionRules(cwd?: string): Promise<PermissionRules>;
|
|
49
|
+
export declare function isValidAllowRule(rule: string): boolean;
|
|
49
50
|
/**
|
|
50
|
-
* Persist an "always allow" pattern
|
|
51
|
-
* (
|
|
51
|
+
* Persist an "always allow" pattern. Validates the rule against the strict
|
|
52
|
+
* grammar (throws `invalid rule` otherwise). When `cwd` is a project dir
|
|
53
|
+
* (exists + contains .git or .klyro), writes to <cwd>/.klyro/settings.json;
|
|
54
|
+
* otherwise writes to the home settings file. Returns whether it was
|
|
52
55
|
* added (false when already present) and the file written.
|
|
56
|
+
*
|
|
57
|
+
* NOTE for the repl integrator (sibling-owned repl.ts): pass the session
|
|
58
|
+
* cwd as the second arg to persist project-scoped allows.
|
|
53
59
|
*/
|
|
54
|
-
export declare function persistAllowRule(rule: string): Promise<{
|
|
60
|
+
export declare function persistAllowRule(rule: string, cwd?: string): Promise<{
|
|
55
61
|
added: boolean;
|
|
56
62
|
path: string;
|
|
57
63
|
}>;
|
package/dist/cli/config.js
CHANGED
|
@@ -338,11 +338,72 @@ export async function loadPermissionRules(cwd = process.cwd()) {
|
|
|
338
338
|
};
|
|
339
339
|
}
|
|
340
340
|
/**
|
|
341
|
-
*
|
|
342
|
-
*
|
|
341
|
+
* Strict allow-rule grammar: `tool` or `tool(glob)` where tool is
|
|
342
|
+
* lowercase alnum+underscore and glob contains no parens.
|
|
343
|
+
*/
|
|
344
|
+
const ALLOW_RULE_RE = /^[a-z0-9_]+\([^()]*\)$|^[a-z0-9_]+$/;
|
|
345
|
+
export function isValidAllowRule(rule) {
|
|
346
|
+
return ALLOW_RULE_RE.test(rule.trim());
|
|
347
|
+
}
|
|
348
|
+
function isProjectDir(cwd) {
|
|
349
|
+
try {
|
|
350
|
+
const st = fsSync.statSync(cwd);
|
|
351
|
+
if (!st.isDirectory())
|
|
352
|
+
return false;
|
|
353
|
+
}
|
|
354
|
+
catch {
|
|
355
|
+
return false;
|
|
356
|
+
}
|
|
357
|
+
return fsSync.existsSync(path.join(cwd, '.git')) || fsSync.existsSync(path.join(cwd, '.klyro'));
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* Persist an "always allow" pattern. Validates the rule against the strict
|
|
361
|
+
* grammar (throws `invalid rule` otherwise). When `cwd` is a project dir
|
|
362
|
+
* (exists + contains .git or .klyro), writes to <cwd>/.klyro/settings.json;
|
|
363
|
+
* otherwise writes to the home settings file. Returns whether it was
|
|
343
364
|
* added (false when already present) and the file written.
|
|
365
|
+
*
|
|
366
|
+
* NOTE for the repl integrator (sibling-owned repl.ts): pass the session
|
|
367
|
+
* cwd as the second arg to persist project-scoped allows.
|
|
344
368
|
*/
|
|
345
|
-
export async function persistAllowRule(rule) {
|
|
369
|
+
export async function persistAllowRule(rule, cwd) {
|
|
370
|
+
if (!isValidAllowRule(rule)) {
|
|
371
|
+
throw new Error(`invalid rule: ${rule}`);
|
|
372
|
+
}
|
|
373
|
+
// Project-scoped persist when cwd is a project dir.
|
|
374
|
+
if (cwd && isProjectDir(cwd)) {
|
|
375
|
+
const projPath = path.join(cwd, '.klyro', 'settings.json');
|
|
376
|
+
let cfg = {};
|
|
377
|
+
try {
|
|
378
|
+
const raw = await fs.readFile(projPath, 'utf-8');
|
|
379
|
+
cfg = validateConfig(parseJsonc(raw, projPath), projPath);
|
|
380
|
+
}
|
|
381
|
+
catch (err) {
|
|
382
|
+
const e = err;
|
|
383
|
+
if (e.code !== 'ENOENT')
|
|
384
|
+
throw err;
|
|
385
|
+
}
|
|
386
|
+
const allow = asStringArray(cfg.allow);
|
|
387
|
+
if (allow.includes(rule))
|
|
388
|
+
return { added: false, path: projPath };
|
|
389
|
+
cfg.allow = [...allow, rule];
|
|
390
|
+
await fs.mkdir(path.dirname(projPath), { recursive: true });
|
|
391
|
+
const tmp = `${projPath}.tmp-${process.pid}-${Date.now()}`;
|
|
392
|
+
const data = JSON.stringify(validateConfig(cfg, projPath), null, 2) + '\n';
|
|
393
|
+
await fs.writeFile(tmp, data, { mode: 0o600 });
|
|
394
|
+
try {
|
|
395
|
+
await fs.rename(tmp, projPath);
|
|
396
|
+
}
|
|
397
|
+
catch (err) {
|
|
398
|
+
await fs.unlink(tmp).catch(() => undefined);
|
|
399
|
+
throw err;
|
|
400
|
+
}
|
|
401
|
+
try {
|
|
402
|
+
await fs.chmod(projPath, 0o600);
|
|
403
|
+
}
|
|
404
|
+
catch { /* ignore on Windows */ }
|
|
405
|
+
return { added: true, path: projPath };
|
|
406
|
+
}
|
|
346
407
|
const cfg = await loadConfig();
|
|
347
408
|
const allow = asStringArray(cfg.allow);
|
|
348
409
|
if (allow.includes(rule))
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* P0.5 — `.env` auto-load (r-11-17.md).
|
|
3
|
+
*
|
|
4
|
+
* Loads `<cwd>/.env` at startup so `KLYRO_API_KEY` etc. work without `export`.
|
|
5
|
+
* Rules:
|
|
6
|
+
* - Never overwrites an already-set variable (explicit env wins).
|
|
7
|
+
* - Only `KEY=VALUE` lines; `#` comments and `export ` prefixes tolerated.
|
|
8
|
+
* - Single/double quotes stripped; missing file is a no-op (never throws).
|
|
9
|
+
*/
|
|
10
|
+
import * as fs from 'node:fs';
|
|
11
|
+
import * as path from 'node:path';
|
|
12
|
+
export function parseDotenv(text) {
|
|
13
|
+
const out = {};
|
|
14
|
+
for (const rawLine of text.split('\n')) {
|
|
15
|
+
const line = rawLine.trim();
|
|
16
|
+
if (!line || line.startsWith('#'))
|
|
17
|
+
continue;
|
|
18
|
+
const body = line.startsWith('export ') ? line.slice('export '.length).trim() : line;
|
|
19
|
+
const eq = body.indexOf('=');
|
|
20
|
+
if (eq <= 0)
|
|
21
|
+
continue;
|
|
22
|
+
const key = body.slice(0, eq).trim();
|
|
23
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
|
|
24
|
+
continue;
|
|
25
|
+
let val = body.slice(eq + 1).trim();
|
|
26
|
+
if (val.length >= 2 && ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'")))) {
|
|
27
|
+
val = val.slice(1, -1);
|
|
28
|
+
}
|
|
29
|
+
// Strip trailing inline comments on unquoted values.
|
|
30
|
+
if (!body.slice(eq + 1).trim().startsWith('"') && !body.slice(eq + 1).trim().startsWith("'")) {
|
|
31
|
+
const hash = val.indexOf(' #');
|
|
32
|
+
if (hash >= 0)
|
|
33
|
+
val = val.slice(0, hash).trimEnd();
|
|
34
|
+
}
|
|
35
|
+
out[key] = val;
|
|
36
|
+
}
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
/** Load `<cwd>/.env` into `process.env` (no-clobber). Returns loaded keys. */
|
|
40
|
+
export function loadDotenv(cwd) {
|
|
41
|
+
let text;
|
|
42
|
+
try {
|
|
43
|
+
text = fs.readFileSync(path.join(cwd, '.env'), 'utf-8');
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
48
|
+
const parsed = parseDotenv(text);
|
|
49
|
+
const loaded = [];
|
|
50
|
+
for (const [k, v] of Object.entries(parsed)) {
|
|
51
|
+
if (process.env[k] === undefined) {
|
|
52
|
+
process.env[k] = v;
|
|
53
|
+
loaded.push(k);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return loaded;
|
|
57
|
+
}
|
package/dist/cli/eval.d.ts
CHANGED
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
* is set, emits one JSON object per line. Exit code 0 if all pass, 1
|
|
40
40
|
* otherwise.
|
|
41
41
|
*/
|
|
42
|
-
import { type RunResult } from '../agent/runtime.js';
|
|
42
|
+
import { type RunResult, type VerifyMode } from '../agent/runtime.js';
|
|
43
43
|
import type { ProviderAdapter } from '../agent/provider-adapter.js';
|
|
44
44
|
export interface EvalScenario {
|
|
45
45
|
name: string;
|
|
@@ -47,6 +47,11 @@ export interface EvalScenario {
|
|
|
47
47
|
model?: string;
|
|
48
48
|
maxSteps?: number;
|
|
49
49
|
maxTokens?: number;
|
|
50
|
+
/** Verification passthrough (default unchanged: strict pipeline when omitted). */
|
|
51
|
+
verify?: {
|
|
52
|
+
mode?: VerifyMode;
|
|
53
|
+
command?: string;
|
|
54
|
+
};
|
|
50
55
|
/** Adapter events to script, as [eventKind, ...args] tuples. */
|
|
51
56
|
scripted_events?: Array<Array<unknown[]>>;
|
|
52
57
|
expect?: {
|
package/dist/cli/eval.js
CHANGED
|
@@ -236,6 +236,15 @@ export async function runScenario(sc) {
|
|
|
236
236
|
maxSteps: sc.maxSteps,
|
|
237
237
|
maxTokens: sc.maxTokens,
|
|
238
238
|
nonInteractive: true,
|
|
239
|
+
...(sc.verify
|
|
240
|
+
? {
|
|
241
|
+
verify: {
|
|
242
|
+
enabled: true,
|
|
243
|
+
...(sc.verify.command !== undefined ? { command: sc.verify.command } : {}),
|
|
244
|
+
...(sc.verify.mode !== undefined ? { mode: sc.verify.mode } : {}),
|
|
245
|
+
},
|
|
246
|
+
}
|
|
247
|
+
: {}),
|
|
239
248
|
}, {
|
|
240
249
|
adapter,
|
|
241
250
|
registry,
|
package/dist/cli/repl.js
CHANGED
|
@@ -12,7 +12,10 @@ import { render } from 'ink';
|
|
|
12
12
|
import { App } from '../tui/app.js';
|
|
13
13
|
import { httpChatAdapter } from '../agent/provider-adapter.js';
|
|
14
14
|
import { anthropicAdapter } from '../agent/anthropic-adapter.js';
|
|
15
|
+
import { retryingAdapter } from '../agent/retry.js';
|
|
16
|
+
import { globalBus } from '../events/bus.js';
|
|
15
17
|
import { run } from '../agent/runtime.js';
|
|
18
|
+
import { resolveSystemPrompt } from '../agent/runtime.js';
|
|
16
19
|
import { builtinRegistry } from '../tools/registry.js';
|
|
17
20
|
import { builtinRules, clonePolicyConfig, PolicyEngine } from '../policy/engine.js';
|
|
18
21
|
import { buildLevel6Context } from '../context/level6.js';
|
|
@@ -26,8 +29,18 @@ import { MouseFilter, MOUSE_ENABLE, MOUSE_DISABLE, createReadWrapper } from '../
|
|
|
26
29
|
import { inferProviderFromBaseURL } from '../agent/registry.js';
|
|
27
30
|
import { getDefaultSessionStore } from '../persistence/session.js';
|
|
28
31
|
import { buildSystemPrompt, parseImageInput } from '../context/system-prompt.js';
|
|
32
|
+
import { memoryBlock } from '../context/memory.js';
|
|
29
33
|
import { estimateCost } from '../providers/model-info.js';
|
|
34
|
+
import { ContextTrust } from '../context/trust.js';
|
|
35
|
+
import { redact } from '../policy/secret-redactor.js';
|
|
30
36
|
export async function startRepl(opts = {}) {
|
|
37
|
+
// P0.5 — load <cwd>/.env first so provider resolution below sees KLYRO_*
|
|
38
|
+
// vars without `export`. Never throws; explicit env wins (no-clobber).
|
|
39
|
+
try {
|
|
40
|
+
const { loadDotenv } = await import('./dotenv.js');
|
|
41
|
+
loadDotenv(opts.cwd ?? process.cwd());
|
|
42
|
+
}
|
|
43
|
+
catch { /* ignore */ }
|
|
31
44
|
// Reuse the same provider resolution as legacy repl.ts — probes local
|
|
32
45
|
// Ollama / LM Studio / vLLM when env is not fully set, so bare `klyro`
|
|
33
46
|
// works with a local model just like `klyro chat` does.
|
|
@@ -81,6 +94,66 @@ export async function startRepl(opts = {}) {
|
|
|
81
94
|
catch {
|
|
82
95
|
/* ignore — engine defaults stand */
|
|
83
96
|
}
|
|
97
|
+
// Best-effort MCP tools: never fatal, never prompts. Servers live for the
|
|
98
|
+
// process lifetime, so no close wiring here (unlike runOnce's finally).
|
|
99
|
+
// NOTE: the actual loadAndRegisterMcp call lives below (after the approval
|
|
100
|
+
// object exists) so project-server approval can route through it.
|
|
101
|
+
// Local inline sanitizer for the approval prompt (duplicates the shared
|
|
102
|
+
// sanitizeForPrompt in sibling-owned policy/approval.ts — intentionally
|
|
103
|
+
// not imported; kept local to avoid coupling the REPL to that module).
|
|
104
|
+
function sanitizeForPrompt(s) {
|
|
105
|
+
return s.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]|\x1b\[[0-9;]*[A-Za-z]/g, '').slice(0, 120);
|
|
106
|
+
}
|
|
107
|
+
async function loadMcpTools() {
|
|
108
|
+
try {
|
|
109
|
+
const { loadAndRegisterMcp } = await import('../mcp/registry.js');
|
|
110
|
+
const mcp = await loadAndRegisterMcp({
|
|
111
|
+
cwd,
|
|
112
|
+
registry,
|
|
113
|
+
policy,
|
|
114
|
+
// CONTRACT (sibling D): project-server approval hook. Registries that
|
|
115
|
+
// predate the contract ignore the unknown opt (checked via try/catch
|
|
116
|
+
// below with a skip-with-warning fallback).
|
|
117
|
+
approveProjectServer: async (info) => {
|
|
118
|
+
// Show the disabled state alongside source before asking, so a
|
|
119
|
+
// disabled server's approval can't be mistaken for activation.
|
|
120
|
+
let disabledNote = '';
|
|
121
|
+
try {
|
|
122
|
+
const { loadMcpServers } = await import('../mcp/config.js');
|
|
123
|
+
const cfg = loadMcpServers(cwd);
|
|
124
|
+
const spec = cfg.servers[info.name];
|
|
125
|
+
if (spec?.disabled)
|
|
126
|
+
disabledNote = ' (disabled in config)';
|
|
127
|
+
}
|
|
128
|
+
catch { /* ignore — prompt without the note */ }
|
|
129
|
+
const safeName = sanitizeForPrompt(info.name);
|
|
130
|
+
const safeSource = sanitizeForPrompt(info.source);
|
|
131
|
+
try {
|
|
132
|
+
const choice = await approval.ask({
|
|
133
|
+
toolName: 'mcp_project_server',
|
|
134
|
+
reason: `MCP project server "${safeName}"${disabledNote} from ${safeSource} — approve to register its tools`,
|
|
135
|
+
summary: safeName,
|
|
136
|
+
});
|
|
137
|
+
return choice === 'allow' || choice === 'always' || choice === 'always-persist';
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
for (const e of mcp.errors)
|
|
145
|
+
process.stderr.write(`klyro: mcp ${e.server}: ${e.message}\n`);
|
|
146
|
+
if (mcp.registered.length > 0) {
|
|
147
|
+
process.stderr.write(`klyro: mcp tools: ${mcp.registered.join(', ')}\n`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
try {
|
|
152
|
+
process.stderr.write('klyro: mcp project-server approval skipped (registry unavailable)\n');
|
|
153
|
+
}
|
|
154
|
+
catch { /* ignore */ }
|
|
155
|
+
}
|
|
156
|
+
}
|
|
84
157
|
const providerKind = inferProviderFromBaseURL(baseUrl);
|
|
85
158
|
// Local Ollama exposes OpenAI-compat but hostname could contain "anthropic"
|
|
86
159
|
// via proxy — don't try anthropic adapter with empty key (would 401).
|
|
@@ -93,20 +166,37 @@ export async function startRepl(opts = {}) {
|
|
|
93
166
|
let currentApiKey = apiKey;
|
|
94
167
|
let currentMaxSteps = opts.maxSteps ?? 30;
|
|
95
168
|
let effortLevel = 'medium';
|
|
96
|
-
|
|
169
|
+
// Retry wrapper lives inside the factory so the initial construction AND
|
|
170
|
+
// the /provider switch + /reload rebuilds (which re-call buildAdapter)
|
|
171
|
+
// all stay wrapped. REPL has no session id at adapter scope, so retry
|
|
172
|
+
// telemetry uses 'ephemeral'.
|
|
173
|
+
const buildAdapter = (prov, url, key) => retryingAdapter(prov === 'anthropic'
|
|
97
174
|
? anthropicAdapter({ baseURL: url, apiKey: key, timeoutMs: 60_000 })
|
|
98
|
-
: httpChatAdapter({ baseURL: url, apiKey: key, timeoutMs: 60_000 })
|
|
175
|
+
: httpChatAdapter({ baseURL: url, apiKey: key, timeoutMs: 60_000 }), {
|
|
176
|
+
onRetry: (info) => {
|
|
177
|
+
globalBus.emit({
|
|
178
|
+
type: 'provider.retry',
|
|
179
|
+
ts: Date.now(),
|
|
180
|
+
sessionId: 'ephemeral',
|
|
181
|
+
attempt: info.attempt,
|
|
182
|
+
status: info.status,
|
|
183
|
+
...(info.retryAfterMs !== undefined ? { retryAfterMs: info.retryAfterMs } : {}),
|
|
184
|
+
});
|
|
185
|
+
},
|
|
186
|
+
});
|
|
99
187
|
let adapter = buildAdapter(currentProvider, currentBaseUrl, currentApiKey);
|
|
100
188
|
const ctxBlock = await buildLevel6Context({ cwd });
|
|
101
189
|
let ctxPrefix = ctxBlock.formatted ? `\n\n<context>\n${ctxBlock.formatted}\n</context>` : '';
|
|
102
|
-
// 4.4 KLYRO.md hierarchy (mutable — /reload refreshes)
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
190
|
+
// 4.4 KLYRO.md hierarchy (mutable — /reload refreshes). Content is gated
|
|
191
|
+
// through the context trust store (see loadKlyroBlockTrust below) — the
|
|
192
|
+
// initial value stays empty until the gated load runs after approval setup.
|
|
193
|
+
let klyroBlock = '';
|
|
194
|
+
// 2.3 layered system prompt (split shape: stable base + volatile telemetry suffix)
|
|
106
195
|
const systemPromptFn = (_ctx) => {
|
|
107
|
-
|
|
108
|
-
const
|
|
109
|
-
|
|
196
|
+
// 8.4 — inject session memory so memory_write actually takes effect.
|
|
197
|
+
const memBlock = memoryBlock(cwd);
|
|
198
|
+
const base = buildSystemPrompt({ cwd, model, extraSystem: opts.systemPrompt, appendSystem: ctxPrefix + klyroBlock + memBlock });
|
|
199
|
+
return _ctx.telemetry ? { system: base, suffix: _ctx.telemetry } : { system: base };
|
|
110
200
|
};
|
|
111
201
|
let ac = new AbortController();
|
|
112
202
|
// When the TUI is mounted, use the inline Ink prompt. Otherwise
|
|
@@ -137,6 +227,45 @@ export async function startRepl(opts = {}) {
|
|
|
137
227
|
});
|
|
138
228
|
},
|
|
139
229
|
});
|
|
230
|
+
// Context-file trust gate (context/trust.js): KLYRO.md / AGENTS.md content
|
|
231
|
+
// only enters the prompt after the trust store approves it. Untrusted files
|
|
232
|
+
// prompt via the repl approval object (allow/always/always-persist → true,
|
|
233
|
+
// deny → false); every decision emits context.trust_prompt on globalBus.
|
|
234
|
+
const trustStore = new ContextTrust();
|
|
235
|
+
async function loadKlyroBlockTrust() {
|
|
236
|
+
try {
|
|
237
|
+
const { loadTrustedKlyroMd } = await import('../context/trust.js');
|
|
238
|
+
const text = await loadTrustedKlyroMd(cwd, {
|
|
239
|
+
trust: trustStore,
|
|
240
|
+
approve: async (file, reason) => {
|
|
241
|
+
try {
|
|
242
|
+
const choice = await approval.ask({
|
|
243
|
+
toolName: 'context_trust',
|
|
244
|
+
reason: `untrusted context file (${reason}): ${file.path}`,
|
|
245
|
+
summary: file.path,
|
|
246
|
+
});
|
|
247
|
+
return choice === 'allow' || choice === 'always' || choice === 'always-persist';
|
|
248
|
+
}
|
|
249
|
+
catch {
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
252
|
+
},
|
|
253
|
+
sessionId: typeof tuiSessionId === 'string' ? tuiSessionId : 'ephemeral',
|
|
254
|
+
emit: (ev) => globalBus.emit(ev),
|
|
255
|
+
});
|
|
256
|
+
return text ? `\n\n<KLYRO.md>\n${text.slice(0, 4000)}\n</KLYRO.md>` : '';
|
|
257
|
+
}
|
|
258
|
+
catch {
|
|
259
|
+
return '';
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
// MCP servers need the approval object above — load them now.
|
|
263
|
+
await loadMcpTools();
|
|
264
|
+
// Non-TUI runs can prompt on stdin immediately; the TUI defers the gated
|
|
265
|
+
// load to onMounted (the modal can't render before mount — see below).
|
|
266
|
+
if (!useTui) {
|
|
267
|
+
klyroBlock = await loadKlyroBlockTrust().catch(() => '');
|
|
268
|
+
}
|
|
140
269
|
let inflight = null;
|
|
141
270
|
let lastStatus = null;
|
|
142
271
|
const pendingQueue = [];
|
|
@@ -440,14 +569,41 @@ export async function startRepl(opts = {}) {
|
|
|
440
569
|
return s.length > cap ? s.slice(0, cap) + `\n... [truncated ${s.length - cap} chars]` : s;
|
|
441
570
|
}
|
|
442
571
|
async function execShell(command, timeoutMs = 120_000) {
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
572
|
+
// Internal shell executions bypass the agent runtime's event path, so
|
|
573
|
+
// audit them here: a redacted tool.call before, the matching tool.result
|
|
574
|
+
// after. One helper covers every slash handler (/commit, /push, …).
|
|
575
|
+
const callId = `internal-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
576
|
+
const sessionId = typeof tuiSessionId === 'string' ? tuiSessionId : 'ephemeral';
|
|
577
|
+
const startedAt = Date.now();
|
|
578
|
+
globalBus.emit({
|
|
579
|
+
type: 'tool.call', ts: startedAt, sessionId, callId,
|
|
580
|
+
name: 'shell_exec', input: { internal: true, command: redact(command).slice(0, 2000) },
|
|
581
|
+
});
|
|
582
|
+
try {
|
|
583
|
+
const r = await registry.execute('shell_exec', { command, timeoutMs }, { cwd, env: process.env, nonInteractive: true });
|
|
584
|
+
if (!r.ok)
|
|
585
|
+
throw new Error(r.error.message ?? 'shell failed');
|
|
586
|
+
const v = r.value;
|
|
587
|
+
globalBus.emit({
|
|
588
|
+
type: 'tool.result', ts: Date.now(), sessionId, callId,
|
|
589
|
+
name: 'shell_exec', output: { internal: true, exitCode: v.exitCode },
|
|
590
|
+
isError: false, latencyMs: Date.now() - startedAt,
|
|
591
|
+
});
|
|
592
|
+
return v;
|
|
593
|
+
}
|
|
594
|
+
catch (err) {
|
|
595
|
+
globalBus.emit({
|
|
596
|
+
type: 'tool.result', ts: Date.now(), sessionId, callId,
|
|
597
|
+
name: 'shell_exec', output: { internal: true, error: err instanceof Error ? err.message : String(err) },
|
|
598
|
+
isError: true, latencyMs: Date.now() - startedAt,
|
|
599
|
+
});
|
|
600
|
+
throw err;
|
|
601
|
+
}
|
|
447
602
|
}
|
|
448
603
|
/** Read-only LLM answer (no tools) — powers /ask, /explain, /compact. */
|
|
449
604
|
async function answerReadOnly(question, context) {
|
|
450
|
-
|
|
605
|
+
// Legacy chat path (no modes): resolve the split prompt back to one string.
|
|
606
|
+
const sys = resolveSystemPrompt(systemPromptFn, { cwd, telemetry: '' }).system + '\n\nAnswer read-only: do not call tools, do not edit files.';
|
|
451
607
|
const attached = await buildAttachedBlock();
|
|
452
608
|
const userText = (context ? `${question}\n\n<context>\n${context.slice(0, 6000)}\n</context>` : question) + attached;
|
|
453
609
|
const req = {
|
|
@@ -525,6 +681,14 @@ export async function startRepl(opts = {}) {
|
|
|
525
681
|
hooks.append(ev.item);
|
|
526
682
|
}
|
|
527
683
|
pendingQueue.length = 0;
|
|
684
|
+
// Deferred trust-gated KLYRO.md load: the approval modal can only
|
|
685
|
+
// render once the App is mounted. Prompts sent before this resolves
|
|
686
|
+
// run without the KLYRO.md block (noted in /reload output).
|
|
687
|
+
if (useTui && !klyroBlock) {
|
|
688
|
+
void loadKlyroBlockTrust()
|
|
689
|
+
.then((b) => { klyroBlock = b; })
|
|
690
|
+
.catch(() => undefined);
|
|
691
|
+
}
|
|
528
692
|
},
|
|
529
693
|
}), { patchConsole: false });
|
|
530
694
|
// Install SIGINT handler only after app exists (avoids TDZ) and use once
|
|
@@ -579,7 +743,8 @@ export async function startRepl(opts = {}) {
|
|
|
579
743
|
try {
|
|
580
744
|
const simpleReq = {
|
|
581
745
|
model,
|
|
582
|
-
|
|
746
|
+
// Legacy chat path (no modes): plain string system, untouched by the split.
|
|
747
|
+
system: resolveSystemPrompt(systemPromptFn, { cwd, telemetry: '' }).system,
|
|
583
748
|
messages: [{ role: 'user', content: [{ kind: 'text', text: taskText }] }],
|
|
584
749
|
tools: [],
|
|
585
750
|
signal: ac.signal,
|
|
@@ -2341,8 +2506,9 @@ export async function startRepl(opts = {}) {
|
|
|
2341
2506
|
}
|
|
2342
2507
|
const ctx = await buildLevel6Context({ cwd });
|
|
2343
2508
|
ctxPrefix = ctx.formatted ? `\n\n<context>\n${ctx.formatted}\n</context>` : '';
|
|
2344
|
-
|
|
2345
|
-
|
|
2509
|
+
// Trust-gated like the initial load (the modal is mounted here,
|
|
2510
|
+
// so untrusted files prompt instead of being silently dropped).
|
|
2511
|
+
klyroBlock = await loadKlyroBlockTrust().catch(() => '');
|
|
2346
2512
|
queuedAppend({
|
|
2347
2513
|
id: `reload-${Date.now()}`,
|
|
2348
2514
|
kind: 'text',
|
package/dist/cli/run.d.ts
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
* even on a long task. Tool calls and policy decisions go to stderr so
|
|
8
8
|
* they don't pollute the captured `finalText` if the user pipes.
|
|
9
9
|
*/
|
|
10
|
+
import type { VerifyMode } from '../agent/runtime.js';
|
|
11
|
+
import type { SystemPromptFn } from '../agent/runtime.js';
|
|
10
12
|
import type { Message } from '../agent/message.js';
|
|
11
13
|
export interface RunCliOptions {
|
|
12
14
|
task: string;
|
|
@@ -15,10 +17,7 @@ export interface RunCliOptions {
|
|
|
15
17
|
maxSteps?: number;
|
|
16
18
|
maxTokens?: number;
|
|
17
19
|
temperature?: number;
|
|
18
|
-
systemPrompt?:
|
|
19
|
-
cwd: string;
|
|
20
|
-
telemetry?: string;
|
|
21
|
-
}) => string;
|
|
20
|
+
systemPrompt?: SystemPromptFn;
|
|
22
21
|
baseUrl?: string;
|
|
23
22
|
apiKey?: string;
|
|
24
23
|
timeoutMs?: number;
|
|
@@ -60,18 +59,18 @@ export interface RunCliOptions {
|
|
|
60
59
|
maxRepairAttempts?: number;
|
|
61
60
|
verifyTimeoutMs?: number;
|
|
62
61
|
requireVerify?: boolean;
|
|
62
|
+
/** Verification mode: strict (default) runs the full repair pipeline;
|
|
63
|
+
* advisory reports once without repairs; off skips verification. */
|
|
64
|
+
verifyMode?: VerifyMode;
|
|
63
65
|
/** Level 9 — persistence */
|
|
64
66
|
persist?: boolean;
|
|
65
67
|
sessionId?: string;
|
|
66
68
|
sessionsDir?: string;
|
|
69
|
+
/** P1.4 — run the task under a named child-capable orchestrator context. */
|
|
70
|
+
agent?: string;
|
|
71
|
+
maxDepth?: number;
|
|
67
72
|
}
|
|
68
73
|
export declare function runOnce(opts: RunCliOptions): Promise<number>;
|
|
69
74
|
/** Wrap a system-prompt fn to inject Level-6 context (project map etc.) + KLYRO.md (4.4). */
|
|
70
|
-
export declare function makeRunSystemPrompt(cwd: string, base:
|
|
71
|
-
cwd: string;
|
|
72
|
-
telemetry?: string;
|
|
73
|
-
}) => string): Promise<(ctx: {
|
|
74
|
-
cwd: string;
|
|
75
|
-
telemetry?: string;
|
|
76
|
-
}) => string>;
|
|
75
|
+
export declare function makeRunSystemPrompt(cwd: string, base: SystemPromptFn): Promise<SystemPromptFn>;
|
|
77
76
|
export declare function loadTranscript(path: string): Message[];
|