klyro 0.1.4 → 0.1.6

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.
Files changed (78) hide show
  1. package/dist/agent/anthropic-adapter.d.ts +1 -1
  2. package/dist/agent/anthropic-adapter.js +22 -20
  3. package/dist/agent/provider-adapter.js +30 -7
  4. package/dist/agent/registry.js +24 -7
  5. package/dist/agent/retry.js +54 -9
  6. package/dist/agent/runtime.d.ts +45 -1
  7. package/dist/agent/runtime.js +236 -23
  8. package/dist/chat.d.ts +3 -3
  9. package/dist/chat.js +137 -39
  10. package/dist/cli/auth.d.ts +9 -0
  11. package/dist/cli/auth.js +84 -0
  12. package/dist/cli/completion.d.ts +6 -0
  13. package/dist/cli/completion.js +66 -0
  14. package/dist/cli/config.d.ts +44 -0
  15. package/dist/cli/config.js +428 -0
  16. package/dist/cli/doctor.d.ts +8 -0
  17. package/dist/cli/doctor.js +136 -0
  18. package/dist/cli/errors.d.ts +5 -0
  19. package/dist/cli/errors.js +37 -0
  20. package/dist/cli/markdown.d.ts +9 -0
  21. package/dist/cli/markdown.js +77 -0
  22. package/dist/cli/repl.js +113 -5
  23. package/dist/cli/run.d.ts +9 -0
  24. package/dist/cli/run.js +97 -2
  25. package/dist/cli/slash/parser.d.ts +10 -0
  26. package/dist/cli/slash/parser.js +6 -1
  27. package/dist/cli/trace.d.ts +7 -0
  28. package/dist/cli/trace.js +63 -0
  29. package/dist/cli/update.d.ts +6 -0
  30. package/dist/cli/update.js +70 -0
  31. package/dist/context/system-prompt.d.ts +14 -0
  32. package/dist/context/system-prompt.js +47 -0
  33. package/dist/events/bus.d.ts +16 -0
  34. package/dist/events/bus.js +28 -0
  35. package/dist/events/catalog.d.ts +136 -0
  36. package/dist/events/catalog.js +5 -0
  37. package/dist/index.js +335 -22
  38. package/dist/persistence/session.d.ts +12 -0
  39. package/dist/persistence/session.js +45 -0
  40. package/dist/policy/engine.d.ts +11 -1
  41. package/dist/policy/engine.js +85 -2
  42. package/dist/providers/model-info.d.ts +18 -0
  43. package/dist/providers/model-info.js +18 -0
  44. package/dist/providers.js +3 -2
  45. package/dist/renderers/json.d.ts +11 -0
  46. package/dist/renderers/json.js +14 -0
  47. package/dist/renderers/terminal.d.ts +9 -0
  48. package/dist/renderers/terminal.js +41 -0
  49. package/dist/repl.js +52 -15
  50. package/dist/shared/errors.d.ts +18 -0
  51. package/dist/shared/errors.js +33 -0
  52. package/dist/shared/index.d.ts +2 -0
  53. package/dist/shared/index.js +2 -0
  54. package/dist/shared/types.d.ts +19 -0
  55. package/dist/shared/types.js +11 -0
  56. package/dist/tools/fs/read-file.d.ts +1 -31
  57. package/dist/tools/fs/read-file.js +45 -17
  58. package/dist/tools/fs/read-history.d.ts +3 -0
  59. package/dist/tools/fs/read-history.js +13 -0
  60. package/dist/tools/fs/write-file.d.ts +1 -4
  61. package/dist/tools/fs/write-file.js +35 -1
  62. package/dist/tools/normalize.d.ts +1 -1
  63. package/dist/tools/shell/shell-exec.d.ts +1 -26
  64. package/dist/tools/shell/shell-exec.js +121 -17
  65. package/dist/tools/types.d.ts +25 -0
  66. package/dist/trace/writer.d.ts +13 -0
  67. package/dist/trace/writer.js +53 -0
  68. package/dist/tui/app.d.ts +1 -15
  69. package/dist/tui/app.js +203 -22
  70. package/dist/tui/approval.js +25 -4
  71. package/dist/tui/approval.test.js +1 -1
  72. package/dist/tui/snapshot.test.js +2 -2
  73. package/dist/tui/status.js +5 -1
  74. package/dist/util/log.d.ts +12 -0
  75. package/dist/util/log.js +75 -0
  76. package/dist/verification/auto.d.ts +8 -0
  77. package/dist/verification/auto.js +52 -0
  78. package/package.json +3 -2
@@ -17,6 +17,10 @@
17
17
  import { text, toolUse, toolResult as mkToolResult } from './message.js';
18
18
  import { redact } from '../policy/secret-redactor.js';
19
19
  import { RuntimeTelemetry, emptyTelemetryBlock, summarizeToolCall } from '../context/level7.js';
20
+ import { verify, diagnosticForModel } from '../verification/engine.js';
21
+ import { detectVerifyCommand } from '../verification/auto.js';
22
+ import { globalBus } from '../events/bus.js';
23
+ import { TraceWriter } from '../trace/writer.js';
20
24
  const DEFAULT_MAX_STEPS = 30;
21
25
  /** Convert a registry of tools into ToolDefinitions for the provider. */
22
26
  export function toolDefinitions(registry) {
@@ -28,22 +32,94 @@ export function toolDefinitions(registry) {
28
32
  }
29
33
  /** Run the autonomous loop. */
30
34
  export async function run(opts, deps) {
31
- const maxSteps = opts.maxSteps ?? DEFAULT_MAX_STEPS;
32
- const transcript = opts.initialTranscript
33
- ? [...opts.initialTranscript, { role: 'user', content: [text(opts.task)] }]
34
- : [{ role: 'user', content: [text(opts.task)] }];
35
+ const maxSteps = opts.maxTurns ?? opts.maxSteps ?? DEFAULT_MAX_STEPS;
36
+ const transcript = (() => {
37
+ if (opts.initialTranscript) {
38
+ const base = [...opts.initialTranscript];
39
+ // Avoid duplicating task when resuming a session that already ends with same task
40
+ const last = base[base.length - 1];
41
+ if (last?.role === 'user') {
42
+ const lastText = last.content
43
+ .filter((b) => b.kind === 'text')
44
+ .map((b) => b.text ?? '')
45
+ .join('');
46
+ if (lastText === opts.task)
47
+ return base;
48
+ }
49
+ return [...base, { role: 'user', content: [text(opts.task)] }];
50
+ }
51
+ return [{ role: 'user', content: [text(opts.task)] }];
52
+ })();
35
53
  const usage = { input: 0, output: 0 };
36
54
  let steps = 0;
37
55
  let toolCallCount = 0;
38
56
  let finalText = '';
39
57
  let repairs = 0;
58
+ let verificationAttempts = 0;
59
+ let hasEdits = false;
40
60
  const emit = opts.onEvent;
41
61
  const telemetry = new RuntimeTelemetry();
42
62
  telemetry.setMaxSteps(maxSteps);
63
+ // 3.1 — Event bus + TraceWriter
64
+ const bus = deps.bus ?? globalBus;
65
+ let tracer;
66
+ if (opts.persist?.sessionId) {
67
+ tracer = new TraceWriter(opts.persist.sessionId);
68
+ await tracer.init().catch(() => undefined);
69
+ }
70
+ const emitKlyro = (ev) => {
71
+ bus.emit(ev);
72
+ tracer?.write(ev).catch(() => undefined);
73
+ };
74
+ const closeTracer = async () => {
75
+ try {
76
+ await tracer?.close();
77
+ }
78
+ catch { /* ignore */ }
79
+ };
80
+ // Level 9 — persistence helpers
81
+ const store = opts.persist?.store;
82
+ const sessionId = opts.persist?.sessionId;
83
+ async function checkpoint(msg, obs) {
84
+ if (!store || !sessionId)
85
+ return;
86
+ try {
87
+ if (msg) {
88
+ await store.appendMessage(sessionId, { role: msg.role, content: msg.content, ts: Date.now() });
89
+ }
90
+ if (obs) {
91
+ await store.appendObservation(sessionId, {
92
+ toolCallId: obs.toolCallId,
93
+ toolName: obs.toolName,
94
+ input: obs.input,
95
+ output: obs.output,
96
+ isError: obs.isError,
97
+ startedAt: Date.now(),
98
+ finishedAt: Date.now(),
99
+ });
100
+ }
101
+ emit?.({ kind: 'checkpoint_saved', sessionId });
102
+ }
103
+ catch {
104
+ // best-effort — don't crash runtime on persistence failure
105
+ }
106
+ }
107
+ // Persist initial user message
108
+ if (store && sessionId && transcript.length > 0) {
109
+ // Fire-and-forget initial checkpoint (don't await to block loop start)
110
+ void checkpoint(transcript[transcript.length - 1]);
111
+ }
43
112
  outer: while (steps < maxSteps) {
44
113
  if (opts.signal?.aborted) {
45
114
  emit?.({ kind: 'aborted' });
46
- return { status: 'aborted', steps, toolCalls: toolCallCount, finalText, transcript, usage, repairs };
115
+ if (store && sessionId) {
116
+ try {
117
+ await store.setStatus(sessionId, 'aborted', finalText);
118
+ }
119
+ catch { /* ignore */ }
120
+ }
121
+ await closeTracer();
122
+ return { status: 'aborted', steps, toolCalls: toolCallCount, finalText, transcript, usage, repairs, verification: hasEdits ? { ok: false, attempts: verificationAttempts } : undefined };
47
123
  }
48
124
  steps++;
49
125
  emit?.({ kind: 'step_start', step: steps });
@@ -92,6 +168,13 @@ export async function run(opts, deps) {
92
168
  }
93
169
  else if (ev.kind === 'error') {
94
170
  telemetry.recordError(`stream_error: ${ev.code}`);
171
+ if (store && sessionId) {
172
+ try {
173
+ await store.setStatus(sessionId, 'aborted', textBuf);
174
+ }
175
+ catch { /* ignore */ }
176
+ }
177
+ await closeTracer();
95
178
  return {
96
179
  status: 'no_final',
97
180
  steps,
@@ -99,6 +182,8 @@ export async function run(opts, deps) {
99
182
  finalText: textBuf,
100
183
  transcript,
101
184
  usage,
185
+ repairs,
186
+ verification: hasEdits ? { ok: false, attempts: verificationAttempts } : undefined,
102
187
  };
103
188
  }
104
189
  }
@@ -119,91 +204,219 @@ export async function run(opts, deps) {
119
204
  assistantContent.push(toolUse(tc.id, tc.name, input));
120
205
  emit?.({ kind: 'tool_call_end', id: tc.id, name: tc.name, input });
121
206
  }
122
- transcript.push({ role: 'assistant', content: assistantContent });
123
- // No tool calls → done.
207
+ const assistantMsg = { role: 'assistant', content: assistantContent };
208
+ transcript.push(assistantMsg);
209
+ await checkpoint(assistantMsg);
210
+ // No tool calls → potential completion (Level 8 verify gate)
124
211
  if (opts.signal?.aborted) {
125
212
  finalText = textBuf;
126
213
  emit?.({ kind: 'aborted' });
127
- return { status: 'aborted', steps, toolCalls: toolCallCount, finalText, transcript, usage, repairs };
214
+ if (store && sessionId) {
215
+ try {
216
+ await store.setStatus(sessionId, 'aborted', finalText);
217
+ }
218
+ catch { /* ignore */ }
219
+ }
220
+ await closeTracer();
221
+ return { status: 'aborted', steps, toolCalls: toolCallCount, finalText, transcript, usage, repairs, verification: hasEdits ? { ok: false, attempts: verificationAttempts } : undefined };
128
222
  }
129
223
  if (finalizedCalls.length === 0) {
130
224
  finalText = textBuf;
225
+ // Level 8 — Verification + Autonomous Repair
226
+ const verifyEnabled = opts.verify?.enabled !== false;
227
+ const verifyCmd = opts.verify?.command ?? detectVerifyCommand(opts.cwd);
228
+ const maxRepairs = opts.verify?.maxRepairAttempts ?? 3;
229
+ if (verifyEnabled && hasEdits && verifyCmd && verificationAttempts < maxRepairs) {
230
+ emit?.({ kind: 'verification_started', command: verifyCmd });
231
+ let vResult;
232
+ try {
233
+ vResult = await verify({ cwd: opts.cwd, command: verifyCmd, timeoutMs: opts.verify?.timeoutMs });
234
+ }
235
+ catch (e) {
236
+ const msg = e instanceof Error ? e.message : String(e);
237
+ vResult = { ok: false, exitCode: -1, stdout: '', stderr: msg, failure: { type: 'unknown', files: [], raw: msg, exitCode: -1 } };
238
+ }
239
+ if (vResult.ok) {
240
+ emit?.({ kind: 'verification_succeeded', command: verifyCmd });
241
+ emit?.({ kind: 'final_text', text: finalText });
242
+ emit?.({ kind: 'step_end', step: steps });
243
+ if (store && sessionId) {
244
+ try {
245
+ await store.setStatus(sessionId, 'complete', finalText);
246
+ }
247
+ catch { /* ignore */ }
248
+ }
249
+ await closeTracer();
250
+ return { status: 'complete', steps, toolCalls: toolCallCount, finalText, transcript, usage, repairs, verification: { ok: true, command: verifyCmd, attempts: verificationAttempts } };
251
+ }
252
+ // Failure → repair loop
253
+ verificationAttempts++;
254
+ const diagnostic = diagnosticForModel(vResult);
255
+ const failureType = vResult.failure?.type ?? 'unknown';
256
+ emit?.({ kind: 'verification_failed', step: String(steps), reason: diagnostic.slice(0, 800) });
257
+ emit?.({ kind: 'repair_started', attempt: verificationAttempts, maxAttempts: maxRepairs, reason: diagnostic.slice(0, 400) });
258
+ telemetry.recordError(`verify_${failureType}`);
259
+ const repairMsg = {
260
+ role: 'user',
261
+ content: [text(`Verification failed (attempt ${verificationAttempts}/${maxRepairs}) running \`${verifyCmd}\`:\n\n${diagnostic}\n\nPlease analyze the failure, re-read the failing files, and repair the code. Focus on the error above.`)],
262
+ };
263
+ transcript.push(repairMsg);
264
+ await checkpoint(repairMsg);
265
+ if (store && sessionId) {
266
+ try {
267
+ await store.setStatus(sessionId, 'verify_failed', finalText);
268
+ }
269
+ catch { /* ignore */ }
270
+ }
271
+ if (verificationAttempts >= maxRepairs) {
272
+ emit?.({ kind: 'final_text', text: finalText });
273
+ emit?.({ kind: 'step_end', step: steps });
274
+ await closeTracer();
275
+ return { status: 'verify_failed', steps, toolCalls: toolCallCount, finalText, transcript, usage, repairs, verification: { ok: false, command: verifyCmd, attempts: verificationAttempts, failureType } };
276
+ }
277
+ emit?.({ kind: 'step_end', step: steps });
278
+ continue; // -> next iteration lets model repair
279
+ }
131
280
  emit?.({ kind: 'final_text', text: finalText });
132
281
  emit?.({ kind: 'step_end', step: steps });
133
- return { status: 'complete', steps, toolCalls: toolCallCount, finalText, transcript, usage, repairs };
282
+ if (store && sessionId) {
283
+ try {
284
+ await store.setStatus(sessionId, 'complete', finalText);
285
+ }
286
+ catch { /* ignore */ }
287
+ }
288
+ await closeTracer();
289
+ return { status: 'complete', steps, toolCalls: toolCallCount, finalText, transcript, usage, repairs, verification: hasEdits ? { ok: true, attempts: verificationAttempts } : undefined };
134
290
  }
135
- // Execute each tool call (after policy).
291
+ // 3.1 — emit turn events
292
+ emitKlyro({ type: 'turn.start', ts: Date.now(), sessionId: sessionId ?? 'ephemeral', turn: steps, model: opts.model });
293
+ // Execute each tool call (after policy) — 3.5 parallel if all concurrencySafe
136
294
  const toolCtx = {
137
295
  cwd: opts.cwd,
138
296
  env: process.env,
139
297
  signal: opts.signal,
140
298
  nonInteractive: opts.nonInteractive,
299
+ sessionId,
141
300
  };
142
- for (const call of finalizedCalls) {
143
- toolCallCount++;
301
+ const allSafe = finalizedCalls.length > 1 && finalizedCalls.every((c) => deps.registry.get(c.name)?.isConcurrencySafe !== false);
302
+ const runOne = async (call) => {
303
+ // Use per-call handling without continue (runOne is not a loop)
144
304
  const decision = await deps.policy.evaluate({ name: call.name, input: call.input }, { cwd: opts.cwd, nonInteractive: opts.nonInteractive });
145
305
  emit?.({ kind: 'policy_decision', id: call.id, name: call.name, action: decision.action, ...(decision.action !== 'allow' ? { reason: decision.reason } : {}) });
306
+ // Mirror to KlyroEvent bus
307
+ emitKlyro({ type: 'permission.decision', ts: Date.now(), sessionId: sessionId ?? 'ephemeral', callId: call.id, action: decision.action, ...(decision.action !== 'allow' ? { reason: decision.reason } : {}) });
146
308
  if (decision.action === 'deny') {
147
- transcript.push({
309
+ const denyMsg = {
148
310
  role: 'tool',
149
311
  content: [
150
312
  mkToolResult(call.id, call.name, { error: 'POLICY_DENIED', reason: decision.reason }, true),
151
313
  ],
152
- });
314
+ };
315
+ transcript.push(denyMsg);
316
+ await checkpoint(denyMsg, { toolCallId: call.id, toolName: call.name, input: call.input, output: { error: 'POLICY_DENIED', reason: decision.reason }, isError: true });
317
+ emitKlyro({ type: 'tool.result', ts: Date.now(), sessionId: sessionId ?? 'ephemeral', callId: call.id, name: call.name, output: { error: 'POLICY_DENIED' }, isError: true, latencyMs: 0 });
153
318
  telemetry.recordToolError(call, 'policy_denied');
154
319
  emit?.({ kind: 'tool_result', id: call.id, name: call.name, output: { error: 'POLICY_DENIED', reason: decision.reason }, isError: true, latencyMs: 0 });
155
- continue;
320
+ return;
156
321
  }
157
322
  if (decision.action === 'ask') {
323
+ emitKlyro({ type: 'permission.ask', ts: Date.now(), sessionId: sessionId ?? 'ephemeral', callId: call.id, name: call.name, reason: decision.reason });
158
324
  const choice = await deps.approval.ask({
159
325
  toolName: call.name,
160
326
  reason: decision.reason,
161
327
  summary: summarizeToolCall(call),
162
328
  });
329
+ // Approval UI in TUI handles y/a/A/n/e/? — e edits input, ? explains
163
330
  if (choice === 'deny') {
164
- transcript.push({
331
+ const denyMsg2 = {
165
332
  role: 'tool',
166
333
  content: [
167
334
  mkToolResult(call.id, call.name, { error: 'POLICY_DENIED', reason: 'user denied' }, true),
168
335
  ],
169
- });
336
+ };
337
+ transcript.push(denyMsg2);
338
+ await checkpoint(denyMsg2, { toolCallId: call.id, toolName: call.name, input: call.input, output: { error: 'POLICY_DENIED', reason: 'user denied' }, isError: true });
170
339
  telemetry.recordToolError(call, 'user_denied');
171
340
  emit?.({ kind: 'tool_result', id: call.id, name: call.name, output: { error: 'POLICY_DENIED', reason: 'user denied' }, isError: true, latencyMs: 0 });
172
- continue;
341
+ return;
173
342
  }
343
+ // Handle 'edit' choice: for now treat as allow with edited input (future: re-prompt)
174
344
  repairs++;
175
345
  }
176
346
  const t0 = Date.now();
347
+ emitKlyro({ type: 'tool.call', ts: Date.now(), sessionId: sessionId ?? 'ephemeral', callId: call.id, name: call.name, input: call.input });
177
348
  const obs = await deps.registry.execute(call.name, call.input, toolCtx);
178
349
  const latencyMs = Date.now() - t0;
179
350
  const output = obs.ok ? redactOutput(obs.value) : redactOutput({ error: obs.error });
180
- transcript.push({
351
+ const toolMsg = {
181
352
  role: 'tool',
182
353
  content: [mkToolResult(call.id, call.name, output, !obs.ok)],
183
- });
354
+ };
355
+ transcript.push(toolMsg);
356
+ await checkpoint(toolMsg, { toolCallId: call.id, toolName: call.name, input: call.input, output, isError: !obs.ok });
184
357
  if (obs.ok) {
185
358
  telemetry.recordToolCall(call, latencyMs, false);
359
+ if (call.name === 'write_file' || call.name === 'edit_file')
360
+ hasEdits = true;
186
361
  }
187
362
  else {
188
363
  const code = String(obs.error?.code ?? 'tool_error');
189
364
  telemetry.recordToolCall(call, latencyMs, true);
190
365
  telemetry.recordError(`${code}: ${call.name}`);
191
366
  }
367
+ emitKlyro({ type: 'tool.result', ts: Date.now(), sessionId: sessionId ?? 'ephemeral', callId: call.id, name: call.name, output, isError: !obs.ok, latencyMs });
192
368
  emit?.({ kind: 'tool_result', id: call.id, name: call.name, output, isError: !obs.ok, latencyMs });
193
369
  if (obs.ok) {
194
370
  const fileChanged = inferFileChanged(call.name, call.input, obs.value);
195
- if (fileChanged)
371
+ if (fileChanged) {
196
372
  emit?.({ kind: 'file_changed', path: fileChanged.path, op: fileChanged.op });
373
+ emitKlyro({ type: 'file.changed', ts: Date.now(), sessionId: sessionId ?? 'ephemeral', path: fileChanged.path, op: fileChanged.op });
374
+ }
375
+ }
376
+ };
377
+ // 3.5 — parallel if all concurrencySafe, sequential otherwise
378
+ if (allSafe) {
379
+ await Promise.all(finalizedCalls.map((c) => { toolCallCount++; return runOne(c); }));
380
+ }
381
+ else {
382
+ for (const call of finalizedCalls) {
383
+ toolCallCount++;
384
+ await runOne(call);
385
+ // 3.5 — cancellation: if signal aborted mid-tools, stop
386
+ if (opts.signal?.aborted)
387
+ break;
197
388
  }
198
389
  }
199
390
  emit?.({ kind: 'step_end', step: steps });
391
+ emitKlyro({ type: 'turn.end', ts: Date.now(), sessionId: sessionId ?? 'ephemeral', turn: steps });
392
+ // Level 9 — checkpoint status after each step
393
+ if (store && sessionId) {
394
+ try {
395
+ await store.setStatus(sessionId, 'open');
396
+ }
397
+ catch { /* ignore */ }
398
+ }
200
399
  }
201
400
  if (opts.signal?.aborted) {
202
401
  emit?.({ kind: 'aborted' });
203
- return { status: 'aborted', steps, toolCalls: toolCallCount, finalText, transcript, usage, repairs };
402
+ if (store && sessionId) {
403
+ try {
404
+ await store.setStatus(sessionId, 'aborted', finalText);
405
+ }
406
+ catch { /* ignore */ }
407
+ }
408
+ await closeTracer();
409
+ return { status: 'aborted', steps, toolCalls: toolCallCount, finalText, transcript, usage, repairs, verification: hasEdits ? { ok: false, attempts: verificationAttempts } : undefined };
204
410
  }
205
411
  emit?.({ kind: 'final_text', text: finalText });
206
- return { status: 'max_steps', steps, toolCalls: toolCallCount, finalText, transcript, usage, repairs };
412
+ if (store && sessionId) {
413
+ try {
414
+ await store.setStatus(sessionId, 'max_steps', finalText);
415
+ }
416
+ catch { /* ignore */ }
417
+ }
418
+ await closeTracer();
419
+ return { status: 'max_steps', steps, toolCalls: toolCallCount, finalText, transcript, usage, repairs, verification: hasEdits ? { ok: false, attempts: verificationAttempts } : undefined };
207
420
  }
208
421
  function redactOutput(v) {
209
422
  if (typeof v === 'string')
package/dist/chat.d.ts CHANGED
@@ -27,9 +27,9 @@ export declare function normalizeBaseURL(url: string): string;
27
27
  export declare function assertSafeBaseURL(url: string): void;
28
28
  export declare function chat(prompt: string, system: string, modelOverride?: string, opts?: ChatOptions): Promise<void>;
29
29
  /**
30
- * Parse SSE frames and write text deltas to stdout. Each `data: ...` line is
31
- * one chunk; `[DONE]` terminates the stream. Respects backpressure on stdout
32
- * and the provided abort signal.
30
+ * Parse SSE frames and write text deltas to stdout. Handles
31
+ * fragmented chunks by buffering and splitting on \n\n event boundary.
32
+ * Respects backpressure on stdout and the provided abort signal.
33
33
  */
34
34
  export declare function streamToStdout(body: ReadableStream<Uint8Array>, signal: AbortSignal): Promise<void>;
35
35
  /**
package/dist/chat.js CHANGED
@@ -34,10 +34,18 @@ export function assertSafeBaseURL(url) {
34
34
  return;
35
35
  if (parsed.protocol === 'http:') {
36
36
  const host = parsed.hostname.toLowerCase();
37
- if (host === 'localhost' || host === '127.0.0.1' || host === '::1')
37
+ // Allow loopback equivalents: localhost, 127.0.0.1, ::1, 0.0.0.0, ::, 127.x.x.x is NOT allowed without https
38
+ // Note: hostnames that resolve to loopback (e.g. nip.io) still require https — we check hostname, not DNS.
39
+ if (host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '0.0.0.0' || host === '::' || host === '[::]')
38
40
  return;
41
+ // Also allow 127.0.0.0/8 range via prefix check (e.g. 127.0.0.2)
42
+ if (host.startsWith('127.')) {
43
+ const parts = host.split('.');
44
+ if (parts.length === 4 && parts.every((p) => /^\d+$/.test(p) && Number(p) >= 0 && Number(p) <= 255))
45
+ return;
46
+ }
39
47
  throw new Error(`Refusing to send KLYRO_API_KEY over plaintext HTTP to ${host}. ` +
40
- `Use https:// or a localhost URL.`);
48
+ `Use https:// or a localhost URL (localhost, 127.0.0.1, ::1, 0.0.0.0).`);
41
49
  }
42
50
  throw new Error(`Unsupported KLYRO_BASE_URL protocol: ${parsed.protocol}`);
43
51
  }
@@ -53,12 +61,17 @@ export async function chat(prompt, system, modelOverride, opts = {}) {
53
61
  assertSafeBaseURL(baseURL);
54
62
  const ac = new AbortController();
55
63
  const timeout = setTimeout(() => ac.abort(new Error(`request timed out after ${timeoutMs}ms`)), timeoutMs);
64
+ // Ensure timer is always cleared — use try/finally pattern
65
+ const clearTimer = () => clearTimeout(timeout);
56
66
  // If the caller passed their own signal, abort when they abort.
67
+ let callerAbortHandler;
57
68
  if (opts.signal) {
58
69
  if (opts.signal.aborted)
59
70
  ac.abort(opts.signal.reason);
60
- else
61
- opts.signal.addEventListener('abort', () => ac.abort(opts.signal?.reason), { once: true });
71
+ else {
72
+ callerAbortHandler = () => ac.abort(opts.signal?.reason);
73
+ opts.signal.addEventListener('abort', callerAbortHandler, { once: true });
74
+ }
62
75
  }
63
76
  let res;
64
77
  try {
@@ -80,12 +93,16 @@ export async function chat(prompt, system, modelOverride, opts = {}) {
80
93
  });
81
94
  }
82
95
  catch (err) {
83
- clearTimeout(timeout);
96
+ clearTimer();
97
+ if (callerAbortHandler && opts.signal)
98
+ opts.signal.removeEventListener('abort', callerAbortHandler);
84
99
  const msg = err instanceof Error ? err.message : String(err);
85
100
  console.error(`klyro: request failed: ${msg}`);
86
101
  process.exit(1);
87
102
  }
88
- clearTimeout(timeout);
103
+ clearTimer();
104
+ if (callerAbortHandler && opts.signal)
105
+ opts.signal.removeEventListener('abort', callerAbortHandler);
89
106
  if (!res.ok) {
90
107
  const text = await readBoundedText(res.body, MAX_ERROR_BODY_BYTES);
91
108
  console.error(`klyro: HTTP ${res.status} ${res.statusText}\n${text}`);
@@ -109,9 +126,9 @@ export async function chat(prompt, system, modelOverride, opts = {}) {
109
126
  }
110
127
  }
111
128
  /**
112
- * Parse SSE frames and write text deltas to stdout. Each `data: ...` line is
113
- * one chunk; `[DONE]` terminates the stream. Respects backpressure on stdout
114
- * and the provided abort signal.
129
+ * Parse SSE frames and write text deltas to stdout. Handles
130
+ * fragmented chunks by buffering and splitting on \n\n event boundary.
131
+ * Respects backpressure on stdout and the provided abort signal.
115
132
  */
116
133
  export async function streamToStdout(body, signal) {
117
134
  const reader = body.getReader();
@@ -126,38 +143,77 @@ export async function streamToStdout(body, signal) {
126
143
  if (done)
127
144
  break;
128
145
  buf += decoder.decode(value, { stream: true });
146
+ // SSE events are separated by \n\n — handle fragmented deliveries
129
147
  let idx;
130
- while ((idx = buf.indexOf('\n')) !== -1) {
148
+ while ((idx = buf.indexOf('\n\n')) !== -1) {
131
149
  if (signal.aborted)
132
150
  throw new Error('aborted');
133
- const line = buf.slice(0, idx).replace(/\r$/, '');
134
- buf = buf.slice(idx + 1);
135
- if (!line.startsWith('data:'))
136
- continue;
137
- const data = line.slice(5).trim();
138
- if (data === '[DONE]') {
139
- process.stdout.write('\n');
140
- return;
141
- }
142
- let parsed;
143
- try {
144
- parsed = JSON.parse(data);
145
- }
146
- catch {
147
- continue;
148
- }
149
- const text = parsed.choices?.[0]?.delta?.content;
150
- if (text) {
151
- if (!await writeWithBackpressure(text)) {
152
- // stdout closed (pipe severed). Stop reading.
153
- try {
154
- await reader.cancel();
155
- }
156
- catch {
157
- /* ignore */
151
+ const event = buf.slice(0, idx);
152
+ buf = buf.slice(idx + 2);
153
+ for (const rawLine of event.split('\n')) {
154
+ const line = rawLine.replace(/\r$/, '');
155
+ if (!line.startsWith('data:'))
156
+ continue;
157
+ const data = line.slice(5).trim();
158
+ if (data === '[DONE]') {
159
+ process.stdout.write('\n');
160
+ return;
161
+ }
162
+ // Handle case where data was split across chunks and reassembled as event
163
+ if (data === '')
164
+ continue;
165
+ let parsed;
166
+ try {
167
+ parsed = JSON.parse(data);
168
+ }
169
+ catch {
170
+ continue;
171
+ }
172
+ const text = parsed.choices?.[0]?.delta?.content;
173
+ if (text) {
174
+ if (!await writeWithBackpressure(text, signal)) {
175
+ try {
176
+ await reader.cancel();
177
+ }
178
+ catch { /* ignore */ }
179
+ return;
158
180
  }
181
+ }
182
+ }
183
+ }
184
+ // Also handle single \n lines that haven't yet formed \n\n (for providers that send \n only)
185
+ // Process remaining complete lines if no \n\n found but buffer has \n
186
+ if (buf.includes('\n') && !buf.includes('\n\n')) {
187
+ const lines = buf.split('\n');
188
+ // Keep last incomplete line in buf
189
+ buf = lines.pop() ?? '';
190
+ for (const rawLine of lines) {
191
+ const line = rawLine.replace(/\r$/, '');
192
+ if (!line.startsWith('data:'))
193
+ continue;
194
+ const data = line.slice(5).trim();
195
+ if (data === '[DONE]') {
196
+ process.stdout.write('\n');
159
197
  return;
160
198
  }
199
+ if (data === '')
200
+ continue;
201
+ try {
202
+ const parsed = JSON.parse(data);
203
+ const text = parsed.choices?.[0]?.delta?.content;
204
+ if (text) {
205
+ if (!await writeWithBackpressure(text, signal)) {
206
+ try {
207
+ await reader.cancel();
208
+ }
209
+ catch { /* ignore */ }
210
+ return;
211
+ }
212
+ }
213
+ }
214
+ catch {
215
+ continue;
216
+ }
161
217
  }
162
218
  }
163
219
  }
@@ -175,20 +231,47 @@ export async function streamToStdout(body, signal) {
175
231
  /**
176
232
  * Write to stdout and wait for the drain event if the buffer is full.
177
233
  * Returns false if stdout has been closed (e.g. piped to `head`).
234
+ * Respects abort signal — resolves false if aborted while waiting.
178
235
  */
179
- function writeWithBackpressure(chunk) {
236
+ function writeWithBackpressure(chunk, signal) {
180
237
  return new Promise((resolve) => {
238
+ if (signal?.aborted) {
239
+ resolve(false);
240
+ return;
241
+ }
181
242
  if (!process.stdout.write(chunk)) {
182
- const onDrain = () => {
243
+ let settled = false;
244
+ const cleanup = () => {
245
+ process.stdout.off('drain', onDrain);
183
246
  process.stdout.off('error', onError);
247
+ if (signal)
248
+ signal.removeEventListener('abort', onAbort);
249
+ };
250
+ const onDrain = () => {
251
+ if (settled)
252
+ return;
253
+ settled = true;
254
+ cleanup();
184
255
  resolve(true);
185
256
  };
186
257
  const onError = () => {
187
- process.stdout.off('drain', onDrain);
258
+ if (settled)
259
+ return;
260
+ settled = true;
261
+ cleanup();
262
+ resolve(false);
263
+ };
264
+ const onAbort = () => {
265
+ if (settled)
266
+ return;
267
+ settled = true;
268
+ cleanup();
188
269
  resolve(false);
189
270
  };
190
271
  process.stdout.once('drain', onDrain);
191
272
  process.stdout.once('error', onError);
273
+ if (signal)
274
+ signal.addEventListener('abort', onAbort, { once: true });
192
275
  }
193
276
  else {
194
277
  resolve(true);
@@ -207,9 +290,18 @@ export async function readBoundedText(body, max) {
207
290
  const chunks = [];
208
291
  try {
209
292
  while (received < max) {
210
- const { value, done } = await reader.read();
293
+ let result;
294
+ try {
295
+ result = await reader.read();
296
+ }
297
+ catch {
298
+ break;
299
+ }
300
+ const { value, done } = result;
211
301
  if (done)
212
302
  break;
303
+ if (!value)
304
+ break;
213
305
  const remaining = max - received;
214
306
  if (value.byteLength <= remaining) {
215
307
  chunks.push(value);
@@ -228,6 +320,12 @@ export async function readBoundedText(body, max) {
228
320
  catch {
229
321
  /* ignore */
230
322
  }
323
+ try {
324
+ await reader.cancel().catch(() => undefined);
325
+ }
326
+ catch {
327
+ /* ignore */
328
+ }
231
329
  }
232
330
  const decoder = new TextDecoder('utf-8');
233
331
  const text = decoder.decode(Buffer.concat(chunks));
@@ -0,0 +1,9 @@
1
+ /**
2
+ * 2.2 — klyro login / logout / aliases
3
+ * Stores masked key → ~/.klyro/credentials.json 0600
4
+ */
5
+ export declare function runLogin(): Promise<number>;
6
+ export declare function runLogout(provider?: string): Promise<number>;
7
+ export declare function getStoredKey(provider: string): string | undefined;
8
+ export declare const MODEL_ALIASES: Record<string, string>;
9
+ export declare function resolveAlias(model: string): string;