klyro 0.1.63 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/dist/agent/anthropic-adapter.d.ts +36 -0
  2. package/dist/agent/anthropic-adapter.js +73 -16
  3. package/dist/agent/capabilities.d.ts +145 -0
  4. package/dist/agent/capabilities.js +191 -0
  5. package/dist/agent/child-worker.d.ts +104 -0
  6. package/dist/agent/child-worker.js +250 -0
  7. package/dist/agent/orchestrator.d.ts +232 -0
  8. package/dist/agent/orchestrator.js +589 -0
  9. package/dist/agent/provider-adapter.d.ts +17 -0
  10. package/dist/agent/provider-adapter.js +35 -3
  11. package/dist/agent/registry.d.ts +1 -0
  12. package/dist/agent/registry.js +1 -0
  13. package/dist/agent/retry.d.ts +13 -2
  14. package/dist/agent/retry.js +21 -3
  15. package/dist/agent/runtime.d.ts +70 -8
  16. package/dist/agent/runtime.js +205 -34
  17. package/dist/agent/scoped-registry.d.ts +22 -0
  18. package/dist/agent/scoped-registry.js +42 -0
  19. package/dist/agent/task-manager.d.ts +115 -0
  20. package/dist/agent/task-manager.js +250 -0
  21. package/dist/agent/worker-spawner.d.ts +17 -12
  22. package/dist/agent/worker-spawner.js +26 -20
  23. package/dist/agent/worktree-manager.d.ts +74 -0
  24. package/dist/agent/worktree-manager.js +189 -0
  25. package/dist/checkpoints/store.js +30 -5
  26. package/dist/cli/auth.js +16 -1
  27. package/dist/cli/config.d.ts +9 -3
  28. package/dist/cli/config.js +64 -3
  29. package/dist/cli/dotenv.d.ts +3 -0
  30. package/dist/cli/dotenv.js +57 -0
  31. package/dist/cli/eval.d.ts +6 -1
  32. package/dist/cli/eval.js +9 -0
  33. package/dist/cli/repl.js +183 -17
  34. package/dist/cli/run.d.ts +10 -11
  35. package/dist/cli/run.js +176 -14
  36. package/dist/cli/update.d.ts +5 -0
  37. package/dist/cli/update.js +62 -10
  38. package/dist/context/import-graph.d.ts +2 -0
  39. package/dist/context/import-graph.js +31 -3
  40. package/dist/context/klyro-md.d.ts +6 -0
  41. package/dist/context/klyro-md.js +25 -16
  42. package/dist/context/memory.d.ts +8 -0
  43. package/dist/context/memory.js +50 -2
  44. package/dist/context/project-map.d.ts +6 -0
  45. package/dist/context/project-map.js +50 -2
  46. package/dist/context/repo-map.d.ts +2 -0
  47. package/dist/context/repo-map.js +31 -1
  48. package/dist/context/trust.d.ts +42 -0
  49. package/dist/context/trust.js +111 -0
  50. package/dist/events/catalog.d.ts +99 -0
  51. package/dist/index.js +93 -4
  52. package/dist/mcp/client.d.ts +55 -0
  53. package/dist/mcp/client.js +294 -0
  54. package/dist/mcp/config.d.ts +40 -0
  55. package/dist/mcp/config.js +99 -0
  56. package/dist/mcp/policy.d.ts +13 -0
  57. package/dist/mcp/policy.js +12 -0
  58. package/dist/mcp/registry.d.ts +72 -0
  59. package/dist/mcp/registry.js +244 -0
  60. package/dist/mcp/schema.d.ts +18 -0
  61. package/dist/mcp/schema.js +57 -0
  62. package/dist/mcp/trust.d.ts +20 -0
  63. package/dist/mcp/trust.js +74 -0
  64. package/dist/persistence/audit.d.ts +28 -0
  65. package/dist/persistence/audit.js +101 -1
  66. package/dist/persistence/store.d.ts +26 -2
  67. package/dist/persistence/store.js +140 -13
  68. package/dist/policy/approval.d.ts +14 -0
  69. package/dist/policy/approval.js +44 -2
  70. package/dist/policy/engine.d.ts +1 -0
  71. package/dist/policy/engine.js +91 -10
  72. package/dist/policy/secret-redactor.js +4 -0
  73. package/dist/providers/model-info.d.ts +17 -0
  74. package/dist/providers/model-info.js +35 -2
  75. package/dist/repl.d.ts +6 -0
  76. package/dist/repl.js +12 -7
  77. package/dist/tools/agent/spawn-agent.d.ts +9 -0
  78. package/dist/tools/agent/spawn-agent.js +50 -0
  79. package/dist/tools/agent/task-apply.d.ts +4 -0
  80. package/dist/tools/agent/task-apply.js +44 -0
  81. package/dist/tools/agent/task-get.d.ts +8 -0
  82. package/dist/tools/agent/task-get.js +40 -0
  83. package/dist/tools/agent/task-list.d.ts +4 -0
  84. package/dist/tools/agent/task-list.js +41 -0
  85. package/dist/tools/agent/task-stop.d.ts +6 -0
  86. package/dist/tools/agent/task-stop.js +39 -0
  87. package/dist/tools/agent/task-wait.d.ts +17 -0
  88. package/dist/tools/agent/task-wait.js +79 -0
  89. package/dist/tools/fs/apply-patch.js +71 -0
  90. package/dist/tools/fs/edit-file.js +65 -0
  91. package/dist/tools/fs/multi-edit.d.ts +4 -0
  92. package/dist/tools/fs/multi-edit.js +66 -0
  93. package/dist/tools/fs/write-file.js +67 -0
  94. package/dist/tools/plan/todo-write.d.ts +1 -1
  95. package/dist/tools/registry.js +12 -0
  96. package/dist/tools/shell/background.js +6 -3
  97. package/dist/tools/shell/sandbox.d.ts +51 -0
  98. package/dist/tools/shell/sandbox.js +143 -0
  99. package/dist/tools/shell/shell-exec.d.ts +1 -0
  100. package/dist/tools/shell/shell-exec.js +83 -11
  101. package/dist/tools/shell/worker-entry.d.ts +12 -0
  102. package/dist/tools/shell/worker-entry.js +43 -0
  103. package/dist/tools/types.d.ts +18 -0
  104. package/dist/tools/verify/run-verify.js +3 -1
  105. package/dist/trace/writer.d.ts +13 -0
  106. package/dist/trace/writer.js +55 -4
  107. package/dist/tui/app.js +1 -1
  108. package/dist/tui/approval.js +20 -21
  109. package/dist/util.d.ts +1 -0
  110. package/dist/util.js +1 -0
  111. package/dist/verification/baseline.js +17 -3
  112. package/dist/verification/classify.js +23 -12
  113. package/dist/verification/engine.js +3 -1
  114. package/dist/verification/registry.d.ts +2 -0
  115. package/dist/verification/registry.js +33 -0
  116. package/dist/verification/scoped.js +28 -6
  117. package/package.json +1 -1
package/dist/cli/run.js CHANGED
@@ -10,11 +10,14 @@
10
10
  import { stdout, stderr } from 'node:process';
11
11
  import { httpChatAdapter } from '../agent/provider-adapter.js';
12
12
  import { anthropicAdapter } from '../agent/anthropic-adapter.js';
13
+ import { retryingAdapter } from '../agent/retry.js';
14
+ import { globalBus } from '../events/bus.js';
13
15
  import { run } from '../agent/runtime.js';
14
16
  import { builtinRegistry } from '../tools/registry.js';
15
17
  import { builtinRules, clonePolicyConfig, PolicyEngine } from '../policy/engine.js';
16
18
  import { DenyAllApprovalPrompt } from '../policy/approval.js';
17
19
  import { buildLevel6Context } from '../context/level6.js';
20
+ import { memoryBlock } from '../context/memory.js';
18
21
  import { resolveSessionId } from '../persistence/session.js';
19
22
  import * as fs from 'node:fs';
20
23
  function readEnv(name, fallback) {
@@ -22,6 +25,13 @@ function readEnv(name, fallback) {
22
25
  return v && v.length > 0 ? v : fallback;
23
26
  }
24
27
  export async function runOnce(opts) {
28
+ // P0.5 — load <cwd>/.env first so KLYRO_* vars resolve without `export`.
29
+ // Never throws (missing file is a no-op); explicit env wins (no-clobber).
30
+ try {
31
+ const { loadDotenv } = await import('./dotenv.js');
32
+ loadDotenv(opts.cwd);
33
+ }
34
+ catch { /* ignore */ }
25
35
  const output = opts.output ?? 'human';
26
36
  if (opts.dryRun) {
27
37
  return await dryRunReport(opts);
@@ -32,6 +42,27 @@ export async function runOnce(opts) {
32
42
  clearReadHistory();
33
43
  }
34
44
  catch { /* ignore */ }
45
+ // P1.4 — validate --agent early so typos fail fast (exit 2) even without API keys.
46
+ if (opts.agent) {
47
+ const { BUILTIN_AGENTS: _known } = await import('../agent/orchestrator.js');
48
+ if (!_known.some((a) => a.id === opts.agent)) {
49
+ stderr.write(`klyro: unknown agent: ${opts.agent} (known: ${_known.map((a) => a.id).join(', ')})\n`);
50
+ return 2;
51
+ }
52
+ }
53
+ // Box so retry telemetry (emitted before the session exists) picks up the
54
+ // real sessionId once create/resume assigns it below.
55
+ const sessionIdForRetry = { id: 'ephemeral' };
56
+ const onRetryEmit = (info) => {
57
+ globalBus.emit({
58
+ type: 'provider.retry',
59
+ ts: Date.now(),
60
+ sessionId: sessionIdForRetry.id,
61
+ attempt: info.attempt,
62
+ status: info.status,
63
+ ...(info.retryAfterMs !== undefined ? { retryAfterMs: info.retryAfterMs } : {}),
64
+ });
65
+ };
35
66
  let adapter = opts.adapter;
36
67
  if (!adapter) {
37
68
  const provider = opts.provider ?? 'openai';
@@ -42,19 +73,19 @@ export async function runOnce(opts) {
42
73
  return 2;
43
74
  }
44
75
  if (provider === 'anthropic') {
45
- adapter = anthropicAdapter({
76
+ adapter = retryingAdapter(anthropicAdapter({
46
77
  baseURL: baseUrl,
47
78
  apiKey,
48
79
  timeoutMs: opts.timeoutMs ?? 60_000,
49
80
  authHeader: opts.authHeader,
50
- });
81
+ }), { onRetry: onRetryEmit });
51
82
  }
52
83
  else {
53
84
  if (!baseUrl) {
54
85
  stderr.write('klyro: KLYRO_BASE_URL is not set (or pass --base-url)\n');
55
86
  return 2;
56
87
  }
57
- adapter = httpChatAdapter({ baseURL: baseUrl, apiKey, timeoutMs: opts.timeoutMs ?? 60_000 });
88
+ adapter = retryingAdapter(httpChatAdapter({ baseURL: baseUrl, apiKey, timeoutMs: opts.timeoutMs ?? 60_000 }), { onRetry: onRetryEmit });
58
89
  }
59
90
  }
60
91
  const registry = builtinRegistry();
@@ -67,6 +98,37 @@ export async function runOnce(opts) {
67
98
  catch {
68
99
  /* ignore — engine defaults stand */
69
100
  }
101
+ // Best-effort MCP tools: never fatal, never prompts (headless cannot
102
+ // approve). Project-sourced servers auto-connect ONLY when their exact
103
+ // spec hash is already in the McpTrust store (e.g. approved in a prior
104
+ // REPL session); unknown specs are skipped with a warning.
105
+ let closeMcp;
106
+ try {
107
+ const { loadAndRegisterMcp } = await import('../mcp/registry.js');
108
+ const { McpTrust, hashSpec } = await import('../mcp/trust.js');
109
+ const { loadMcpServers } = await import('../mcp/config.js');
110
+ const mcpTrust = new McpTrust();
111
+ const mcpSpecs = loadMcpServers(opts.cwd).servers;
112
+ const mcp = await loadAndRegisterMcp({
113
+ cwd: opts.cwd,
114
+ registry,
115
+ policy,
116
+ approveProjectServer: async ({ name }) => {
117
+ const spec = mcpSpecs[name];
118
+ if (spec && mcpTrust.isTrusted(name, hashSpec(spec)))
119
+ return true;
120
+ stderr.write(`klyro: mcp project server "${name}" not in trust store (skipped — headless cannot prompt)\n`);
121
+ return false;
122
+ },
123
+ });
124
+ for (const e of mcp.errors)
125
+ stderr.write(`klyro: mcp ${e.server}: ${e.message}\n`);
126
+ if (mcp.registered.length > 0 && output === 'human') {
127
+ stderr.write(`klyro: mcp tools: ${mcp.registered.join(', ')}\n`);
128
+ }
129
+ closeMcp = mcp.closeAll;
130
+ }
131
+ catch { /* ignore — MCP is optional */ }
70
132
  const systemPrompt = await makeRunSystemPrompt(opts.cwd, opts.systemPrompt ?? defaultRunSystemPrompt);
71
133
  // Level 9 — session setup (create or resume)
72
134
  const persistEnabled = opts.persist !== false;
@@ -110,6 +172,9 @@ export async function runOnce(opts) {
110
172
  else if (opts.resumePath) {
111
173
  initialTranscript = loadTranscript(opts.resumePath);
112
174
  }
175
+ // Publish the real sessionId to retry telemetry (stays 'ephemeral' when
176
+ // persistence is disabled or the session was never created).
177
+ sessionIdForRetry.id = sessionId ?? 'ephemeral';
113
178
  const ac = new AbortController();
114
179
  const onSigint = () => {
115
180
  stderr.write('\nklyro: SIGINT — aborting\n');
@@ -129,8 +194,52 @@ export async function runOnce(opts) {
129
194
  maxRepairAttempts: opts.maxRepairAttempts ?? 3,
130
195
  timeoutMs: opts.verifyTimeoutMs,
131
196
  requireVerify: opts.requireVerify,
197
+ ...(opts.verifyMode ? { mode: opts.verifyMode } : {}),
132
198
  };
133
199
  let result;
200
+ // P1.4 — if --agent is requested, stand up a parent orchestrator so the
201
+ // model can call spawn_agent / task_list / task_get. The root run keeps
202
+ // depth 0; children are capped at maxDepth (default 1 per r-6-10.fix.md).
203
+ let agentBridge;
204
+ let parentContext;
205
+ if (opts.agent) {
206
+ const { AgentOrchestrator, BUILTIN_AGENTS } = await import('../agent/orchestrator.js');
207
+ const def = BUILTIN_AGENTS.find((a) => a.id === opts.agent); // validated above
208
+ const maxDepth = opts.maxDepth ?? 1;
209
+ const rootDeps = { adapter, registry, policy, approval: new DenyAllApprovalPrompt(), systemPrompt };
210
+ const orchestrator = new AgentOrchestrator({ sessionId: sessionId ?? 'ephemeral', deps: rootDeps });
211
+ const allowedTools = new Set(registry.list().map((t) => t.name));
212
+ parentContext = {
213
+ sessionId: sessionId ?? 'ephemeral',
214
+ depth: 0,
215
+ maxDepth,
216
+ allowedTools,
217
+ model: def.model ?? opts.model,
218
+ };
219
+ agentBridge = orchestrator.bridgeFor({
220
+ sessionId: sessionId ?? 'ephemeral',
221
+ cwd: opts.cwd,
222
+ depth: 0,
223
+ maxDepth,
224
+ allowedTools,
225
+ model: def.model ?? opts.model,
226
+ });
227
+ }
228
+ else if (opts.maxDepth !== undefined) {
229
+ const allowedTools = new Set(registry.list().map((t) => t.name));
230
+ const { AgentOrchestrator } = await import('../agent/orchestrator.js');
231
+ const rootDeps = { adapter, registry, policy, approval: new DenyAllApprovalPrompt(), systemPrompt };
232
+ const orchestrator = new AgentOrchestrator({ sessionId: sessionId ?? 'ephemeral', deps: rootDeps });
233
+ parentContext = { sessionId: sessionId ?? 'ephemeral', depth: 0, maxDepth: opts.maxDepth, allowedTools, model: opts.model };
234
+ agentBridge = orchestrator.bridgeFor({
235
+ sessionId: sessionId ?? 'ephemeral',
236
+ cwd: opts.cwd,
237
+ depth: 0,
238
+ maxDepth: opts.maxDepth,
239
+ allowedTools,
240
+ model: opts.model,
241
+ });
242
+ }
134
243
  try {
135
244
  result = await run({
136
245
  task: opts.task,
@@ -144,6 +253,8 @@ export async function runOnce(opts) {
144
253
  initialTranscript,
145
254
  verify: verifyOpts,
146
255
  persist: store && sessionId ? { store, sessionId } : undefined,
256
+ ...(agentBridge ? { agentBridge } : {}),
257
+ ...(parentContext ? { parentContext } : {}),
147
258
  onEvent: (ev) => {
148
259
  if (output === 'json') {
149
260
  stdout.write(JSON.stringify(ev) + '\n');
@@ -192,12 +303,17 @@ export async function runOnce(opts) {
192
303
  }
193
304
  finally {
194
305
  doneSigint();
306
+ try {
307
+ await closeMcp?.();
308
+ }
309
+ catch { /* ignore */ }
195
310
  }
196
311
  if (store && sessionId) {
197
312
  // Finalize session status
198
313
  const statusMap = {
199
314
  complete: 'complete',
200
315
  max_steps: 'max_steps',
316
+ limit: 'max_steps',
201
317
  aborted: 'aborted',
202
318
  no_final: 'aborted',
203
319
  verify_failed: 'verify_failed',
@@ -221,7 +337,21 @@ export async function runOnce(opts) {
221
337
  stdout.write(JSON.stringify({ kind: 'final', status: result.status, steps: result.steps }) + '\n');
222
338
  else
223
339
  stderr.write(`klyro: hit max steps (${result.steps}); consider raising --max-steps\n`);
224
- return 3;
340
+ return 7;
341
+ }
342
+ if (result.status === 'limit') {
343
+ if (output === 'json')
344
+ stdout.write(JSON.stringify({ kind: 'final', status: result.status, steps: result.steps, text: result.finalText }) + '\n');
345
+ else
346
+ stderr.write(`klyro: stopped early: ${result.finalText || result.status} (after ${result.steps} steps)\n`);
347
+ return 7;
348
+ }
349
+ if (result.status === 'stuck') {
350
+ if (output === 'json')
351
+ stdout.write(JSON.stringify({ kind: 'final', status: result.status, steps: result.steps }) + '\n');
352
+ else
353
+ stderr.write(`klyro: stuck — repeated the same action with no progress; aborting after ${result.steps} steps\n`);
354
+ return 7;
225
355
  }
226
356
  if (result.status === 'aborted') {
227
357
  return 130;
@@ -229,14 +359,14 @@ export async function runOnce(opts) {
229
359
  if (result.status === 'no_final') {
230
360
  if (output !== 'json')
231
361
  stderr.write('klyro: provider error — no final answer\n');
232
- return 4;
362
+ return 5;
233
363
  }
234
364
  if (result.status === 'verify_failed') {
235
365
  if (output === 'json')
236
366
  stdout.write(JSON.stringify({ kind: 'final', status: 'verify_failed', failureType: result.verification?.failureType }) + '\n');
237
367
  else
238
368
  stderr.write(`klyro: verification failed after ${result.verification?.attempts ?? 3} repairs — see output above\n`);
239
- return 5;
369
+ return 8;
240
370
  }
241
371
  // 6.5 — --require-verify: if edits were made but verification never passed, exit 8
242
372
  if (opts.requireVerify && result.verification && !result.verification.ok) {
@@ -262,7 +392,9 @@ async function dryRunReport(opts) {
262
392
  // Assemble the REAL prompt (Level-6 context + KLYRO.md), not the bare base —
263
393
  // otherwise dry-run shows a different prompt than production runs use.
264
394
  const systemPromptFn = await makeRunSystemPrompt(opts.cwd, opts.systemPrompt ?? defaultRunSystemPrompt);
265
- const systemPrompt = systemPromptFn({ cwd: opts.cwd });
395
+ const { resolveSystemPrompt } = await import('../agent/runtime.js');
396
+ const { system, suffix } = resolveSystemPrompt(systemPromptFn, { cwd: opts.cwd });
397
+ const systemPrompt = suffix ? `${system}\n\n${suffix}` : system;
266
398
  const registry = builtinRegistry();
267
399
  const rules = builtinRules();
268
400
  const report = {
@@ -287,23 +419,53 @@ function defaultRunSystemPrompt(_ctx) {
287
419
  'Solve it by calling tools as needed. When done, produce a short final text answer.',
288
420
  'Do not invent file paths. Do not call tools outside the working directory.',
289
421
  ].join(' ');
290
- return _ctx.telemetry ? base + '\n\n' + _ctx.telemetry : base;
422
+ return _ctx.telemetry ? { system: base, suffix: _ctx.telemetry } : { system: base };
291
423
  }
292
424
  /** Wrap a system-prompt fn to inject Level-6 context (project map etc.) + KLYRO.md (4.4). */
293
425
  export async function makeRunSystemPrompt(cwd, base) {
294
426
  const ctxBlock = await buildLevel6Context({ cwd });
295
427
  const prefix = ctxBlock.formatted ? `\n\n<context>\n${ctxBlock.formatted}\n</context>` : '';
428
+ // Session memory: .klyro/memory/session-notes.md injected so memory_write
429
+ // actually takes effect (redacted at write time — see context/memory.ts).
430
+ const memBlock = memoryBlock(cwd);
296
431
  let klyroBlock = '';
297
432
  try {
298
- const { loadKlyroMd } = await import('../context/klyro-md.js');
299
- const md = await loadKlyroMd(cwd);
300
- if (md)
301
- klyroBlock = `\n\n<KLYRO.md>\n${md.slice(0, 4000)}\n</KLYRO.md>`;
433
+ // P0.3 trust gate with headless approve-if-store-known: approve(file) is
434
+ // trust.isTrusted(file), so REPL-persisted approvals are honored with no
435
+ // prompting; unknown/changed files stay excluded and each exclusion is
436
+ // bus-visible.
437
+ const { loadKlyroMdFiles } = await import('../context/klyro-md.js');
438
+ const { ContextTrust } = await import('../context/trust.js');
439
+ const { globalBus } = await import('../events/bus.js');
440
+ const trust = new ContextTrust();
441
+ // Headless approve-if-store-known (no prompts): approve(file) is
442
+ // trust.isTrusted(file), so REPL-persisted approvals are honored;
443
+ // unknown/changed files stay excluded.
444
+ const approve = (file) => trust.isTrusted(file);
445
+ const files = await loadKlyroMdFiles(cwd);
446
+ const { trusted, untrusted } = trust.check(files);
447
+ for (const { file, reason } of untrusted) {
448
+ globalBus.emit({ type: 'context.trust_prompt', ts: Date.now(), sessionId: 'ephemeral', path: file.path, reason, trusted: false });
449
+ }
450
+ if (untrusted.length > 0) {
451
+ stderr.write(`klyro: trust gate excluded ${untrusted.length} unapproved context file(s): ${untrusted.map((u) => u.file.path).join(', ')}\n`);
452
+ }
453
+ const keptFiles = trusted.filter(approve);
454
+ const kept = keptFiles.map((f) => `# ${f.path}\n${f.content}`).join('\n\n---\n\n');
455
+ if (kept)
456
+ klyroBlock = `\n\n<KLYRO.md>\n${kept.slice(0, 4000)}\n</KLYRO.md>`;
302
457
  }
303
458
  catch { /* ignore */ }
304
459
  return (ctx) => {
305
- const t = ctx.telemetry ? '\n\n' + ctx.telemetry : '';
306
- return base(ctx) + prefix + klyroBlock + t;
460
+ const r = base(ctx);
461
+ if (typeof r === 'string') {
462
+ // Legacy string fn: keep the exact legacy concatenation behavior.
463
+ const t = ctx.telemetry ? '\n\n' + ctx.telemetry : '';
464
+ return r + prefix + klyroBlock + memBlock + t;
465
+ }
466
+ // Split shape: Level-6/KLYRO.md join the stable prefix; telemetry stays
467
+ // the volatile suffix for cache-friendly adapters.
468
+ return { system: r.system + prefix + klyroBlock + memBlock, ...(r.suffix !== undefined ? { suffix: r.suffix } : {}) };
307
469
  };
308
470
  }
309
471
  export function loadTranscript(path) {
@@ -1,6 +1,11 @@
1
1
  /**
2
2
  * klyro update — check registry for newer version, cached 24h.
3
3
  * Env KLYRO_NO_UPDATE_CHECK=1 disables.
4
+ *
5
+ * Integrity: before recommending `npm i`, we verify the tarball's SRI hash
6
+ * (sha512) against the registry's recorded `dist.integrity`. An install is
7
+ * only recommended when the download hash matches, so a tampered CDN or
8
+ * MITM registry response can't push a malicious binary to the operator.
4
9
  */
5
10
  export declare function checkForUpdate(current: string): Promise<string | null>;
6
11
  export declare function runUpdate(): Promise<number>;
@@ -1,15 +1,65 @@
1
1
  /**
2
2
  * klyro update — check registry for newer version, cached 24h.
3
3
  * Env KLYRO_NO_UPDATE_CHECK=1 disables.
4
+ *
5
+ * Integrity: before recommending `npm i`, we verify the tarball's SRI hash
6
+ * (sha512) against the registry's recorded `dist.integrity`. An install is
7
+ * only recommended when the download hash matches, so a tampered CDN or
8
+ * MITM registry response can't push a malicious binary to the operator.
4
9
  */
5
10
  import * as fs from 'node:fs/promises';
6
11
  import * as path from 'node:path';
7
12
  import * as os from 'node:os';
13
+ import { createHash } from 'node:crypto';
8
14
  const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
15
+ const REGISTRY_BASE = 'https://registry.npmjs.org/klyro';
16
+ const FETCH_TIMEOUT_MS = 3000;
17
+ const TARBALL_TIMEOUT_MS = 10000;
9
18
  function cachePath() {
19
+ // KLYRO_UPDATE_CACHE lets tests isolate the on-disk cache (and power users
20
+ // relocate it); default is the home cache like the rest of Klyro.
21
+ const override = process.env.KLYRO_UPDATE_CACHE;
22
+ if (override)
23
+ return override;
10
24
  const home = os.homedir() || process.cwd();
11
25
  return path.join(home, '.klyro', 'update-cache.json');
12
26
  }
27
+ /** SRI string may carry multiple hashes parsable with `pick`; we accept sha512 or sha256. */
28
+ function parseSRI(integrity) {
29
+ if (!integrity)
30
+ return null;
31
+ for (const part of integrity.split(/\s+/)) {
32
+ const m = /^(sha256|sha512)-([A-Za-z0-9+/=]+)$/.exec(part);
33
+ if (m)
34
+ return { algo: m[1], digest: m[2] };
35
+ }
36
+ return null;
37
+ }
38
+ /** Fetch with a timeout; rejects on non-2xx. */
39
+ async function fetchWithTimeout(url, timeoutMs = FETCH_TIMEOUT_MS) {
40
+ const ctrl = new AbortController();
41
+ const t = setTimeout(() => ctrl.abort(), timeoutMs);
42
+ try {
43
+ const res = await fetch(url, { signal: ctrl.signal });
44
+ if (!res.ok)
45
+ throw new Error(`HTTP ${res.status}`);
46
+ return res;
47
+ }
48
+ finally {
49
+ clearTimeout(t);
50
+ }
51
+ }
52
+ /** Download the tarball and confirm its hash equals the registry's SRI digest. */
53
+ async function verifyTarballIntegrity(dist) {
54
+ const sri = parseSRI(dist.integrity);
55
+ const tarball = dist.tarball;
56
+ if (!sri || !tarball)
57
+ return false;
58
+ const res = await fetchWithTimeout(tarball, TARBALL_TIMEOUT_MS);
59
+ const buf = Buffer.from(await res.arrayBuffer());
60
+ const actual = createHash(sri.algo).update(buf).digest('base64');
61
+ return actual === sri.digest;
62
+ }
13
63
  export async function checkForUpdate(current) {
14
64
  if (process.env.KLYRO_NO_UPDATE_CHECK === '1')
15
65
  return null;
@@ -25,18 +75,20 @@ export async function checkForUpdate(current) {
25
75
  // ignore
26
76
  }
27
77
  try {
28
- const ctrl = new AbortController();
29
- const t = setTimeout(() => ctrl.abort(), 3000);
30
- const res = await fetch('https://registry.npmjs.org/klyro/latest', { signal: ctrl.signal });
31
- clearTimeout(t);
32
- if (!res.ok)
33
- return null;
78
+ const res = await fetchWithTimeout(`${REGISTRY_BASE}/latest`);
34
79
  const json = (await res.json());
35
80
  const latest = json.version ?? '';
36
- await fs.mkdir(path.dirname(cache), { recursive: true });
37
- await fs.writeFile(cache, JSON.stringify({ at: Date.now(), latest }), 'utf-8');
38
- if (latest && latest !== current)
81
+ if (latest && latest !== current) {
82
+ // Verify the tarball's integrity before caching/recommending this version.
83
+ const verRes = await fetchWithTimeout(`${REGISTRY_BASE}/${encodeURIComponent(latest)}`);
84
+ const verJson = (await verRes.json());
85
+ const ok = await verifyTarballIntegrity(verJson.dist ?? {});
86
+ if (!ok)
87
+ return null; // integrity mismatch — treat as no update, never recommend it
88
+ await fs.mkdir(path.dirname(cache), { recursive: true });
89
+ await fs.writeFile(cache, JSON.stringify({ at: Date.now(), latest }), 'utf-8');
39
90
  return latest;
91
+ }
40
92
  }
41
93
  catch {
42
94
  // network failure — silent
@@ -55,7 +107,7 @@ export async function runUpdate() {
55
107
  const cur = pkg.version ?? '0.0.0';
56
108
  const latest = await checkForUpdate(cur);
57
109
  if (latest) {
58
- process.stdout.write(`Update available: ${cur} → ${latest}\n npm i -g klyro@latest\n`);
110
+ process.stdout.write(`Update available: ${cur} → ${latest} (integrity verified)\n npm i -g klyro@latest\n`);
59
111
  }
60
112
  else {
61
113
  process.stdout.write(`klyro ${cur} is latest\n`);
@@ -3,6 +3,8 @@ export interface ImportGraph {
3
3
  edges: Map<string, Set<string>>;
4
4
  mtime: number;
5
5
  }
6
+ /** Invalidate the import-graph cache (tests + callers that mutate the tree). */
7
+ export declare function clearImportGraphCache(): void;
6
8
  export declare function buildImportGraph(cwd: string): Promise<ImportGraph>;
7
9
  export declare function importsOf(cwd: string, file: string): Promise<string[]>;
8
10
  export declare function importersOf(cwd: string, file: string): Promise<string[]>;
@@ -1,6 +1,10 @@
1
1
  /**
2
2
  * 7.3 — Import graph (cached) — powers L6 scoped tests + imports_of / importers_of
3
3
  * Parses TS/JS/Py/Go imports via regex, builds adjacency, caches by mtime.
4
+ *
5
+ * Freshness: the cached graph stores per-file {mtimeMs, size} alongside it.
6
+ * On a cache hit every file in the graph is re-statted (stat-only, bounded
7
+ * to files already in the graph) — any mtime/size mismatch rebuilds.
4
8
  */
5
9
  import * as fs from 'node:fs/promises';
6
10
  import * as path from 'node:path';
@@ -19,10 +23,32 @@ async function parseImports(file, content) {
19
23
  }
20
24
  return out;
21
25
  }
26
+ /** Re-stat every file in the cached graph; true when all stats still match. */
27
+ async function statsStillMatch(cwd, stats) {
28
+ for (const [rel, prev] of stats) {
29
+ try {
30
+ const s = await fs.stat(path.join(cwd, rel));
31
+ if (s.mtimeMs !== prev.mtimeMs || s.size !== prev.size)
32
+ return false;
33
+ }
34
+ catch {
35
+ return false; // deleted (or unreadable) — rebuild
36
+ }
37
+ }
38
+ return true;
39
+ }
40
+ /** Invalidate the import-graph cache (tests + callers that mutate the tree). */
41
+ export function clearImportGraphCache() {
42
+ cache = null;
43
+ }
22
44
  export async function buildImportGraph(cwd) {
23
- if (cache && cache.cwd === cwd && Date.now() - cache.graph.mtime < 60_000)
24
- return cache.graph;
45
+ if (cache && cache.cwd === cwd && Date.now() - cache.graph.mtime < 60_000) {
46
+ if (await statsStillMatch(cwd, cache.stats))
47
+ return cache.graph;
48
+ cache = null;
49
+ }
25
50
  const graph = { nodes: new Set(), edges: new Map(), mtime: Date.now() };
51
+ const stats = new Map();
26
52
  async function walk(dir, depth = 0) {
27
53
  if (depth > 6)
28
54
  return;
@@ -43,6 +69,8 @@ export async function buildImportGraph(cwd) {
43
69
  const rel = path.relative(cwd, full).replace(/\\/g, '/');
44
70
  graph.nodes.add(rel);
45
71
  try {
72
+ const st = await fs.stat(full);
73
+ stats.set(rel, { mtimeMs: st.mtimeMs, size: st.size });
46
74
  const txt = await fs.readFile(full, 'utf-8');
47
75
  const imps = await parseImports(rel, txt);
48
76
  for (const imp of imps) {
@@ -57,7 +85,7 @@ export async function buildImportGraph(cwd) {
57
85
  }
58
86
  }
59
87
  await walk(cwd);
60
- cache = { cwd, graph };
88
+ cache = { cwd, graph, stats };
61
89
  return graph;
62
90
  }
63
91
  export async function importsOf(cwd, file) {
@@ -6,5 +6,11 @@
6
6
  * secret files into context), each file is capped, and total output is
7
7
  * capped so a giant monorepo doc can't blow the context budget.
8
8
  */
9
+ export interface KlyroMdFile {
10
+ path: string;
11
+ content: string;
12
+ }
13
+ /** Per-file loader (used by the trust gate to approve/hash individual files). */
14
+ export declare function loadKlyroMdFiles(cwd: string): Promise<KlyroMdFile[]>;
9
15
  export declare function loadKlyroMd(cwd: string): Promise<string>;
10
16
  export declare function handleInit(cwd: string): Promise<string>;
@@ -14,37 +14,46 @@ const MAX_TOTAL_CHARS = 8000;
14
14
  function cap(s, n) {
15
15
  return s.length > n ? s.slice(0, n) + `\n... [truncated ${s.length - n} chars]` : s;
16
16
  }
17
- export async function loadKlyroMd(cwd) {
18
- const parts = [];
19
- let total = 0;
20
- const push = (s) => {
21
- if (total >= MAX_TOTAL_CHARS)
22
- return;
23
- const room = MAX_TOTAL_CHARS - total;
24
- const chunk = s.length > room ? s.slice(0, room) + '\n... [truncated]' : s;
25
- parts.push(chunk);
26
- total += chunk.length;
27
- };
28
- // Global
17
+ /** Per-file loader (used by the trust gate to approve/hash individual files). */
18
+ export async function loadKlyroMdFiles(cwd) {
19
+ const files = [];
29
20
  const home = os.homedir();
30
21
  if (home) {
31
22
  for (const p of [path.join(home, '.klyro', 'KLYRO.md'), path.join(home, '.klyro', 'KLYRO.local.md')]) {
32
23
  try {
33
24
  const t = await fs.readFile(p, 'utf-8');
34
- push(`# ${p}\n${cap(t, MAX_FILE_CHARS)}`);
25
+ files.push({ path: p, content: cap(t, MAX_FILE_CHARS) });
35
26
  }
36
27
  catch { /* ignore */ }
37
28
  }
38
29
  }
39
- // Root (imports resolved relative to each file, contained to cwd)
40
- for (const name of ['KLYRO.md', 'KLYRO.local.md', 'AGENTS.md', '.cursorrules']) {
30
+ // Klyro's own instruction file is KLYRO.md. The rest are read-only
31
+ // fallbacks for imported repos that follow other conventions — Klyro
32
+ // never writes them. KLYRO.md wins by load order.
33
+ for (const name of ['KLYRO.md', 'KLYRO.local.md', 'CLAUDE.md', 'CLAUDE.local.md', 'AGENTS.md', '.cursorrules']) {
41
34
  const p = path.join(cwd, name);
42
35
  try {
43
36
  const t = await fs.readFile(p, 'utf-8');
44
- push(`# ${p}\n${cap(await resolveImports(t, path.dirname(p), cwd), MAX_FILE_CHARS)}`);
37
+ files.push({ path: p, content: cap(await resolveImports(t, path.dirname(p), cwd), MAX_FILE_CHARS) });
45
38
  }
46
39
  catch { /* ignore */ }
47
40
  }
41
+ return files;
42
+ }
43
+ export async function loadKlyroMd(cwd) {
44
+ const parts = [];
45
+ let total = 0;
46
+ const push = (s) => {
47
+ if (total >= MAX_TOTAL_CHARS)
48
+ return;
49
+ const room = MAX_TOTAL_CHARS - total;
50
+ const chunk = s.length > room ? s.slice(0, room) + '\n... [truncated]' : s;
51
+ parts.push(chunk);
52
+ total += chunk.length;
53
+ };
54
+ for (const f of await loadKlyroMdFiles(cwd)) {
55
+ push(`# ${f.path}\n${f.content}`);
56
+ }
48
57
  return parts.join('\n\n---\n\n');
49
58
  }
50
59
  async function resolveImports(text, base, root, depth = 0) {
@@ -1,5 +1,13 @@
1
1
  import type { PlanStep } from '../agent/runtime.js';
2
+ /** Single-write ceiling — anything larger is rejected, not sliced. */
3
+ export declare const MEMORY_WRITE_LIMIT_CHARS = 8000;
4
+ /** Steady-state cap (~1k tokens ≈ 4k chars) kept via tail slice. */
5
+ export declare const MEMORY_STEADY_STATE_CHARS = 4000;
2
6
  export declare function memoryWrite(cwd: string, content: string): Promise<string>;
3
7
  export declare function loadMemory(cwd: string): Promise<string>;
8
+ /** Synchronous read for the system-prompt build path (run on every turn). */
9
+ export declare function loadMemorySync(cwd: string): string;
10
+ /** Wrap persisted notes into the injected prompt block; '' when empty. */
11
+ export declare function memoryBlock(cwd: string): string;
4
12
  export declare function shouldRemind(turn: number, lastRemindTurn: number): boolean;
5
13
  export declare function reminderForTodos(todos: PlanStep[]): string | undefined;