klyro 1.0.5 → 1.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/README.md +13 -0
  2. package/dist/agent/custom-agents.d.ts +3 -0
  3. package/dist/agent/custom-agents.js +96 -0
  4. package/dist/agent/orchestrator.d.ts +26 -0
  5. package/dist/agent/orchestrator.js +41 -4
  6. package/dist/agent/runtime.d.ts +15 -0
  7. package/dist/agent/runtime.js +232 -61
  8. package/dist/chat.d.ts +10 -0
  9. package/dist/chat.js +39 -7
  10. package/dist/checkpoints/store.d.ts +11 -0
  11. package/dist/checkpoints/store.js +32 -0
  12. package/dist/cli/auth.d.ts +10 -3
  13. package/dist/cli/auth.js +43 -5
  14. package/dist/cli/completion.js +2 -2
  15. package/dist/cli/config.d.ts +4 -4
  16. package/dist/cli/doctor.js +0 -1
  17. package/dist/cli/eval.d.ts +15 -1
  18. package/dist/cli/eval.js +43 -5
  19. package/dist/cli/hooks.d.ts +74 -5
  20. package/dist/cli/hooks.js +118 -7
  21. package/dist/cli/init.d.ts +6 -0
  22. package/dist/cli/init.js +60 -0
  23. package/dist/cli/keychain.d.ts +10 -0
  24. package/dist/cli/keychain.js +86 -0
  25. package/dist/cli/repl.js +188 -30
  26. package/dist/cli/run.d.ts +7 -1
  27. package/dist/cli/run.js +92 -50
  28. package/dist/cli/setup.js +3 -2
  29. package/dist/cli/slash/custom.d.ts +25 -0
  30. package/dist/cli/slash/custom.js +166 -0
  31. package/dist/cli/slash/parser.d.ts +9 -1
  32. package/dist/cli/slash/parser.js +34 -9
  33. package/dist/cli/update.d.ts +3 -1
  34. package/dist/cli/update.js +16 -1
  35. package/dist/context/accounting.d.ts +6 -0
  36. package/dist/context/accounting.js +8 -2
  37. package/dist/context/compaction.d.ts +2 -1
  38. package/dist/context/compaction.js +39 -12
  39. package/dist/context/memory.d.ts +11 -0
  40. package/dist/context/memory.js +59 -4
  41. package/dist/eval/harness.d.ts +40 -5
  42. package/dist/eval/harness.js +103 -10
  43. package/dist/eval/judge.d.ts +32 -0
  44. package/dist/eval/judge.js +63 -0
  45. package/dist/eval/tasks.js +134 -0
  46. package/dist/index.js +239 -130
  47. package/dist/mcp/auth.d.ts +85 -0
  48. package/dist/mcp/auth.js +249 -0
  49. package/dist/mcp/client.d.ts +15 -0
  50. package/dist/mcp/client.js +42 -2
  51. package/dist/mcp/config.d.ts +31 -1
  52. package/dist/mcp/config.js +84 -1
  53. package/dist/mcp/registry.d.ts +19 -0
  54. package/dist/mcp/registry.js +118 -2
  55. package/dist/mcp/remote.d.ts +36 -0
  56. package/dist/mcp/remote.js +207 -0
  57. package/dist/mcp/sse.d.ts +42 -0
  58. package/dist/mcp/sse.js +310 -0
  59. package/dist/persistence/audit.d.ts +15 -3
  60. package/dist/persistence/audit.js +84 -13
  61. package/dist/persistence/store.d.ts +9 -0
  62. package/dist/persistence/store.js +17 -0
  63. package/dist/policy/approval.d.ts +15 -1
  64. package/dist/policy/approval.js +8 -0
  65. package/dist/policy/engine.js +9 -0
  66. package/dist/providers/endpoints.d.ts +43 -0
  67. package/dist/providers/endpoints.js +104 -0
  68. package/dist/providers.js +17 -14
  69. package/dist/tools/shell/shell-exec.d.ts +13 -0
  70. package/dist/tools/shell/shell-exec.js +64 -2
  71. package/dist/tui/app.js +172 -15
  72. package/dist/tui/app.test.js +27 -2
  73. package/dist/tui/approval.js +55 -1
  74. package/dist/tui/scroll-model.d.ts +2 -2
  75. package/dist/tui/scroll-model.js +9 -3
  76. package/dist/tui/tokens.d.ts +8 -11
  77. package/dist/tui/tokens.js +18 -11
  78. package/package.json +1 -1
package/dist/cli/run.js CHANGED
@@ -19,6 +19,7 @@ import { DenyAllApprovalPrompt } from '../policy/approval.js';
19
19
  import { redact } from '../policy/secret-redactor.js';
20
20
  import { buildLevel6Context } from '../context/level6.js';
21
21
  import { memoryBlock } from '../context/memory.js';
22
+ import { estimateCost } from '../providers/model-info.js';
22
23
  import { resolveSessionId } from '../persistence/session.js';
23
24
  import * as fs from 'node:fs';
24
25
  function readEnv(name, fallback) {
@@ -53,7 +54,8 @@ export async function runOnce(opts) {
53
54
  catch { /* ignore */ }
54
55
  // P1.4 — validate --agent early so typos fail fast (exit 2) even without API keys.
55
56
  if (opts.agent) {
56
- const { BUILTIN_AGENTS: _known } = await import('../agent/orchestrator.js');
57
+ const { listAllAgents } = await import('../agent/orchestrator.js');
58
+ const _known = listAllAgents(opts.cwd);
57
59
  if (!_known.some((a) => a.id === opts.agent)) {
58
60
  stderr.write(`klyro: unknown agent: ${opts.agent} (known: ${_known.map((a) => a.id).join(', ')})\n`);
59
61
  return 2;
@@ -141,36 +143,39 @@ export async function runOnce(opts) {
141
143
  // approve). Project-sourced servers auto-connect ONLY when their exact
142
144
  // spec hash is already in the McpTrust store (e.g. approved in a prior
143
145
  // REPL session); unknown specs are skipped with a warning.
146
+ // --bare skips MCP entirely (deterministic, no subprocesses).
144
147
  let closeMcp;
145
- try {
146
- const { loadAndRegisterMcp } = await import('../mcp/registry.js');
147
- const { McpTrust, hashSpec } = await import('../mcp/trust.js');
148
- const { loadMcpServers } = await import('../mcp/config.js');
149
- const mcpTrust = new McpTrust();
150
- const mcpSpecs = loadMcpServers(opts.cwd).servers;
151
- const mcp = await loadAndRegisterMcp({
152
- cwd: opts.cwd,
153
- registry,
154
- policy,
155
- approveProjectServer: async ({ name }) => {
156
- const spec = mcpSpecs[name];
157
- if (spec && mcpTrust.isTrusted(name, hashSpec(spec)))
158
- return true;
159
- stderr.write(`klyro: mcp project server "${name}" not in trust store (skipped — headless cannot prompt)\n`);
160
- return false;
161
- },
162
- });
163
- for (const e of mcp.errors)
164
- stderr.write(`klyro: mcp ${e.server}: ${e.message}\n`);
165
- if (mcp.registered.length > 0 && output === 'human') {
166
- stderr.write(`klyro: mcp tools: ${mcp.registered.join(', ')}\n`);
148
+ if (!opts.bare) {
149
+ try {
150
+ const { loadAndRegisterMcp } = await import('../mcp/registry.js');
151
+ const { McpTrust, hashSpec } = await import('../mcp/trust.js');
152
+ const { loadMcpServers } = await import('../mcp/config.js');
153
+ const mcpTrust = new McpTrust();
154
+ const mcpSpecs = loadMcpServers(opts.cwd).servers;
155
+ const mcp = await loadAndRegisterMcp({
156
+ cwd: opts.cwd,
157
+ registry,
158
+ policy,
159
+ approveProjectServer: async ({ name }) => {
160
+ const spec = mcpSpecs[name];
161
+ if (spec && mcpTrust.isTrusted(name, hashSpec(spec)))
162
+ return true;
163
+ stderr.write(`klyro: mcp project server "${name}" not in trust store (skipped — headless cannot prompt)\n`);
164
+ return false;
165
+ },
166
+ });
167
+ for (const e of mcp.errors)
168
+ stderr.write(`klyro: mcp ${e.server}: ${e.message}\n`);
169
+ if (mcp.registered.length > 0 && output === 'human') {
170
+ stderr.write(`klyro: mcp tools: ${mcp.registered.join(', ')}\n`);
171
+ }
172
+ closeMcp = mcp.closeAll;
167
173
  }
168
- closeMcp = mcp.closeAll;
174
+ catch { /* ignore — MCP is optional */ }
169
175
  }
170
- catch { /* ignore — MCP is optional */ }
171
- const systemPrompt = await makeRunSystemPrompt(opts.cwd, opts.systemPrompt ?? defaultRunSystemPrompt);
172
- // Level 9 — session setup (create or resume)
173
- const persistEnabled = opts.persist !== false;
176
+ const systemPrompt = await makeRunSystemPrompt(opts.cwd, opts.systemPrompt ?? defaultRunSystemPrompt, opts.bare);
177
+ // Level 9 — session setup (create or resume). --bare skips persistence.
178
+ const persistEnabled = opts.persist !== false && !opts.bare;
174
179
  let store;
175
180
  let sessionId;
176
181
  let initialTranscript;
@@ -256,17 +261,18 @@ export async function runOnce(opts) {
256
261
  ...(opts.verifyMode ? { mode: opts.verifyMode } : {}),
257
262
  };
258
263
  let result;
264
+ let endStatus = 'error';
259
265
  // P1.4 — if --agent is requested, stand up a parent orchestrator so the
260
266
  // model can call spawn_agent / task_list / task_get. The root run keeps
261
267
  // depth 0; children are capped at maxDepth (default 1 per r-6-10.fix.md).
262
268
  let agentBridge;
263
269
  let parentContext;
264
270
  if (opts.agent) {
265
- const { AgentOrchestrator, BUILTIN_AGENTS } = await import('../agent/orchestrator.js');
266
- const def = BUILTIN_AGENTS.find((a) => a.id === opts.agent); // validated above
271
+ const { AgentOrchestrator, findAgent } = await import('../agent/orchestrator.js');
272
+ const def = findAgent(opts.agent, opts.cwd); // validated above
267
273
  const maxDepth = opts.maxDepth ?? 1;
268
274
  const rootDeps = { adapter, registry, policy, approval: new DenyAllApprovalPrompt(), systemPrompt };
269
- const orchestrator = new AgentOrchestrator({ sessionId: sessionId ?? 'ephemeral', deps: rootDeps });
275
+ const orchestrator = new AgentOrchestrator({ sessionId: sessionId ?? 'ephemeral', deps: rootDeps, cwd: opts.cwd });
270
276
  const allowedTools = new Set(registry.list().map((t) => t.name));
271
277
  parentContext = {
272
278
  sessionId: sessionId ?? 'ephemeral',
@@ -304,6 +310,7 @@ export async function runOnce(opts) {
304
310
  task: opts.task,
305
311
  cwd: opts.cwd,
306
312
  model: opts.model,
313
+ ...(opts.bare ? { bare: true } : {}),
307
314
  maxSteps: opts.maxSteps,
308
315
  maxTokens: opts.maxTokens,
309
316
  temperature: opts.temperature,
@@ -371,6 +378,7 @@ export async function runOnce(opts) {
371
378
  adapter, registry, policy, approval: new DenyAllApprovalPrompt(), systemPrompt,
372
379
  ...(failoverAdapters ? { failoverAdapters } : {}),
373
380
  });
381
+ endStatus = result.status;
374
382
  }
375
383
  finally {
376
384
  doneSigint();
@@ -378,6 +386,19 @@ export async function runOnce(opts) {
378
386
  await closeMcp?.();
379
387
  }
380
388
  catch { /* ignore */ }
389
+ // sessionEnd hooks: best-effort end-of-run side effects (logging,
390
+ // notifications, cleanup). Skipped in --bare. Never affects exit code.
391
+ if (!opts.bare) {
392
+ try {
393
+ const { runSessionEndHooks } = await import('./hooks.js');
394
+ const outs = await runSessionEndHooks(opts.cwd, sessionId, endStatus);
395
+ for (const o of outs) {
396
+ if (output !== 'silent' && o.output)
397
+ stderr.write(`[hook ${o.name}] ${o.output.slice(0, 300)}\n`);
398
+ }
399
+ }
400
+ catch { /* ignore */ }
401
+ }
381
402
  }
382
403
  if (store && sessionId) {
383
404
  // Finalize session status
@@ -406,52 +427,70 @@ export async function runOnce(opts) {
406
427
  // Final cost line: headless runs previously surfaced cost only through
407
428
  // [budget] warnings. Human/silent → stderr one-liner; json → additive
408
429
  // cost_usd/usage fields on the final object (purely additive, no shape break).
409
- {
410
- const { estimateCost } = await import('../providers/model-info.js');
411
- const cost = estimateCost(opts.model, result.usage.input, result.usage.output);
412
- const costLine = `$${cost.toFixed(4)} · ${result.usage.input} in / ${result.usage.output} out${result.usage.estimated ? ' (estimated)' : ''}`;
430
+ const finalCost = estimateCost(opts.model, result.usage.input, result.usage.output);
431
+ const costLine = `$${finalCost.toFixed(4)} · ${result.usage.input} in / ${result.usage.output} out${result.usage.estimated ? ' (estimated)' : ''}`;
432
+ if (output === 'json') {
433
+ stdout.write(JSON.stringify({ kind: 'cost', cost_usd: finalCost, usage: result.usage }) + '\n');
434
+ }
435
+ else {
436
+ stderr.write(`klyro: cost ${costLine}\n`);
437
+ }
438
+ // Stable result envelope (machine contract): exactly one `kind:result`
439
+ // line per run in json mode, after all legacy per-status lines. Parsers
440
+ // should read the LAST line; legacy `kind:final` lines are kept for compat.
441
+ const emitEnvelope = (exitCode, extra = {}) => {
413
442
  if (output === 'json') {
414
- stdout.write(JSON.stringify({ kind: 'cost', cost_usd: cost, usage: result.usage }) + '\n');
415
- }
416
- else {
417
- stderr.write(`klyro: cost ${costLine}\n`);
443
+ stdout.write(JSON.stringify({
444
+ kind: 'result',
445
+ status: result.status,
446
+ exit_code: exitCode,
447
+ text: result.finalText,
448
+ steps: result.steps,
449
+ toolCalls: result.toolCalls,
450
+ cost_usd: finalCost,
451
+ usage: result.usage,
452
+ ...(result.verification ? { verification: result.verification } : {}),
453
+ ...(sessionId ? { session_id: sessionId } : {}),
454
+ ...extra,
455
+ }) + '\n');
418
456
  }
419
- }
457
+ return exitCode;
458
+ };
420
459
  if (result.status === 'max_steps') {
421
460
  if (output === 'json')
422
461
  stdout.write(JSON.stringify({ kind: 'final', status: result.status, steps: result.steps }) + '\n');
423
462
  else
424
463
  stderr.write(`klyro: hit max steps (${result.steps}); consider raising --max-steps\n`);
425
- return 7;
464
+ return emitEnvelope(7);
426
465
  }
427
466
  if (result.status === 'limit') {
428
467
  if (output === 'json')
429
468
  stdout.write(JSON.stringify({ kind: 'final', status: result.status, steps: result.steps, text: result.finalText }) + '\n');
430
469
  else
431
470
  stderr.write(`klyro: stopped early: ${result.finalText || result.status} (after ${result.steps} steps)\n`);
432
- return 7;
471
+ return emitEnvelope(7);
433
472
  }
434
473
  if (result.status === 'stuck') {
435
474
  if (output === 'json')
436
475
  stdout.write(JSON.stringify({ kind: 'final', status: result.status, steps: result.steps }) + '\n');
437
476
  else
438
477
  stderr.write(`klyro: stuck — repeated the same action with no progress; aborting after ${result.steps} steps\n`);
439
- return 7;
478
+ return emitEnvelope(7);
440
479
  }
441
480
  if (result.status === 'aborted') {
442
- return 130;
481
+ return emitEnvelope(130);
443
482
  }
444
483
  if (result.status === 'no_final') {
445
484
  if (output !== 'json')
446
485
  stderr.write('klyro: provider error — no final answer\n');
447
- return 5;
486
+ return emitEnvelope(5);
448
487
  }
449
488
  if (result.status === 'verify_failed') {
450
489
  if (output === 'json')
451
490
  stdout.write(JSON.stringify({ kind: 'final', status: 'verify_failed', failureType: result.verification?.failureType }) + '\n');
452
491
  else
453
492
  stderr.write(`klyro: verification failed after ${result.verification?.attempts ?? 3} repairs — see output above\n`);
454
- return 8;
493
+ return emitEnvelope(8);
455
494
  }
456
495
  // 6.5 — --require-verify: if edits were made but verification never passed, exit 8
457
496
  if (opts.requireVerify && result.verification && !result.verification.ok) {
@@ -459,7 +498,7 @@ export async function runOnce(opts) {
459
498
  stdout.write(JSON.stringify({ kind: 'final', status: 'require_verify_failed' }) + '\n');
460
499
  else
461
500
  stderr.write('klyro: --require-verify: verification required but not passed\n');
462
- return 8;
501
+ return emitEnvelope(8);
463
502
  }
464
503
  if (opts.requireVerify && !result.verification && result.hasEdits) {
465
504
  // Edits were made but no verification command found
@@ -467,11 +506,11 @@ export async function runOnce(opts) {
467
506
  stdout.write(JSON.stringify({ kind: 'final', status: 'require_verify_missing' }) + '\n');
468
507
  else
469
508
  stderr.write('klyro: --require-verify: no verification command found and edits were made\n');
470
- return 8;
509
+ return emitEnvelope(8);
471
510
  }
472
511
  if (output === 'json')
473
512
  stdout.write(JSON.stringify({ kind: 'final', status: 'ok', text: result.finalText }) + '\n');
474
- return 0;
513
+ return emitEnvelope(0);
475
514
  }
476
515
  async function dryRunReport(opts) {
477
516
  // Assemble the REAL prompt (Level-6 context + KLYRO.md), not the bare base —
@@ -509,7 +548,10 @@ function defaultRunSystemPrompt(_ctx) {
509
548
  return _ctx.telemetry ? { system: base, suffix: _ctx.telemetry } : { system: base };
510
549
  }
511
550
  /** Wrap a system-prompt fn to inject Level-6 context (project map etc.) + KLYRO.md (4.4). */
512
- export async function makeRunSystemPrompt(cwd, base) {
551
+ export async function makeRunSystemPrompt(cwd, base, bare = false) {
552
+ // --bare: base prompt only — no L6 scan, memory, or KLYRO.md (fast + deterministic).
553
+ if (bare)
554
+ return base;
513
555
  const ctxBlock = await buildLevel6Context({ cwd });
514
556
  const prefix = ctxBlock.formatted ? `\n\n<context>\n${ctxBlock.formatted}\n</context>` : '';
515
557
  // Session memory: .klyro/memory/session-notes.md injected so memory_write
package/dist/cli/setup.js CHANGED
@@ -13,7 +13,7 @@
13
13
  *
14
14
  * The `ask` callback is injected so this is unit-testable without a TTY.
15
15
  */
16
- import { LOGIN_DEFAULTS, getStoredKey, saveKey } from './auth.js';
16
+ import { LOGIN_DEFAULTS, saveKey } from './auth.js';
17
17
  import { loadConfig, saveConfig } from './config.js';
18
18
  import { assertSafeBaseURL } from '../chat.js';
19
19
  function isAbort(err) {
@@ -70,7 +70,8 @@ export async function runFirstRunSetup(ask) {
70
70
  const defs = LOGIN_DEFAULTS[name] ?? LOGIN_DEFAULTS.openai;
71
71
  const storeProvider = name === 'local' ? 'openai' : name;
72
72
  // Reuse an already-saved key when present — don't make the user re-paste.
73
- const hasKey = !!getStoredKey(storeProvider);
73
+ const { getStoredKeyAsync } = await import('./auth.js');
74
+ const hasKey = !!(await getStoredKeyAsync(storeProvider));
74
75
  let key = '';
75
76
  if (hasKey) {
76
77
  const keep = await ask(`API key already saved for ${storeProvider} — keep it? [Y/n]: `);
@@ -0,0 +1,25 @@
1
+ export interface FrontmatterResult {
2
+ data: Record<string, string>;
3
+ body: string;
4
+ }
5
+ /** Minimal frontmatter parser: `---` fences + `key: value` lines only. */
6
+ export declare function parseFrontmatter(text: string): FrontmatterResult;
7
+ /** Parse a scalar list: `[a, b]`, `a, b`, or newline/`- ` items. */
8
+ export declare function parseList(value: string | undefined): string[];
9
+ export declare function parseBool(value: string | undefined, fallback: boolean): boolean;
10
+ export declare function parseInt_(value: string | undefined): number | undefined;
11
+ export interface CustomCommand {
12
+ name: string;
13
+ description: string;
14
+ body: string;
15
+ source: 'project' | 'global';
16
+ }
17
+ /** Expand `$1..$9` and `$@` (all args joined by space). Missing args → ''. */
18
+ export declare function expandArgs(body: string, args: string[]): string;
19
+ /** Load custom commands: global first, project wins on name clash. Never throws. */
20
+ export declare function loadCustomCommands(cwd: string): CustomCommand[];
21
+ /**
22
+ * Relative file paths for `@`-completion: recursive walk, ignored dirs
23
+ * skipped, capped (directories sort first via trailing `/`).
24
+ */
25
+ export declare function listCompletableFiles(cwd: string): string[];
@@ -0,0 +1,166 @@
1
+ /**
2
+ * File-based extensibility: `.klyro/commands/*.md` (project) +
3
+ * `~/.klyro/commands/*.md` (global). Project wins on name clash.
4
+ *
5
+ * Format: YAML-ish frontmatter (`---` fences) with `name` (default:
6
+ * filename), `description`/`hint`, then a body supporting `$1..$9` and
7
+ * `$@` (all args). A custom command expands to prompt text and runs
8
+ * through the normal prompt path (with recursion depth guard).
9
+ */
10
+ import * as fs from 'node:fs';
11
+ import * as os from 'node:os';
12
+ import * as path from 'node:path';
13
+ /** Minimal frontmatter parser: `---` fences + `key: value` lines only. */
14
+ export function parseFrontmatter(text) {
15
+ const data = {};
16
+ if (!text.startsWith('---'))
17
+ return { data, body: text };
18
+ const end = text.indexOf('\n---', 3);
19
+ if (end === -1)
20
+ return { data, body: text };
21
+ const head = text.slice(3, end);
22
+ for (const line of head.split('\n')) {
23
+ const m = /^([A-Za-z0-9_-]+)\s*:\s*(.*)$/.exec(line.trimEnd());
24
+ if (m)
25
+ data[m[1].toLowerCase()] = (m[2] ?? '').trim();
26
+ }
27
+ return { data, body: text.slice(end + 4).replace(/^\r?\n/, '') };
28
+ }
29
+ /** Parse a scalar list: `[a, b]`, `a, b`, or newline/`- ` items. */
30
+ export function parseList(value) {
31
+ if (!value)
32
+ return [];
33
+ let v = value.trim();
34
+ if (v.startsWith('[') && v.endsWith(']'))
35
+ v = v.slice(1, -1);
36
+ const out = [];
37
+ for (const chunk of v.split(/[\n,]/)) {
38
+ const t = chunk.trim().replace(/^-+\s*/, '').replace(/^['"]|['"]$/g, '');
39
+ if (t)
40
+ out.push(t);
41
+ }
42
+ return out;
43
+ }
44
+ export function parseBool(value, fallback) {
45
+ if (value === undefined || value === '')
46
+ return fallback;
47
+ return /^(true|yes|1|on)$/i.test(value.trim());
48
+ }
49
+ export function parseInt_(value) {
50
+ if (!value)
51
+ return undefined;
52
+ const n = Number(value.trim());
53
+ return Number.isInteger(n) && n > 0 ? n : undefined;
54
+ }
55
+ /** Expand `$1..$9` and `$@` (all args joined by space). Missing args → ''. */
56
+ export function expandArgs(body, args) {
57
+ const all = args.join(' ');
58
+ return body
59
+ .replace(/\$@/g, () => all)
60
+ .replace(/\$([1-9])/g, (_m, d) => args[Number(d) - 1] ?? '');
61
+ }
62
+ function commandsDir(home) {
63
+ return [path.join(home, '.klyro', 'commands')];
64
+ }
65
+ function readCommandFile(file, source) {
66
+ let raw;
67
+ try {
68
+ raw = fs.readFileSync(file, 'utf-8');
69
+ }
70
+ catch {
71
+ return null;
72
+ }
73
+ const { data, body } = parseFrontmatter(raw);
74
+ const fallback = path.basename(file, path.extname(file)).toLowerCase();
75
+ const name = (data['name'] || fallback).toLowerCase();
76
+ if (!/^[a-z0-9_-]{1,32}$/.test(name))
77
+ return null;
78
+ if (!body.trim())
79
+ return null;
80
+ return { name, description: data['description'] || data['hint'] || '', body: body.trim(), source };
81
+ }
82
+ function listCommandFiles(dir) {
83
+ let entries;
84
+ try {
85
+ entries = fs.readdirSync(dir, { withFileTypes: true });
86
+ }
87
+ catch {
88
+ return [];
89
+ }
90
+ return entries
91
+ .filter((e) => e.isFile() && e.name.toLowerCase().endsWith('.md'))
92
+ .map((e) => path.join(dir, e.name))
93
+ .sort();
94
+ }
95
+ /** Load custom commands: global first, project wins on name clash. Never throws. */
96
+ export function loadCustomCommands(cwd) {
97
+ const byName = new Map();
98
+ try {
99
+ const home = os.homedir() || process.cwd();
100
+ for (const dir of commandsDir(home)) {
101
+ for (const f of listCommandFiles(dir)) {
102
+ const c = readCommandFile(f, 'global');
103
+ if (c)
104
+ byName.set(c.name, c);
105
+ }
106
+ }
107
+ const projectDir = path.join(cwd, '.klyro', 'commands');
108
+ for (const f of listCommandFiles(projectDir)) {
109
+ const c = readCommandFile(f, 'project');
110
+ if (c)
111
+ byName.set(c.name, c);
112
+ }
113
+ }
114
+ catch {
115
+ return [...byName.values()];
116
+ }
117
+ return [...byName.values()];
118
+ }
119
+ const COMPLETABLE_IGNORED = new Set(['node_modules', '.git', 'dist', 'build', 'coverage', '.klyro', 'out', '.next', 'target', 'vendor']);
120
+ const MAX_COMPLETABLE_FILES = 1000;
121
+ /**
122
+ * Relative file paths for `@`-completion: recursive walk, ignored dirs
123
+ * skipped, capped (directories sort first via trailing `/`).
124
+ */
125
+ export function listCompletableFiles(cwd) {
126
+ const out = [];
127
+ const walk = (dir, rel) => {
128
+ if (out.length >= MAX_COMPLETABLE_FILES)
129
+ return;
130
+ let entries;
131
+ try {
132
+ entries = fs.readdirSync(dir, { withFileTypes: true });
133
+ }
134
+ catch {
135
+ return;
136
+ }
137
+ const sorted = entries.sort((a, b) => (a.isDirectory() === b.isDirectory() ? a.name.localeCompare(b.name) : a.isDirectory() ? -1 : 1));
138
+ for (const e of sorted) {
139
+ if (out.length >= MAX_COMPLETABLE_FILES)
140
+ return;
141
+ if (rel === '' && COMPLETABLE_IGNORED.has(e.name))
142
+ continue;
143
+ if (e.name.startsWith('.') && rel === '') {
144
+ // Top-level dotfiles are completable (e.g. .env.example) but never traversed.
145
+ if (e.isFile())
146
+ out.push(e.name);
147
+ continue;
148
+ }
149
+ const r = rel === '' ? e.name : `${rel}/${e.name}`;
150
+ if (e.isDirectory()) {
151
+ out.push(`${r}/`);
152
+ walk(path.join(dir, e.name), r);
153
+ }
154
+ else if (e.isFile()) {
155
+ out.push(r);
156
+ }
157
+ }
158
+ };
159
+ try {
160
+ walk(cwd, '');
161
+ }
162
+ catch {
163
+ return out;
164
+ }
165
+ return out;
166
+ }
@@ -39,8 +39,13 @@ export type SlashCommand = {
39
39
  kind: 'diff';
40
40
  } | {
41
41
  kind: 'undo';
42
+ n?: number;
42
43
  } | {
43
44
  kind: 'rewind';
45
+ n?: number;
46
+ mode?: 'summary' | 'preview' | 'full';
47
+ } | {
48
+ kind: 'checkpoints';
44
49
  } | {
45
50
  kind: 'plan';
46
51
  task?: string;
@@ -68,6 +73,9 @@ export type SlashCommand = {
68
73
  kind: 'thinking';
69
74
  } | {
70
75
  kind: 'memory';
76
+ } | {
77
+ kind: 'memory-append';
78
+ text: string;
71
79
  } | {
72
80
  kind: 'jobs';
73
81
  } | {
@@ -323,4 +331,4 @@ export declare const COMMAND_DEFS: CommandDef[];
323
331
  */
324
332
  export declare function fuzzyScore(name: string, query: string): number;
325
333
  /** Fuzzy-match command names for TUI autocomplete — top `limit` (default 6). */
326
- export declare function suggestCommands(prefix: string, limit?: number): CommandDef[];
334
+ export declare function suggestCommands(prefix: string, limit?: number, extra?: CommandDef[]): CommandDef[];
@@ -14,7 +14,7 @@
14
14
  * Anything not starting with "/" is a regular prompt and yields
15
15
  * { kind: 'prompt', text }.
16
16
  */
17
- const KNOWN = ['help', 'clear', 'new', 'exit', 'quit', 'q', 'compact', 'resume', 'sessions', 'rename', 'fork', 'branch', 'export', 'copy', 'model', 'm', 'models', 'provider', 'p', 'effort', 'e', 'fast', 'init', 'status', 'context', 'diff', 'plan', 'todos', 'memory', 'permissions', 'mode', 'sandbox', 'approve', 'deny', 'login', 'logout', 'auth', 'version', 'update', 'cancel', 'shell', 'mention', 'tools', 'config', 'settings', 'doctor', 'cost', 'thinking', 'jobs', 'verify', 'project', 'undo', 'rewind', 'review', 'code-review', 'security-review', 'simplify', 'test', 'lint', 'build', 'run', 'fix', 'explain', 'format', 'ask', 'redo', 'checkpoint', 'accept', 'reject', 'details', 'verbose', 'raw', 'activity', 'tasks', 'ps', 'stop', 'queue', 'retry', 'kill', 'mcp', 'agents', 'agent', 'subagents', 'subtask', 'background', 'add-dir', 'cd', 'attach', 'drop', 'image', 'paste', 'files', 'ls', 'tree', 'search', 'web', 'read', 'map', 'tokens', 'commit', 'push', 'pull', 'pr', 'issue', 'editor', 'keymap', 'vim', 'theme', 'statusline', 'output-style', 'debug', 'whoami', 'reload', 'reset', 'bug', 'changelog', 'prompt', 'alias', 'commands', 'env', 'deps', 'install'];
17
+ const KNOWN = ['help', 'clear', 'new', 'exit', 'quit', 'q', 'compact', 'resume', 'sessions', 'rename', 'fork', 'branch', 'export', 'copy', 'model', 'm', 'models', 'provider', 'p', 'effort', 'e', 'fast', 'init', 'status', 'context', 'diff', 'plan', 'todos', 'memory', 'permissions', 'mode', 'sandbox', 'approve', 'deny', 'login', 'logout', 'auth', 'version', 'update', 'cancel', 'shell', 'mention', 'tools', 'config', 'settings', 'doctor', 'cost', 'thinking', 'jobs', 'verify', 'project', 'undo', 'rewind', 'checkpoints', 'review', 'code-review', 'security-review', 'simplify', 'test', 'lint', 'build', 'run', 'fix', 'explain', 'format', 'ask', 'redo', 'checkpoint', 'accept', 'reject', 'details', 'verbose', 'raw', 'activity', 'tasks', 'ps', 'stop', 'queue', 'retry', 'kill', 'mcp', 'agents', 'agent', 'subagents', 'subtask', 'background', 'add-dir', 'cd', 'attach', 'drop', 'image', 'paste', 'files', 'ls', 'tree', 'search', 'web', 'read', 'map', 'tokens', 'commit', 'push', 'pull', 'pr', 'issue', 'editor', 'keymap', 'vim', 'theme', 'statusline', 'output-style', 'debug', 'whoami', 'reload', 'reset', 'bug', 'changelog', 'prompt', 'alias', 'commands', 'env', 'deps', 'install'];
18
18
  export function parse(input) {
19
19
  const trimmed = input.trim();
20
20
  // `!cmd` alias for /shell, `@path` alias for /mention (commands.md P1)
@@ -35,14 +35,38 @@ export function parse(input) {
35
35
  case 'new': return { kind: 'new' };
36
36
  case 'compact': return { kind: 'compact', focus: rest || undefined };
37
37
  case 'diff': return { kind: 'diff' };
38
- case 'undo': return { kind: 'undo' };
39
- case 'rewind': return { kind: 'rewind' };
38
+ case 'undo': {
39
+ const un = Number(rest.split(/\s+/).filter(Boolean)[0] ?? '1');
40
+ return { kind: 'undo', n: Number.isInteger(un) && un > 0 ? un : 1 };
41
+ }
42
+ case 'rewind': {
43
+ // /rewind [n] [summary|preview|full] — nth-back snapshot (1 = latest).
44
+ // summary: post-restore revert report; preview: dry-run file list;
45
+ // full: code restore + truncate conversation after the snapshot.
46
+ const parts = rest.split(/\s+/).filter(Boolean);
47
+ const n = parts.length > 0 ? Number(parts[0]) : 1;
48
+ const modeWord = parts.slice(1).join(' ').toLowerCase();
49
+ const mode = modeWord === 'summary' || modeWord === 'preview' || modeWord === 'full' ? modeWord : undefined;
50
+ return {
51
+ kind: 'rewind',
52
+ n: Number.isInteger(n) && n > 0 ? n : 1,
53
+ ...(mode ? { mode } : {}),
54
+ };
55
+ }
56
+ case 'checkpoints': return { kind: 'checkpoints' };
40
57
  case 'plan': return { kind: 'plan', task: rest || undefined };
41
58
  case 'todos': return { kind: 'todos' };
42
59
  case 'status': return { kind: 'status' };
43
60
  case 'cost': return { kind: 'cost' };
44
61
  case 'thinking': return { kind: 'thinking' };
45
- case 'memory': return { kind: 'memory' };
62
+ case 'memory': {
63
+ // /memory shows notes; /memory append <text> writes one (human path,
64
+ // same redaction + atomicity as the memory_write tool).
65
+ const mm = /^append\s+([\s\S]+)$/.exec(rest);
66
+ if (mm)
67
+ return { kind: 'memory-append', text: mm[1].trim() };
68
+ return { kind: 'memory' };
69
+ }
46
70
  case 'jobs': return { kind: 'jobs' };
47
71
  case 'verify': return { kind: 'verify' };
48
72
  case 'project': return { kind: 'project' };
@@ -233,9 +257,9 @@ export const COMMAND_DEFS = [
233
257
  { name: 'explain', hint: 'explain code' },
234
258
  { name: 'format', hint: 'format code' },
235
259
  { name: 'ask', hint: 'read-only Q&A' },
236
- { name: 'undo', hint: 'undo change' },
237
- { name: 'redo', hint: 'redo change' },
260
+ { name: 'undo', hint: 'undo change' }, { name: 'redo', hint: 'redo change' },
238
261
  { name: 'rewind', hint: 'rewind code' },
262
+ { name: 'checkpoints', hint: 'list snapshots' },
239
263
  { name: 'checkpoint', hint: 'create checkpoint' },
240
264
  { name: 'accept', hint: 'accept edits' },
241
265
  { name: 'reject', hint: 'reject edits' },
@@ -325,11 +349,12 @@ export function fuzzyScore(name, query) {
325
349
  return score;
326
350
  }
327
351
  /** Fuzzy-match command names for TUI autocomplete — top `limit` (default 6). */
328
- export function suggestCommands(prefix, limit = 6) {
352
+ export function suggestCommands(prefix, limit = 6, extra = []) {
329
353
  const p = prefix.toLowerCase().replace(/^\//, '');
354
+ const pool = extra.length > 0 ? [...extra, ...COMMAND_DEFS] : COMMAND_DEFS;
330
355
  if (!p)
331
- return COMMAND_DEFS.slice(0, limit);
332
- return COMMAND_DEFS.map((d) => ({ d, s: fuzzyScore(d.name, p) + fuzzyScore(d.hint, p) * 0.25 }))
356
+ return pool.slice(0, limit);
357
+ return pool.map((d) => ({ d, s: fuzzyScore(d.name, p) + fuzzyScore(d.hint, p) * 0.25 }))
333
358
  .filter((x) => x.s > -Infinity)
334
359
  .sort((a, b) => b.s - a.s)
335
360
  .slice(0, limit)
@@ -12,4 +12,6 @@
12
12
  /** Minimal semver compare for `x.y.z[-prerelease]`; null when unparseable. */
13
13
  export declare function compareSemver(a: string, b: string): number | null;
14
14
  export declare function checkForUpdate(current: string): Promise<string | null>;
15
- export declare function runUpdate(): Promise<number>;
15
+ export declare function runUpdate(opts?: {
16
+ apply?: boolean;
17
+ }): Promise<number>;
@@ -138,7 +138,7 @@ export async function checkForUpdate(current) {
138
138
  }
139
139
  return null;
140
140
  }
141
- export async function runUpdate() {
141
+ export async function runUpdate(opts = {}) {
142
142
  const here = await import('../index.js').then(() => '');
143
143
  // Get version from package.json via dynamic import
144
144
  const { readFileSync } = await import('node:fs');
@@ -151,6 +151,21 @@ export async function runUpdate() {
151
151
  const latest = await checkForUpdate(cur);
152
152
  if (latest) {
153
153
  process.stdout.write(`Update available: ${cur} → ${latest} (integrity verified)\n npm i -g klyro@latest\n`);
154
+ if (opts.apply) {
155
+ // Opt-in self-apply: the tarball was already hash-verified by
156
+ // checkForUpdate, so npm installs exactly the verified version.
157
+ process.stdout.write(`Applying update to klyro@${latest}...\n`);
158
+ const { spawnSync } = await import('node:child_process');
159
+ const npmCli = process.env['npm_execpath'];
160
+ const r = npmCli
161
+ ? spawnSync(process.execPath, [npmCli, 'i', '-g', `klyro@${latest}`], { stdio: 'inherit' })
162
+ : spawnSync('npm', ['i', '-g', `klyro@${latest}`], { stdio: 'inherit', shell: process.platform === 'win32' });
163
+ if (r.error) {
164
+ process.stderr.write(`klyro update: apply failed: ${r.error.message}\n`);
165
+ return 1;
166
+ }
167
+ return r.status === 0 ? 0 : 1;
168
+ }
154
169
  }
155
170
  else {
156
171
  process.stdout.write(`klyro ${cur} is latest\n`);
@@ -7,6 +7,12 @@ export interface ContextAccounting {
7
7
  compactAt: number;
8
8
  toolResultMax: number;
9
9
  }
10
+ /**
11
+ * Single output-reserve used by every budget in the harness (displayed
12
+ * accounting, runtime enforcement, compaction elide). One constant so the
13
+ * meter and the enforcer can never disagree.
14
+ */
15
+ export declare const RESERVE_OUTPUT_TOKENS = 8000;
10
16
  export declare function accounting(system: string | undefined, messages: Message[], opts?: {
11
17
  cap?: number;
12
18
  reserveOutput?: number;