klyro 1.0.14 → 1.0.17
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/README.md +14 -5
- package/dist/agent/anthropic-adapter.js +26 -1
- package/dist/agent/child-worker.js +15 -4
- package/dist/agent/provider-adapter.d.ts +7 -0
- package/dist/agent/provider-adapter.js +26 -1
- package/dist/agent/runtime.d.ts +7 -0
- package/dist/agent/runtime.js +18 -21
- package/dist/chat.js +16 -56
- package/dist/checkpoints/store.js +7 -9
- package/dist/cli/completion.js +0 -3
- package/dist/cli/config.js +87 -2
- package/dist/cli/doctor.d.ts +13 -0
- package/dist/cli/doctor.js +133 -1
- package/dist/cli/dotenv.d.ts +2 -0
- package/dist/cli/dotenv.js +74 -0
- package/dist/cli/errors.js +1 -1
- package/dist/cli/eval.js +1 -3
- package/dist/cli/hooks.d.ts +31 -0
- package/dist/cli/hooks.js +126 -7
- package/dist/cli/markdown.js +1 -1
- package/dist/cli/repl.js +13 -24
- package/dist/cli/run.d.ts +32 -0
- package/dist/cli/run.js +93 -17
- package/dist/cli/session-commands.js +2 -1
- package/dist/cli/update.js +23 -7
- package/dist/context/accounting.js +1 -1
- package/dist/context/level7.d.ts +4 -0
- package/dist/context/level7.js +20 -1
- package/dist/context/project-map.js +14 -13
- package/dist/eval/harness.d.ts +4 -3
- package/dist/eval/harness.js +16 -12
- package/dist/index.js +76 -5
- package/dist/mcp/client.js +12 -0
- package/dist/mcp/config.js +2 -1
- package/dist/mcp/serve.d.ts +24 -0
- package/dist/mcp/serve.js +41 -4
- package/dist/persistence/audit.js +8 -1
- package/dist/persistence/store.d.ts +2 -0
- package/dist/persistence/store.js +28 -2
- package/dist/policy/approval.js +3 -0
- package/dist/policy/path-guard.d.ts +9 -4
- package/dist/policy/path-guard.js +47 -23
- package/dist/policy/secret-redactor.js +2 -2
- package/dist/providers/endpoints.js +8 -0
- package/dist/providers/model-info.d.ts +1 -0
- package/dist/providers/model-info.js +19 -0
- package/dist/providers.js +6 -2
- package/dist/renderers/terminal.d.ts +7 -0
- package/dist/renderers/terminal.js +52 -0
- package/dist/repl.d.ts +9 -0
- package/dist/repl.js +24 -5
- package/dist/shared/index.d.ts +2 -0
- package/dist/shared/index.js +2 -0
- package/dist/shared/json.d.ts +7 -0
- package/dist/shared/json.js +9 -0
- package/dist/shared/output-cap.d.ts +30 -0
- package/dist/shared/output-cap.js +28 -0
- package/dist/shared/proxy.d.ts +37 -0
- package/dist/shared/proxy.js +360 -0
- package/dist/tools/agent/task-list.d.ts +1 -1
- package/dist/tools/fs/apply-patch.js +4 -4
- package/dist/tools/fs/edit-file.js +2 -4
- package/dist/tools/fs/list-dir.js +1 -1
- package/dist/tools/fs/multi-edit.js +2 -2
- package/dist/tools/fs/read-file.js +1 -1
- package/dist/tools/fs/write-file.js +2 -2
- package/dist/tools/lsp/diagnostics.js +2 -3
- package/dist/tools/plan/todo-write.d.ts +1 -1
- package/dist/tools/search/dependencies.d.ts +4 -4
- package/dist/tools/search/dependencies.js +1 -1
- package/dist/tools/search/glob.js +1 -1
- package/dist/tools/search/grep.js +1 -1
- package/dist/tools/search/recent-files.js +1 -1
- package/dist/tools/search/search-files.js +1 -1
- package/dist/tools/web/web-fetch.d.ts +9 -0
- package/dist/tools/web/web-fetch.js +44 -7
- package/dist/tools/web/web-search.js +3 -1
- package/dist/tui/app.js +85 -31
- package/dist/tui/app.test.js +54 -7
- package/dist/tui/measure.js +1 -0
- package/dist/tui/mouse.d.ts +7 -5
- package/dist/tui/mouse.js +9 -6
- package/dist/tui/scroll-flow.test.js +0 -1
- package/dist/util/log.d.ts +19 -10
- package/dist/util/log.js +87 -52
- package/dist/verification/baseline.js +10 -11
- package/dist/verification/classify.d.ts +1 -1
- package/dist/verification/classify.js +19 -10
- package/dist/verification/engine.js +9 -16
- package/dist/verification/scoped.js +16 -15
- package/package.json +6 -1
package/README.md
CHANGED
|
@@ -51,6 +51,7 @@ node dist/index.js chat
|
|
|
51
51
|
| `KLYRO_YES` / `--yes` | **Commit only** — auto-approves `klyro commit` prompts; nothing else reads it |
|
|
52
52
|
| `KLYRO_NO_UPDATE_CHECK=1` | Disables the 24h update check |
|
|
53
53
|
| `KLYRO_ALLOW_MAIN_PUSH=1` | Per-risk escape for protected-branch push |
|
|
54
|
+
| `KLYRO_TRUST_PROJECT_HOOKS=1` | Per-shell opt-in to run repo-authored `.klyro/hooks.json` commands without pinning them via `klyro hooks trust` |
|
|
54
55
|
| `KLYRO_CREDENTIALS_INSECURE_OK=1` | Warn (don't refuse) on group-readable credentials |
|
|
55
56
|
| `KLYRO_LSP=0` | Force language tools off |
|
|
56
57
|
| `KLYRO_SYMBOLS=0` | Force `find_symbol` off |
|
|
@@ -60,6 +61,7 @@ node dist/index.js chat
|
|
|
60
61
|
## New in recent releases
|
|
61
62
|
|
|
62
63
|
- `klyro run --bare` — deterministic runs: skips MCP, hooks, memory/KLYRO.md/context, persistence
|
|
64
|
+
- `klyro hooks trust` — project `.klyro/hooks.json` commands run only after explicit review: the file is hash-pinned in `~/.klyro/trusted-hooks.json` and any edit re-locks it (a cloned repo can no longer execute code just because you ran `klyro` in it)
|
|
63
65
|
- `klyro mcp trust <name>` / `mcp prompts [server]` / `mcp add <name> <https-url>` — remote MCP + prompt trust
|
|
64
66
|
- `klyro agents lint` — validate `.klyro/agents/*.md` (ids, tool names)
|
|
65
67
|
- `klyro init` — scan-seeded `KLYRO.md` + `.mcp.json` (never overwrites)
|
|
@@ -72,13 +74,20 @@ node dist/index.js chat
|
|
|
72
74
|
|
|
73
75
|
## Documentation
|
|
74
76
|
|
|
77
|
+
Progress: Klyro is **complete through Level 10** ("Klyro 1.0" milestone) of the
|
|
78
|
+
20-level plan; see [`docs/STATUS.md`](docs/STATUS.md) for the audited
|
|
79
|
+
level-by-level grading.
|
|
80
|
+
|
|
75
81
|
| Doc | Purpose |
|
|
76
82
|
|---|---|
|
|
77
|
-
| [`docs/
|
|
78
|
-
| [`
|
|
79
|
-
| [`
|
|
80
|
-
| [`
|
|
81
|
-
| [`
|
|
83
|
+
| [`docs/STATUS.md`](docs/STATUS.md) | **Status (authoritative)** — release state + plan level progress |
|
|
84
|
+
| [`plan.md`](plan.md) | **Roadmap** — 20-level / 100-sub-level plan from bare CLI to super-harness |
|
|
85
|
+
| [`PRD.md`](PRD.md) | Product vision and requirements |
|
|
86
|
+
| [`READ.md`](READ.md) | Full build documentation / architecture walkthrough |
|
|
87
|
+
| [`review.md`](review.md) | Point-in-time repository review (1.0.9) |
|
|
88
|
+
| [`comparison.md`](comparison.md) | Architectural audit vs Claude Code (36 rounds) |
|
|
89
|
+
| [`commands.md`](commands.md) | CLI + slash-command reference |
|
|
90
|
+
| [`TUI_DESIGN.md`](TUI_DESIGN.md) | TUI design notes (layout, scroll model) |
|
|
82
91
|
|
|
83
92
|
## Code structure
|
|
84
93
|
|
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import { assertSafeBaseURL } from '../chat.js';
|
|
19
19
|
import { parseRetryAfterMs } from './provider-adapter.js';
|
|
20
|
+
import { estimateTokens } from '../context/tokenizer.js';
|
|
21
|
+
import { proxiedFetch } from '../shared/proxy.js';
|
|
20
22
|
const DEFAULT_VERSION = '2023-06-01';
|
|
21
23
|
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
22
24
|
export const PROMPT_CACHING_BETA = 'prompt-caching-2024-07-31';
|
|
@@ -40,7 +42,7 @@ export function anthropicAdapter(opts) {
|
|
|
40
42
|
const betas = [...(opts.betas ?? [])];
|
|
41
43
|
if (promptCache && !betas.includes(PROMPT_CACHING_BETA))
|
|
42
44
|
betas.push(PROMPT_CACHING_BETA);
|
|
43
|
-
const fetchImpl = opts.fetchImpl ??
|
|
45
|
+
const fetchImpl = opts.fetchImpl ?? proxiedFetch;
|
|
44
46
|
if (!fetchImpl) {
|
|
45
47
|
throw new Error('anthropicAdapter: no fetch available — pass opts.fetchImpl or run on Node 18+');
|
|
46
48
|
}
|
|
@@ -52,6 +54,29 @@ export function anthropicAdapter(opts) {
|
|
|
52
54
|
fetchImpl, version, authHeader, betas, promptCache,
|
|
53
55
|
});
|
|
54
56
|
},
|
|
57
|
+
// 2.1 — capability discovery via the Anthropic Models API.
|
|
58
|
+
// Returns [] when unavailable (callers fall back to configured ids).
|
|
59
|
+
async listModels() {
|
|
60
|
+
try {
|
|
61
|
+
const headers = { 'anthropic-version': version };
|
|
62
|
+
if (authHeader === 'x-api-key')
|
|
63
|
+
headers['x-api-key'] = opts.apiKey;
|
|
64
|
+
else
|
|
65
|
+
headers['Authorization'] = `Bearer ${opts.apiKey}`;
|
|
66
|
+
const res = await fetchImpl(`${baseURL}/v1/models`, { headers });
|
|
67
|
+
if (!res.ok)
|
|
68
|
+
return [];
|
|
69
|
+
const json = (await res.json());
|
|
70
|
+
const ids = Array.isArray(json.data) ? json.data.map((m) => m.id).filter((id) => typeof id === 'string' && id.length > 0) : [];
|
|
71
|
+
return [...new Set(ids)];
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return [];
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
countTokens(text) {
|
|
78
|
+
return estimateTokens(text);
|
|
79
|
+
},
|
|
55
80
|
};
|
|
56
81
|
}
|
|
57
82
|
/**
|
|
@@ -33,6 +33,11 @@ import { spawn } from 'node:child_process';
|
|
|
33
33
|
import { fileURLToPath } from 'node:url';
|
|
34
34
|
import { existsSync } from 'node:fs';
|
|
35
35
|
import { run } from './runtime.js';
|
|
36
|
+
import { cappedOutput } from '../shared/output-cap.js';
|
|
37
|
+
/** Child results are ONE JSON line, but a misbehaving child can print forever. */
|
|
38
|
+
const MAX_CHILD_STDOUT_BYTES = 8 * 1024 * 1024;
|
|
39
|
+
/** stderr keeps its TAIL — callers slice(-500/-1000) for the failure reason. */
|
|
40
|
+
const MAX_CHILD_STDERR_CHARS = 16_000;
|
|
36
41
|
/**
|
|
37
42
|
* Differently-phrased crash, distinct from an in-process throw the parent
|
|
38
43
|
* would catch. The child either exited non-zero without a parseable result
|
|
@@ -81,12 +86,18 @@ export async function forkChild(entry, payload, opts = {}) {
|
|
|
81
86
|
windowsHide: true,
|
|
82
87
|
shell: false,
|
|
83
88
|
});
|
|
84
|
-
|
|
89
|
+
const stdoutCap = cappedOutput(MAX_CHILD_STDOUT_BYTES);
|
|
85
90
|
let stderr = '';
|
|
86
91
|
child.stdout.setEncoding('utf8');
|
|
87
92
|
child.stderr.setEncoding('utf8');
|
|
88
|
-
|
|
89
|
-
|
|
93
|
+
// Keep the HEAD of stdout (the ChildResult line) and the TAIL of stderr
|
|
94
|
+
// (where the reason lives), so neither can grow the parent's heap without
|
|
95
|
+
// bound over the child's 10-minute budget.
|
|
96
|
+
child.stdout.on('data', (c) => { stdoutCap.push(Buffer.from(c, 'utf8')); });
|
|
97
|
+
child.stderr.on('data', (c) => {
|
|
98
|
+
opts.onStderr?.(c);
|
|
99
|
+
stderr = (stderr + c).slice(-MAX_CHILD_STDERR_CHARS);
|
|
100
|
+
});
|
|
90
101
|
// Write the payload to stdin, then signal EOF so the child knows it has
|
|
91
102
|
// the whole payload before it starts running.
|
|
92
103
|
child.stdin.on('error', () => { });
|
|
@@ -119,7 +130,7 @@ export async function forkChild(entry, payload, opts = {}) {
|
|
|
119
130
|
});
|
|
120
131
|
child.on('close', (code, signal) => {
|
|
121
132
|
cleanup();
|
|
122
|
-
const first =
|
|
133
|
+
const first = stdoutCap.text().split('\n').find((l) => l.trim().length > 0);
|
|
123
134
|
if (code === 0 && first) {
|
|
124
135
|
try {
|
|
125
136
|
resolve(JSON.parse(first));
|
|
@@ -68,11 +68,17 @@ export interface CallRequest {
|
|
|
68
68
|
tools: ToolDefinition[];
|
|
69
69
|
maxTokens?: number;
|
|
70
70
|
temperature?: number;
|
|
71
|
+
/** Reasoning effort for reasoning models ( OpenAI `reasoning_effort` ); adapters that lack the concept ignore it. */
|
|
72
|
+
reasoningEffort?: 'low' | 'medium' | 'high';
|
|
71
73
|
signal?: AbortSignal;
|
|
72
74
|
}
|
|
73
75
|
export interface ProviderAdapter {
|
|
74
76
|
readonly id: string;
|
|
75
77
|
stream(req: CallRequest): AsyncIterable<StreamEvent>;
|
|
78
|
+
/** Optional capability discovery: model ids (2.1). Absent = unknown, use configured ids. */
|
|
79
|
+
listModels?(): Promise<string[]>;
|
|
80
|
+
/** Optional local token estimate (2.1). Absent = caller estimates. */
|
|
81
|
+
countTokens?(text: string): number | Promise<number>;
|
|
76
82
|
}
|
|
77
83
|
export interface HttpAdapterOptions {
|
|
78
84
|
baseURL: string;
|
|
@@ -110,6 +116,7 @@ interface ChatCompletionsRequest {
|
|
|
110
116
|
}>;
|
|
111
117
|
max_tokens?: number;
|
|
112
118
|
temperature?: number;
|
|
119
|
+
reasoning_effort?: 'low' | 'medium' | 'high';
|
|
113
120
|
stream: true;
|
|
114
121
|
}
|
|
115
122
|
/**
|
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
* runtime loop sees one shape regardless of provider quirks.
|
|
12
12
|
*/
|
|
13
13
|
import { redact } from '../policy/secret-redactor.js';
|
|
14
|
+
import { estimateTokens } from '../context/tokenizer.js';
|
|
15
|
+
import { proxiedFetch } from '../shared/proxy.js';
|
|
14
16
|
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
15
17
|
/**
|
|
16
18
|
* Parse a `Retry-After` response header value into milliseconds.
|
|
@@ -157,6 +159,8 @@ export function buildChatCompletionsBody(req) {
|
|
|
157
159
|
body.max_tokens = req.maxTokens;
|
|
158
160
|
if (typeof req.temperature === 'number')
|
|
159
161
|
body.temperature = req.temperature;
|
|
162
|
+
if (req.reasoningEffort)
|
|
163
|
+
body.reasoning_effort = req.reasoningEffort;
|
|
160
164
|
if (req.tools.length) {
|
|
161
165
|
body.tools = req.tools.map((t) => ({
|
|
162
166
|
type: 'function',
|
|
@@ -166,13 +170,34 @@ export function buildChatCompletionsBody(req) {
|
|
|
166
170
|
return body;
|
|
167
171
|
}
|
|
168
172
|
export function httpChatAdapter(opts) {
|
|
169
|
-
const fetchImpl = opts.fetchImpl ??
|
|
173
|
+
const fetchImpl = opts.fetchImpl ?? proxiedFetch;
|
|
170
174
|
const url = `${opts.baseURL.replace(/\/+$/, '')}/chat/completions`;
|
|
171
175
|
return {
|
|
172
176
|
id: 'http-chat',
|
|
173
177
|
stream(req) {
|
|
174
178
|
return streamChatCompletions(url, opts, req, fetchImpl);
|
|
175
179
|
},
|
|
180
|
+
// 2.1 — capability discovery: list model ids via OpenAI /models.
|
|
181
|
+
// Returns [] when the endpoint doesn't serve a model list (not an
|
|
182
|
+
// error: callers fall back to configured ids).
|
|
183
|
+
async listModels() {
|
|
184
|
+
try {
|
|
185
|
+
const res = await fetchImpl(`${opts.baseURL.replace(/\/+$/, '')}/models`, {
|
|
186
|
+
headers: opts.apiKey ? { Authorization: `Bearer ${opts.apiKey}` } : {},
|
|
187
|
+
});
|
|
188
|
+
if (!res.ok)
|
|
189
|
+
return [];
|
|
190
|
+
const json = (await res.json());
|
|
191
|
+
const ids = Array.isArray(json.data) ? json.data.map((m) => m.id).filter((id) => typeof id === 'string' && id.length > 0) : [];
|
|
192
|
+
return [...new Set(ids)];
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
return [];
|
|
196
|
+
}
|
|
197
|
+
},
|
|
198
|
+
countTokens(text) {
|
|
199
|
+
return estimateTokens(text);
|
|
200
|
+
},
|
|
176
201
|
};
|
|
177
202
|
}
|
|
178
203
|
async function* streamChatCompletions(url, opts, req, fetchImpl) {
|
package/dist/agent/runtime.d.ts
CHANGED
|
@@ -83,6 +83,7 @@ export interface RunOptions {
|
|
|
83
83
|
maxTimeMs?: number;
|
|
84
84
|
maxTokens?: number;
|
|
85
85
|
temperature?: number;
|
|
86
|
+
reasoningEffort?: 'low' | 'medium' | 'high';
|
|
86
87
|
signal?: AbortSignal;
|
|
87
88
|
nonInteractive: boolean;
|
|
88
89
|
/**
|
|
@@ -157,6 +158,12 @@ export interface RunOptions {
|
|
|
157
158
|
allowedPaths?: readonly string[];
|
|
158
159
|
model?: string;
|
|
159
160
|
};
|
|
161
|
+
/**
|
|
162
|
+
* Root sandbox extensions (--add-dir): extra resolvable roots for file
|
|
163
|
+
* tools, merged with cwd. Child scopes (parentContext.allowedPaths) still
|
|
164
|
+
* narrow further when present.
|
|
165
|
+
*/
|
|
166
|
+
allowedPaths?: readonly string[];
|
|
160
167
|
/**
|
|
161
168
|
* Delegation bridge (P0). Present on the root run so the model can call
|
|
162
169
|
* spawn_agent / task_list / task_get. The tool layer reads it from the
|
package/dist/agent/runtime.js
CHANGED
|
@@ -223,18 +223,6 @@ export async function run(opts, deps) {
|
|
|
223
223
|
const sessionId = opts.persist?.sessionId;
|
|
224
224
|
// 6.1 baseline cache per HEAD — capture before first edit
|
|
225
225
|
let baselinePrimed = false;
|
|
226
|
-
async function primeBaseline() {
|
|
227
|
-
if (baselinePrimed)
|
|
228
|
-
return;
|
|
229
|
-
baselinePrimed = true;
|
|
230
|
-
const cmd = opts.verify?.command ?? detectVerifyCommand(opts.cwd);
|
|
231
|
-
if (!cmd)
|
|
232
|
-
return;
|
|
233
|
-
try {
|
|
234
|
-
await ensureBaseline(opts.cwd, cmd);
|
|
235
|
-
}
|
|
236
|
-
catch { /* ignore */ }
|
|
237
|
-
}
|
|
238
226
|
async function checkpoint(msg, obs) {
|
|
239
227
|
if (!store || !sessionId)
|
|
240
228
|
return;
|
|
@@ -319,6 +307,11 @@ export async function run(opts, deps) {
|
|
|
319
307
|
// Abort cascade (fix: background shells must not outlive the run).
|
|
320
308
|
const killed = killAllJobs();
|
|
321
309
|
emitKlyro({ type: 'abort', ts: Date.now(), sessionId: sessionId ?? 'ephemeral', reason: killed.length > 0 ? `aborted by operator (${killed.length} background job(s) killed)` : 'aborted by operator' });
|
|
310
|
+
// 2.4 — explicit interrupted marker: partial output stays labeled in
|
|
311
|
+
// the transcript so resume/replay never mistakes it for final text.
|
|
312
|
+
const interruptedNote = { role: 'user', content: [text('[system note] Interrupted by user — output above is partial and preserved.')] };
|
|
313
|
+
transcript.push(interruptedNote);
|
|
314
|
+
await checkpoint(interruptedNote);
|
|
322
315
|
if (store && sessionId) {
|
|
323
316
|
try {
|
|
324
317
|
await store.setStatus(sessionId, 'aborted', finalText);
|
|
@@ -399,23 +392,22 @@ export async function run(opts, deps) {
|
|
|
399
392
|
tools: toolDefinitions(deps.registry),
|
|
400
393
|
...(opts.maxTokens ? { maxTokens: opts.maxTokens } : {}),
|
|
401
394
|
...(typeof opts.temperature === 'number' ? { temperature: opts.temperature } : {}),
|
|
395
|
+
...(opts.reasoningEffort ? { reasoningEffort: opts.reasoningEffort } : {}),
|
|
402
396
|
...(opts.signal ? { signal: opts.signal } : {}),
|
|
403
397
|
};
|
|
404
398
|
const events = activeAdapter.stream(req);
|
|
405
399
|
let textBuf = '';
|
|
406
400
|
// Thinking is ephemeral: streamed to the UI live, never stored in the
|
|
407
401
|
// transcript, and cleared when the turn's answer completes.
|
|
408
|
-
let thinkingBuf = '';
|
|
409
402
|
const pendingToolCalls = new Map();
|
|
410
|
-
let lastFinishReason;
|
|
411
403
|
// Set when this step's request must be re-issued after overflow recovery.
|
|
412
404
|
let overflowRetryPending = false;
|
|
413
405
|
// Set when a terminal provider error consumed a failover adapter — the
|
|
414
406
|
// step is re-issued against the next adapter without consuming budget.
|
|
415
407
|
let failoverPending = false;
|
|
416
|
-
let failoverFrom
|
|
417
|
-
let failoverTo
|
|
418
|
-
let failoverReason
|
|
408
|
+
let failoverFrom;
|
|
409
|
+
let failoverTo;
|
|
410
|
+
let failoverReason;
|
|
419
411
|
for await (const ev of events) {
|
|
420
412
|
if (opts.signal?.aborted)
|
|
421
413
|
break outer;
|
|
@@ -424,7 +416,6 @@ export async function run(opts, deps) {
|
|
|
424
416
|
emit?.({ kind: 'text_delta', text: ev.text });
|
|
425
417
|
}
|
|
426
418
|
else if (ev.kind === 'thinking_delta') {
|
|
427
|
-
thinkingBuf += ev.text;
|
|
428
419
|
emit?.({ kind: 'thinking_delta', text: ev.text });
|
|
429
420
|
}
|
|
430
421
|
else if (ev.kind === 'tool_call_start') {
|
|
@@ -441,7 +432,6 @@ export async function run(opts, deps) {
|
|
|
441
432
|
// tool_calls are accumulated; finalization happens after stream.
|
|
442
433
|
}
|
|
443
434
|
else if (ev.kind === 'message_end') {
|
|
444
|
-
lastFinishReason = ev.finishReason;
|
|
445
435
|
if (ev.usage) {
|
|
446
436
|
usage.input += ev.usage.input;
|
|
447
437
|
usage.output += ev.usage.output;
|
|
@@ -553,9 +543,7 @@ export async function run(opts, deps) {
|
|
|
553
543
|
if (failoverPending) {
|
|
554
544
|
failoverPending = false;
|
|
555
545
|
textBuf = '';
|
|
556
|
-
thinkingBuf = '';
|
|
557
546
|
pendingToolCalls.clear();
|
|
558
|
-
lastFinishReason = undefined;
|
|
559
547
|
steps--;
|
|
560
548
|
emit?.({ kind: 'step_end', step: steps + 1 });
|
|
561
549
|
continue outer;
|
|
@@ -613,6 +601,10 @@ export async function run(opts, deps) {
|
|
|
613
601
|
emit?.({ kind: 'aborted' });
|
|
614
602
|
const killed = killAllJobs();
|
|
615
603
|
emitKlyro({ type: 'abort', ts: Date.now(), sessionId: sessionId ?? 'ephemeral', reason: killed.length > 0 ? `aborted by operator (${killed.length} background job(s) killed)` : 'aborted by operator' });
|
|
604
|
+
// 2.4 — explicit interrupted marker (see loop-top abort).
|
|
605
|
+
const interruptedNote = { role: 'user', content: [text('[system note] Interrupted by user — output above is partial and preserved.')] };
|
|
606
|
+
transcript.push(interruptedNote);
|
|
607
|
+
await checkpoint(interruptedNote);
|
|
616
608
|
if (store && sessionId) {
|
|
617
609
|
try {
|
|
618
610
|
await store.setStatus(sessionId, 'aborted', finalText);
|
|
@@ -907,6 +899,7 @@ export async function run(opts, deps) {
|
|
|
907
899
|
: {}),
|
|
908
900
|
agentDepth: opts.parentContext?.depth ?? 0,
|
|
909
901
|
agentMaxDepth: opts.parentContext?.maxDepth ?? 1,
|
|
902
|
+
...(opts.allowedPaths ? { agentAllowedPaths: opts.allowedPaths } : {}),
|
|
910
903
|
...(opts.parentContext?.allowedTools ? { agentAllowedTools: opts.parentContext.allowedTools } : {}),
|
|
911
904
|
...(opts.parentContext?.allowedPaths ? { agentAllowedPaths: opts.parentContext.allowedPaths } : {}),
|
|
912
905
|
...(opts.parentContext?.model ?? opts.model ? { agentModel: opts.parentContext?.model ?? opts.model } : {}),
|
|
@@ -1308,6 +1301,10 @@ export async function run(opts, deps) {
|
|
|
1308
1301
|
}
|
|
1309
1302
|
if (opts.signal?.aborted) {
|
|
1310
1303
|
emit?.({ kind: 'aborted' });
|
|
1304
|
+
// 2.4 — explicit interrupted marker (see loop-top abort).
|
|
1305
|
+
const interruptedNote = { role: 'user', content: [text('[system note] Interrupted by user — output above is partial and preserved.')] };
|
|
1306
|
+
transcript.push(interruptedNote);
|
|
1307
|
+
await checkpoint(interruptedNote);
|
|
1311
1308
|
if (store && sessionId) {
|
|
1312
1309
|
try {
|
|
1313
1310
|
await store.setStatus(sessionId, 'aborted', finalText);
|
package/dist/chat.js
CHANGED
|
@@ -20,6 +20,16 @@
|
|
|
20
20
|
*/
|
|
21
21
|
/** Default request timeout: 60s. */
|
|
22
22
|
const DEFAULT_TIMEOUT_MS = 60_000;
|
|
23
|
+
/**
|
|
24
|
+
* Legacy output flows through the shared terminal renderer (3.1): all
|
|
25
|
+
* human output leaves via the renderer module, which also sanitizes
|
|
26
|
+
* control sequences out of model text.
|
|
27
|
+
*/
|
|
28
|
+
import { TerminalRenderer, writeStdoutDrained } from './renderers/terminal.js';
|
|
29
|
+
const renderer = new TerminalRenderer();
|
|
30
|
+
function frame(text) {
|
|
31
|
+
renderer.handle({ type: 'stream.delta', ts: Date.now(), sessionId: 'legacy-chat', text });
|
|
32
|
+
}
|
|
23
33
|
/** Max bytes of an error response body we will print. */
|
|
24
34
|
const MAX_ERROR_BODY_BYTES = 4_000;
|
|
25
35
|
/** Strip a trailing slash so we can append /chat/completions cleanly. */
|
|
@@ -187,7 +197,7 @@ export async function streamToStdout(body, signal) {
|
|
|
187
197
|
const reader = body.getReader();
|
|
188
198
|
const decoder = new TextDecoder('utf-8');
|
|
189
199
|
let buf = '';
|
|
190
|
-
|
|
200
|
+
frame('\n');
|
|
191
201
|
try {
|
|
192
202
|
while (true) {
|
|
193
203
|
if (signal.aborted)
|
|
@@ -209,7 +219,7 @@ export async function streamToStdout(body, signal) {
|
|
|
209
219
|
continue;
|
|
210
220
|
const data = line.slice(5).trim();
|
|
211
221
|
if (data === '[DONE]') {
|
|
212
|
-
|
|
222
|
+
frame('\n');
|
|
213
223
|
return;
|
|
214
224
|
}
|
|
215
225
|
// Handle case where data was split across chunks and reassembled as event
|
|
@@ -224,7 +234,7 @@ export async function streamToStdout(body, signal) {
|
|
|
224
234
|
}
|
|
225
235
|
const text = parsed.choices?.[0]?.delta?.content;
|
|
226
236
|
if (text) {
|
|
227
|
-
if (!await
|
|
237
|
+
if (!await writeStdoutDrained(text, signal)) {
|
|
228
238
|
try {
|
|
229
239
|
await reader.cancel();
|
|
230
240
|
}
|
|
@@ -246,7 +256,7 @@ export async function streamToStdout(body, signal) {
|
|
|
246
256
|
continue;
|
|
247
257
|
const data = line.slice(5).trim();
|
|
248
258
|
if (data === '[DONE]') {
|
|
249
|
-
|
|
259
|
+
frame('\n');
|
|
250
260
|
return;
|
|
251
261
|
}
|
|
252
262
|
if (data === '')
|
|
@@ -255,7 +265,7 @@ export async function streamToStdout(body, signal) {
|
|
|
255
265
|
const parsed = JSON.parse(data);
|
|
256
266
|
const text = parsed.choices?.[0]?.delta?.content;
|
|
257
267
|
if (text) {
|
|
258
|
-
if (!await
|
|
268
|
+
if (!await writeStdoutDrained(text, signal)) {
|
|
259
269
|
try {
|
|
260
270
|
await reader.cancel();
|
|
261
271
|
}
|
|
@@ -270,7 +280,7 @@ export async function streamToStdout(body, signal) {
|
|
|
270
280
|
}
|
|
271
281
|
}
|
|
272
282
|
}
|
|
273
|
-
|
|
283
|
+
frame('\n');
|
|
274
284
|
}
|
|
275
285
|
finally {
|
|
276
286
|
try {
|
|
@@ -281,56 +291,6 @@ export async function streamToStdout(body, signal) {
|
|
|
281
291
|
}
|
|
282
292
|
}
|
|
283
293
|
}
|
|
284
|
-
/**
|
|
285
|
-
* Write to stdout and wait for the drain event if the buffer is full.
|
|
286
|
-
* Returns false if stdout has been closed (e.g. piped to `head`).
|
|
287
|
-
* Respects abort signal — resolves false if aborted while waiting.
|
|
288
|
-
*/
|
|
289
|
-
function writeWithBackpressure(chunk, signal) {
|
|
290
|
-
return new Promise((resolve) => {
|
|
291
|
-
if (signal?.aborted) {
|
|
292
|
-
resolve(false);
|
|
293
|
-
return;
|
|
294
|
-
}
|
|
295
|
-
if (!process.stdout.write(chunk)) {
|
|
296
|
-
let settled = false;
|
|
297
|
-
const cleanup = () => {
|
|
298
|
-
process.stdout.off('drain', onDrain);
|
|
299
|
-
process.stdout.off('error', onError);
|
|
300
|
-
if (signal)
|
|
301
|
-
signal.removeEventListener('abort', onAbort);
|
|
302
|
-
};
|
|
303
|
-
const onDrain = () => {
|
|
304
|
-
if (settled)
|
|
305
|
-
return;
|
|
306
|
-
settled = true;
|
|
307
|
-
cleanup();
|
|
308
|
-
resolve(true);
|
|
309
|
-
};
|
|
310
|
-
const onError = () => {
|
|
311
|
-
if (settled)
|
|
312
|
-
return;
|
|
313
|
-
settled = true;
|
|
314
|
-
cleanup();
|
|
315
|
-
resolve(false);
|
|
316
|
-
};
|
|
317
|
-
const onAbort = () => {
|
|
318
|
-
if (settled)
|
|
319
|
-
return;
|
|
320
|
-
settled = true;
|
|
321
|
-
cleanup();
|
|
322
|
-
resolve(false);
|
|
323
|
-
};
|
|
324
|
-
process.stdout.once('drain', onDrain);
|
|
325
|
-
process.stdout.once('error', onError);
|
|
326
|
-
if (signal)
|
|
327
|
-
signal.addEventListener('abort', onAbort, { once: true });
|
|
328
|
-
}
|
|
329
|
-
else {
|
|
330
|
-
resolve(true);
|
|
331
|
-
}
|
|
332
|
-
});
|
|
333
|
-
}
|
|
334
294
|
/**
|
|
335
295
|
* Read up to `max` bytes from a response body. Used for error responses where
|
|
336
296
|
* we want to surface the cause without risking OOM on a misbehaving server.
|
|
@@ -14,6 +14,7 @@ import * as fs from 'node:fs/promises';
|
|
|
14
14
|
import * as fsSync from 'node:fs';
|
|
15
15
|
import * as path from 'node:path';
|
|
16
16
|
import * as crypto from 'node:crypto';
|
|
17
|
+
import { cappedOutput } from '../shared/output-cap.js';
|
|
17
18
|
function ckptDir(cwd) {
|
|
18
19
|
return path.join(cwd, '.klyro', 'checkpoints');
|
|
19
20
|
}
|
|
@@ -107,7 +108,7 @@ export async function snapshot(cwd, files) {
|
|
|
107
108
|
const args = ['diff', '--', ...kept.slice(0, 20)];
|
|
108
109
|
const diffText = await new Promise((resolve) => {
|
|
109
110
|
const child = spawn('git', args, { cwd, shell: false, windowsHide: true });
|
|
110
|
-
const
|
|
111
|
+
const sink = cappedOutput(20 * 1024);
|
|
111
112
|
let done = false;
|
|
112
113
|
const t = setTimeout(() => {
|
|
113
114
|
if (!done) {
|
|
@@ -119,16 +120,13 @@ export async function snapshot(cwd, files) {
|
|
|
119
120
|
resolve('');
|
|
120
121
|
}
|
|
121
122
|
}, 10_000);
|
|
122
|
-
child.stdout.on('data', (b) =>
|
|
123
|
-
if (Buffer.concat(chunks).length < 20 * 1024)
|
|
124
|
-
chunks.push(b);
|
|
125
|
-
});
|
|
123
|
+
child.stdout.on('data', (b) => sink.push(b));
|
|
126
124
|
child.on('close', () => {
|
|
127
125
|
if (done)
|
|
128
126
|
return;
|
|
129
127
|
done = true;
|
|
130
128
|
clearTimeout(t);
|
|
131
|
-
resolve(
|
|
129
|
+
resolve(sink.text());
|
|
132
130
|
});
|
|
133
131
|
child.on('error', () => {
|
|
134
132
|
if (done)
|
|
@@ -214,9 +212,9 @@ export async function diff(cwd, id) {
|
|
|
214
212
|
const { spawn } = await import('node:child_process');
|
|
215
213
|
return new Promise((resolve) => {
|
|
216
214
|
const child = spawn('git', ['diff', '--stat'], { cwd, shell: false, windowsHide: true });
|
|
217
|
-
|
|
218
|
-
child.stdout.on('data', (b) =>
|
|
219
|
-
child.on('close', () => resolve(
|
|
215
|
+
const sink = cappedOutput(64 * 1024);
|
|
216
|
+
child.stdout.on('data', (b) => sink.push(b));
|
|
217
|
+
child.on('close', () => resolve(sink.text() || 'No diff'));
|
|
220
218
|
child.on('error', () => resolve('No git diff available'));
|
|
221
219
|
});
|
|
222
220
|
}
|
package/dist/cli/completion.js
CHANGED
|
@@ -19,9 +19,6 @@ const COMMAND_FLAGS = {
|
|
|
19
19
|
completion: ['bash', 'zsh', 'fish', 'powershell'],
|
|
20
20
|
resume: ['-m', '--model', '--max-steps'],
|
|
21
21
|
};
|
|
22
|
-
function flagsFor(cmd) {
|
|
23
|
-
return [...GLOBAL_FLAGS, ...(COMMAND_FLAGS[cmd] ?? [])];
|
|
24
|
-
}
|
|
25
22
|
function bashScript() {
|
|
26
23
|
const cmdCases = Object.entries(COMMAND_FLAGS)
|
|
27
24
|
.map(([c, fs]) => ` ${c}) opts="${fs.join(' ')}" ;;`)
|
package/dist/cli/config.js
CHANGED
|
@@ -328,6 +328,91 @@ export function loadConfigSync() {
|
|
|
328
328
|
}
|
|
329
329
|
return {};
|
|
330
330
|
}
|
|
331
|
+
/**
|
|
332
|
+
* Project-layer trust boundary (P1): `.klyro/settings*.json` ship with the
|
|
333
|
+
* repo, so a hostile checkout can plant them. API keys are NEVER accepted
|
|
334
|
+
* from project layers — keys load only from ~/.klyro, env, or flags. A
|
|
335
|
+
* project baseUrl pointing at a public host (which would receive the user's
|
|
336
|
+
* key + prompts) warns loudly; loopback/RFC1918 stay silent for shared
|
|
337
|
+
* local setups (Ollama etc.).
|
|
338
|
+
*/
|
|
339
|
+
function isPublicHostUrl(raw) {
|
|
340
|
+
let host = '';
|
|
341
|
+
try {
|
|
342
|
+
host = new URL(raw).hostname.toLowerCase().replace(/^\[|\]$/g, '');
|
|
343
|
+
}
|
|
344
|
+
catch {
|
|
345
|
+
return true; // unparseable endpoint — treat as hostile, warn
|
|
346
|
+
}
|
|
347
|
+
if (host === 'localhost' || host === '127.0.0.1' || host === '::1')
|
|
348
|
+
return false;
|
|
349
|
+
if (host.endsWith('.localhost') || host.endsWith('.local'))
|
|
350
|
+
return false;
|
|
351
|
+
if (host.startsWith('127.'))
|
|
352
|
+
return false;
|
|
353
|
+
if (/^10\.\d+\.\d+\.\d+$/.test(host))
|
|
354
|
+
return false;
|
|
355
|
+
if (/^192\.168\.\d+\.\d+$/.test(host))
|
|
356
|
+
return false;
|
|
357
|
+
if (/^172\.(1[6-9]|2\d|3[0-1])\.\d+\.\d+$/.test(host))
|
|
358
|
+
return false;
|
|
359
|
+
return true;
|
|
360
|
+
}
|
|
361
|
+
function scrubProjectLayer(obj, file) {
|
|
362
|
+
const warn = (msg) => {
|
|
363
|
+
try {
|
|
364
|
+
process.stderr.write(msg);
|
|
365
|
+
}
|
|
366
|
+
catch { /* ignore */ }
|
|
367
|
+
};
|
|
368
|
+
for (const k of ['apiKey', 'api_key']) {
|
|
369
|
+
if (obj[k] !== undefined) {
|
|
370
|
+
delete obj[k];
|
|
371
|
+
warn(`klyro: ignoring apiKey in project config ${file} — keys load only from ~/.klyro/settings.json, env (KLYRO_API_KEY), or --api-key\n`);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
for (const k of ['baseUrl', 'baseURL']) {
|
|
375
|
+
const v = obj[k];
|
|
376
|
+
if (typeof v === 'string' && v.length > 0 && isPublicHostUrl(v)) {
|
|
377
|
+
warn(`klyro: warning: project config ${file} points the provider at ${v} — API requests (including your key) will go there\n`);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
scrubProjectFailover(obj['providers'], file, warn);
|
|
381
|
+
return obj;
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* Failover entries from a project layer get the same trust treatment as the
|
|
385
|
+
* primary endpoint. Two reasons `apiKeyEnv` is dropped rather than warned
|
|
386
|
+
* about: (a) a fallback fires *without a prompt*, so a repo-chosen env var
|
|
387
|
+
* name would silently ship the user's credential to a repo-chosen host, and
|
|
388
|
+
* (b) the name can also cross providers (`provider:'openai'` + an Anthropic
|
|
389
|
+
* env var). A literal `apiKey` in project config is left alone — that is the
|
|
390
|
+
* repo's own key, not the user's secret — matching the accepted
|
|
391
|
+
* `providers.failover[].apiKey` behaviour. Keys for a project-authored
|
|
392
|
+
* fallback belong in ~/.klyro/settings.json.
|
|
393
|
+
*/
|
|
394
|
+
function scrubProjectFailover(providers, file, warn) {
|
|
395
|
+
if (providers === null || typeof providers !== 'object')
|
|
396
|
+
return;
|
|
397
|
+
const failover = providers.failover;
|
|
398
|
+
if (!Array.isArray(failover))
|
|
399
|
+
return;
|
|
400
|
+
for (const entry of failover) {
|
|
401
|
+
if (entry === null || typeof entry !== 'object')
|
|
402
|
+
continue;
|
|
403
|
+
const e = entry;
|
|
404
|
+
if (e['apiKeyEnv'] !== undefined) {
|
|
405
|
+
delete e['apiKeyEnv'];
|
|
406
|
+
warn(`klyro: ignoring apiKeyEnv in project config ${file} — a repo must not choose which env var holds your key; configure that fallback in ~/.klyro/settings.json\n`);
|
|
407
|
+
}
|
|
408
|
+
for (const k of ['baseURL', 'baseUrl']) {
|
|
409
|
+
const v = e[k];
|
|
410
|
+
if (typeof v === 'string' && v.length > 0 && isPublicHostUrl(v)) {
|
|
411
|
+
warn(`klyro: warning: project config ${file} routes a failover provider to ${v} — API requests (including your key) will go there\n`);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
331
416
|
// --- Merged load with 5-layer precedence ---
|
|
332
417
|
export async function loadMergedConfig(cwd = process.cwd(), flags = {}) {
|
|
333
418
|
const layers = [];
|
|
@@ -351,7 +436,7 @@ export async function loadMergedConfig(cwd = process.cwd(), flags = {}) {
|
|
|
351
436
|
try {
|
|
352
437
|
const raw = await fs.readFile(path.join(cwd, '.klyro', 'settings.json'), 'utf-8');
|
|
353
438
|
const obj = parseJsonc(raw, path.join(cwd, '.klyro/settings.json'));
|
|
354
|
-
layers.push(validateConfig(obj, path.join(cwd, '.klyro/settings.json')));
|
|
439
|
+
layers.push(scrubProjectLayer(validateConfig(obj, path.join(cwd, '.klyro/settings.json')), path.join(cwd, '.klyro/settings.json')));
|
|
355
440
|
}
|
|
356
441
|
catch (err) {
|
|
357
442
|
const e = err;
|
|
@@ -362,7 +447,7 @@ export async function loadMergedConfig(cwd = process.cwd(), flags = {}) {
|
|
|
362
447
|
try {
|
|
363
448
|
const raw = await fs.readFile(path.join(cwd, '.klyro', 'settings.local.json'), 'utf-8');
|
|
364
449
|
const obj = parseJsonc(raw, path.join(cwd, '.klyro/settings.local.json'));
|
|
365
|
-
layers.push(validateConfig(obj, path.join(cwd, '.klyro/settings.local.json')));
|
|
450
|
+
layers.push(scrubProjectLayer(validateConfig(obj, path.join(cwd, '.klyro/settings.local.json')), path.join(cwd, '.klyro/settings.local.json')));
|
|
366
451
|
}
|
|
367
452
|
catch (err) {
|
|
368
453
|
const e = err;
|
package/dist/cli/doctor.d.ts
CHANGED
|
@@ -3,6 +3,19 @@
|
|
|
3
3
|
* Runs quick diagnostics: node version, config, provider reachability,
|
|
4
4
|
* persistence dir, git, tools.
|
|
5
5
|
*/
|
|
6
|
+
/**
|
|
7
|
+
* Pure byte interpreter for `doctor --keys` (unit-tested): names what a
|
|
8
|
+
* terminal delivered so broken arrow/paste/scroll input becomes provable
|
|
9
|
+
* fact instead of guesswork. Covers arrows, paging/home/end, Ctrl codes,
|
|
10
|
+
* bracketed-paste markers, and SGR/X10 mouse sequences.
|
|
11
|
+
*/
|
|
12
|
+
export declare function describeKeyBytes(chunk: Buffer): string;
|
|
13
|
+
/**
|
|
14
|
+
* Interactive key probe: raw-mode stdin echo of every chunk with its
|
|
15
|
+
* meaning. Proves what THIS terminal delivers for arrows, Ctrl+P/N,
|
|
16
|
+
* paste, and wheel — run it when TUI input misbehaves.
|
|
17
|
+
*/
|
|
18
|
+
export declare function runKeysProbe(): Promise<number>;
|
|
6
19
|
export declare function runDoctor(opts?: {
|
|
7
20
|
json?: boolean;
|
|
8
21
|
cwd?: string;
|