klyro 1.0.0 → 1.0.2

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 (110) hide show
  1. package/dist/agent/anthropic-adapter.d.ts +49 -5
  2. package/dist/agent/anthropic-adapter.js +86 -17
  3. package/dist/agent/capabilities.d.ts +23 -0
  4. package/dist/agent/capabilities.js +53 -6
  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 +124 -6
  8. package/dist/agent/orchestrator.js +425 -58
  9. package/dist/agent/provider-adapter.d.ts +8 -0
  10. package/dist/agent/provider-adapter.js +12 -3
  11. package/dist/agent/retry.d.ts +1 -1
  12. package/dist/agent/retry.js +54 -12
  13. package/dist/agent/runtime.d.ts +84 -8
  14. package/dist/agent/runtime.js +352 -39
  15. package/dist/agent/stream-budget.d.ts +36 -0
  16. package/dist/agent/stream-budget.js +121 -0
  17. package/dist/agent/worktree-manager.d.ts +74 -0
  18. package/dist/agent/worktree-manager.js +189 -0
  19. package/dist/checkpoints/store.d.ts +9 -0
  20. package/dist/checkpoints/store.js +56 -5
  21. package/dist/cli/auth.js +16 -1
  22. package/dist/cli/commit.d.ts +31 -0
  23. package/dist/cli/commit.js +142 -0
  24. package/dist/cli/config.d.ts +54 -3
  25. package/dist/cli/config.js +146 -3
  26. package/dist/cli/doctor.d.ts +1 -0
  27. package/dist/cli/doctor.js +71 -6
  28. package/dist/cli/eval.d.ts +6 -1
  29. package/dist/cli/eval.js +9 -0
  30. package/dist/cli/hooks.d.ts +47 -0
  31. package/dist/cli/hooks.js +181 -0
  32. package/dist/cli/repl.js +196 -29
  33. package/dist/cli/run.d.ts +13 -11
  34. package/dist/cli/run.js +144 -20
  35. package/dist/cli/update.d.ts +5 -0
  36. package/dist/cli/update.js +62 -10
  37. package/dist/context/import-graph.d.ts +2 -0
  38. package/dist/context/import-graph.js +31 -3
  39. package/dist/context/klyro-md.js +4 -1
  40. package/dist/context/memory.d.ts +8 -0
  41. package/dist/context/memory.js +50 -2
  42. package/dist/context/project-map.d.ts +6 -0
  43. package/dist/context/project-map.js +50 -2
  44. package/dist/context/repo-map.d.ts +2 -0
  45. package/dist/context/repo-map.js +31 -1
  46. package/dist/events/catalog.d.ts +37 -0
  47. package/dist/events/catalog.js +9 -0
  48. package/dist/index.js +177 -8
  49. package/dist/mcp/client.d.ts +6 -4
  50. package/dist/mcp/client.js +83 -14
  51. package/dist/mcp/config.d.ts +10 -0
  52. package/dist/mcp/config.js +18 -1
  53. package/dist/mcp/registry.d.ts +23 -19
  54. package/dist/mcp/registry.js +127 -8
  55. package/dist/mcp/schema.d.ts +11 -4
  56. package/dist/mcp/schema.js +27 -16
  57. package/dist/mcp/trust.d.ts +20 -0
  58. package/dist/mcp/trust.js +74 -0
  59. package/dist/persistence/audit.d.ts +28 -0
  60. package/dist/persistence/audit.js +101 -1
  61. package/dist/persistence/store.d.ts +26 -2
  62. package/dist/persistence/store.js +140 -13
  63. package/dist/policy/approval.d.ts +14 -0
  64. package/dist/policy/approval.js +44 -2
  65. package/dist/policy/engine.d.ts +17 -0
  66. package/dist/policy/engine.js +162 -9
  67. package/dist/policy/path-guard.d.ts +24 -0
  68. package/dist/policy/path-guard.js +46 -0
  69. package/dist/policy/secret-redactor.js +4 -0
  70. package/dist/providers/model-info.d.ts +23 -0
  71. package/dist/providers/model-info.js +43 -2
  72. package/dist/repl.d.ts +6 -0
  73. package/dist/repl.js +12 -7
  74. package/dist/tools/agent/spawn-agent.js +5 -5
  75. package/dist/tools/agent/task-apply.d.ts +4 -0
  76. package/dist/tools/agent/task-apply.js +44 -0
  77. package/dist/tools/agent/task-stop.d.ts +6 -0
  78. package/dist/tools/agent/task-stop.js +39 -0
  79. package/dist/tools/agent/task-wait.d.ts +17 -0
  80. package/dist/tools/agent/task-wait.js +79 -0
  81. package/dist/tools/fs/apply-patch.js +77 -1
  82. package/dist/tools/fs/edit-file.js +69 -1
  83. package/dist/tools/fs/multi-edit.d.ts +4 -0
  84. package/dist/tools/fs/multi-edit.js +70 -1
  85. package/dist/tools/fs/write-file.js +83 -6
  86. package/dist/tools/plan/todo-write.js +1 -1
  87. package/dist/tools/registry.js +6 -0
  88. package/dist/tools/shell/background.js +6 -3
  89. package/dist/tools/shell/sandbox.d.ts +51 -0
  90. package/dist/tools/shell/sandbox.js +143 -0
  91. package/dist/tools/shell/shell-exec.d.ts +29 -0
  92. package/dist/tools/shell/shell-exec.js +170 -12
  93. package/dist/tools/shell/worker-entry.d.ts +12 -0
  94. package/dist/tools/shell/worker-entry.js +43 -0
  95. package/dist/tools/types.d.ts +6 -0
  96. package/dist/tools/verify/run-verify.js +3 -1
  97. package/dist/trace/writer.d.ts +20 -0
  98. package/dist/trace/writer.js +62 -4
  99. package/dist/tui/app.js +1 -1
  100. package/dist/tui/approval.js +20 -21
  101. package/dist/util.d.ts +1 -0
  102. package/dist/util.js +1 -0
  103. package/dist/verification/baseline.js +17 -3
  104. package/dist/verification/classify.js +27 -15
  105. package/dist/verification/engine.d.ts +8 -0
  106. package/dist/verification/engine.js +28 -1
  107. package/dist/verification/registry.d.ts +2 -0
  108. package/dist/verification/registry.js +44 -0
  109. package/dist/verification/scoped.js +64 -11
  110. package/package.json +1 -1
package/dist/cli/run.js CHANGED
@@ -16,13 +16,23 @@ import { run } from '../agent/runtime.js';
16
16
  import { builtinRegistry } from '../tools/registry.js';
17
17
  import { builtinRules, clonePolicyConfig, PolicyEngine } from '../policy/engine.js';
18
18
  import { DenyAllApprovalPrompt } from '../policy/approval.js';
19
+ import { redact } from '../policy/secret-redactor.js';
19
20
  import { buildLevel6Context } from '../context/level6.js';
21
+ import { memoryBlock } from '../context/memory.js';
20
22
  import { resolveSessionId } from '../persistence/session.js';
21
23
  import * as fs from 'node:fs';
22
24
  function readEnv(name, fallback) {
23
25
  const v = process.env[name];
24
26
  return v && v.length > 0 ? v : fallback;
25
27
  }
28
+ /**
29
+ * Double-Ctrl+C detector (pure, exported for tests): the second SIGINT
30
+ * within 1500ms of the first forces `process.exit(130)`. The live handler
31
+ * below owns the timestamp closure; tests exercise only this predicate.
32
+ */
33
+ export function shouldForceExit(lastSigintAt, now) {
34
+ return lastSigintAt !== undefined && now - lastSigintAt < 1500;
35
+ }
26
36
  export async function runOnce(opts) {
27
37
  // P0.5 — load <cwd>/.env first so KLYRO_* vars resolve without `export`.
28
38
  // Never throws (missing file is a no-op); explicit env wins (no-clobber).
@@ -87,6 +97,36 @@ export async function runOnce(opts) {
87
97
  adapter = retryingAdapter(httpChatAdapter({ baseURL: baseUrl, apiKey, timeoutMs: opts.timeoutMs ?? 60_000 }), { onRetry: onRetryEmit });
88
98
  }
89
99
  }
100
+ // L15 provider failover: extra chain entries (after the primary) become
101
+ // fallback adapters for the runtime. Custom injected adapters (tests)
102
+ // skip chain wiring. Failures resolving the chain never block the run.
103
+ let failoverAdapters;
104
+ if (!opts.adapter) {
105
+ try {
106
+ const { resolveProviderChain } = await import('./config.js');
107
+ const chain = await resolveProviderChain(opts.cwd);
108
+ const fallbacks = chain.slice(1);
109
+ if (fallbacks.length > 0) {
110
+ const built = [];
111
+ for (const entry of fallbacks) {
112
+ if (!entry.apiKey)
113
+ continue;
114
+ const base = entry.provider === 'anthropic'
115
+ ? anthropicAdapter({ baseURL: entry.baseURL, apiKey: entry.apiKey, timeoutMs: opts.timeoutMs ?? 60_000 })
116
+ : entry.baseURL
117
+ ? httpChatAdapter({ baseURL: entry.baseURL, apiKey: entry.apiKey, timeoutMs: opts.timeoutMs ?? 60_000 })
118
+ : null;
119
+ if (base)
120
+ built.push(retryingAdapter(base, { onRetry: onRetryEmit }));
121
+ }
122
+ if (built.length > 0) {
123
+ failoverAdapters = built;
124
+ stderr.write(`klyro: failover chain: ${built.map((b) => b.id).join(' → ')}\n`);
125
+ }
126
+ }
127
+ }
128
+ catch { /* best-effort — single-provider run proceeds */ }
129
+ }
90
130
  const registry = builtinRegistry();
91
131
  const policy = new PolicyEngine(builtinRules(), clonePolicyConfig());
92
132
  // Persisted "always allow" patterns apply to one-shot runs too.
@@ -97,15 +137,29 @@ export async function runOnce(opts) {
97
137
  catch {
98
138
  /* ignore — engine defaults stand */
99
139
  }
100
- // Best-effort MCP tools: never fatal, never prompts. src/mcp/registry.ts
101
- // lands from a sibling agent — the lazy import keeps runtime + builds green
102
- // until then (import failure is caught below). @ts-ignore is used instead
103
- // of @ts-expect-error so it stays inert after the sibling file lands.
140
+ // Best-effort MCP tools: never fatal, never prompts (headless cannot
141
+ // approve). Project-sourced servers auto-connect ONLY when their exact
142
+ // spec hash is already in the McpTrust store (e.g. approved in a prior
143
+ // REPL session); unknown specs are skipped with a warning.
104
144
  let closeMcp;
105
145
  try {
106
- // @ts-ignore — sibling-owned module may not exist yet
107
146
  const { loadAndRegisterMcp } = await import('../mcp/registry.js');
108
- const mcp = await loadAndRegisterMcp({ cwd: opts.cwd, registry, policy });
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
+ });
109
163
  for (const e of mcp.errors)
110
164
  stderr.write(`klyro: mcp ${e.server}: ${e.message}\n`);
111
165
  if (mcp.registered.length > 0 && output === 'human') {
@@ -134,6 +188,16 @@ export async function runOnce(opts) {
134
188
  return 2;
135
189
  }
136
190
  sessionId = full;
191
+ // M22 resume-lock warning (read-only probe — takeover proceeds anyway).
192
+ try {
193
+ const { readSessionLock } = await import('../persistence/store.js');
194
+ const { getDefaultSessionsDir } = await import('../persistence/session.js');
195
+ const lock = readSessionLock(opts.sessionsDir ?? getDefaultSessionsDir(), sessionId);
196
+ if (lock.held && lock.alive) {
197
+ stderr.write(`klyro: session ${sessionId.slice(0, 8)} is locked by live pid ${lock.pid ?? '?'} — taking over (proceeding anyway)\n`);
198
+ }
199
+ }
200
+ catch { /* probe is best-effort; never block resume */ }
137
201
  const msgs = await store.loadMessages(sessionId);
138
202
  // Convert StoredMessage to Message
139
203
  initialTranscript = msgs.map((m) => ({ role: m.role, content: m.content }));
@@ -161,7 +225,17 @@ export async function runOnce(opts) {
161
225
  // persistence is disabled or the session was never created).
162
226
  sessionIdForRetry.id = sessionId ?? 'ephemeral';
163
227
  const ac = new AbortController();
228
+ // Double-Ctrl+C: first press aborts the run; a second press within
229
+ // 1500ms forces process.exit(130) (shouldForceExit owns the predicate).
230
+ let lastSigintAt;
164
231
  const onSigint = () => {
232
+ const now = Date.now();
233
+ if (shouldForceExit(lastSigintAt, now)) {
234
+ stderr.write('\nklyro: SIGINT twice — forcing exit\n');
235
+ process.exit(130);
236
+ return;
237
+ }
238
+ lastSigintAt = now;
165
239
  stderr.write('\nklyro: SIGINT — aborting\n');
166
240
  ac.abort();
167
241
  };
@@ -179,6 +253,7 @@ export async function runOnce(opts) {
179
253
  maxRepairAttempts: opts.maxRepairAttempts ?? 3,
180
254
  timeoutMs: opts.verifyTimeoutMs,
181
255
  requireVerify: opts.requireVerify,
256
+ ...(opts.verifyMode ? { mode: opts.verifyMode } : {}),
182
257
  };
183
258
  let result;
184
259
  // P1.4 — if --agent is requested, stand up a parent orchestrator so the
@@ -282,8 +357,20 @@ export async function runOnce(opts) {
282
357
  if (output === 'human')
283
358
  stderr.write(`[session ${ev.sessionId.slice(0, 8)} checkpoint]\n`);
284
359
  }
360
+ else if (ev.kind === 'provider_failover') {
361
+ stderr.write(`[failover] ${ev.from} → ${ev.to}: ${ev.reason.slice(0, 200)}\n`);
362
+ }
363
+ else if (ev.kind === 'budget_warning') {
364
+ stderr.write(`[budget] ${(ev.ratio * 100).toFixed(0)}% of max cost used (threshold ${(ev.threshold * 100).toFixed(0)}%)\n`);
365
+ }
366
+ else if (ev.kind === 'model_override') {
367
+ stderr.write(`[model] override: requested ${ev.requested} → effective ${ev.effective}\n`);
368
+ }
285
369
  },
286
- }, { adapter, registry, policy, approval: new DenyAllApprovalPrompt(), systemPrompt });
370
+ }, {
371
+ adapter, registry, policy, approval: new DenyAllApprovalPrompt(), systemPrompt,
372
+ ...(failoverAdapters ? { failoverAdapters } : {}),
373
+ });
287
374
  }
288
375
  finally {
289
376
  doneSigint();
@@ -297,6 +384,7 @@ export async function runOnce(opts) {
297
384
  const statusMap = {
298
385
  complete: 'complete',
299
386
  max_steps: 'max_steps',
387
+ limit: 'max_steps',
300
388
  aborted: 'aborted',
301
389
  no_final: 'aborted',
302
390
  verify_failed: 'verify_failed',
@@ -320,7 +408,21 @@ export async function runOnce(opts) {
320
408
  stdout.write(JSON.stringify({ kind: 'final', status: result.status, steps: result.steps }) + '\n');
321
409
  else
322
410
  stderr.write(`klyro: hit max steps (${result.steps}); consider raising --max-steps\n`);
323
- return 3;
411
+ return 7;
412
+ }
413
+ if (result.status === 'limit') {
414
+ if (output === 'json')
415
+ stdout.write(JSON.stringify({ kind: 'final', status: result.status, steps: result.steps, text: result.finalText }) + '\n');
416
+ else
417
+ stderr.write(`klyro: stopped early: ${result.finalText || result.status} (after ${result.steps} steps)\n`);
418
+ return 7;
419
+ }
420
+ if (result.status === 'stuck') {
421
+ if (output === 'json')
422
+ stdout.write(JSON.stringify({ kind: 'final', status: result.status, steps: result.steps }) + '\n');
423
+ else
424
+ stderr.write(`klyro: stuck — repeated the same action with no progress; aborting after ${result.steps} steps\n`);
425
+ return 7;
324
426
  }
325
427
  if (result.status === 'aborted') {
326
428
  return 130;
@@ -328,14 +430,14 @@ export async function runOnce(opts) {
328
430
  if (result.status === 'no_final') {
329
431
  if (output !== 'json')
330
432
  stderr.write('klyro: provider error — no final answer\n');
331
- return 4;
433
+ return 5;
332
434
  }
333
435
  if (result.status === 'verify_failed') {
334
436
  if (output === 'json')
335
437
  stdout.write(JSON.stringify({ kind: 'final', status: 'verify_failed', failureType: result.verification?.failureType }) + '\n');
336
438
  else
337
439
  stderr.write(`klyro: verification failed after ${result.verification?.attempts ?? 3} repairs — see output above\n`);
338
- return 5;
440
+ return 8;
339
441
  }
340
442
  // 6.5 — --require-verify: if edits were made but verification never passed, exit 8
341
443
  if (opts.requireVerify && result.verification && !result.verification.ok) {
@@ -361,7 +463,9 @@ async function dryRunReport(opts) {
361
463
  // Assemble the REAL prompt (Level-6 context + KLYRO.md), not the bare base —
362
464
  // otherwise dry-run shows a different prompt than production runs use.
363
465
  const systemPromptFn = await makeRunSystemPrompt(opts.cwd, opts.systemPrompt ?? defaultRunSystemPrompt);
364
- const systemPrompt = systemPromptFn({ cwd: opts.cwd });
466
+ const { resolveSystemPrompt } = await import('../agent/runtime.js');
467
+ const { system, suffix } = resolveSystemPrompt(systemPromptFn, { cwd: opts.cwd });
468
+ const systemPrompt = suffix ? `${system}\n\n${suffix}` : system;
365
469
  const registry = builtinRegistry();
366
470
  const rules = builtinRules();
367
471
  const report = {
@@ -371,8 +475,10 @@ async function dryRunReport(opts) {
371
475
  maxSteps: opts.maxSteps,
372
476
  maxTokens: opts.maxTokens,
373
477
  temperature: opts.temperature,
374
- systemPrompt,
375
- task: opts.task,
478
+ // Secrets must never leak into a printable report: redact both the
479
+ // assembled system prompt and the task before printing.
480
+ systemPrompt: redact(systemPrompt),
481
+ task: redact(opts.task),
376
482
  toolCount: registry.list().length,
377
483
  toolNames: registry.list().map((t) => t.name),
378
484
  policyRules: rules.map((r) => r.name),
@@ -386,35 +492,53 @@ function defaultRunSystemPrompt(_ctx) {
386
492
  'Solve it by calling tools as needed. When done, produce a short final text answer.',
387
493
  'Do not invent file paths. Do not call tools outside the working directory.',
388
494
  ].join(' ');
389
- return _ctx.telemetry ? base + '\n\n' + _ctx.telemetry : base;
495
+ return _ctx.telemetry ? { system: base, suffix: _ctx.telemetry } : { system: base };
390
496
  }
391
497
  /** Wrap a system-prompt fn to inject Level-6 context (project map etc.) + KLYRO.md (4.4). */
392
498
  export async function makeRunSystemPrompt(cwd, base) {
393
499
  const ctxBlock = await buildLevel6Context({ cwd });
394
500
  const prefix = ctxBlock.formatted ? `\n\n<context>\n${ctxBlock.formatted}\n</context>` : '';
501
+ // Session memory: .klyro/memory/session-notes.md injected so memory_write
502
+ // actually takes effect (redacted at write time — see context/memory.ts).
503
+ const memBlock = memoryBlock(cwd);
395
504
  let klyroBlock = '';
396
505
  try {
397
- // P0.3 trust gate: unapproved context files are excluded in headless
398
- // mode (secure default-deny) and each exclusion is bus-visible.
506
+ // P0.3 trust gate with headless approve-if-store-known: approve(file) is
507
+ // trust.isTrusted(file), so REPL-persisted approvals are honored with no
508
+ // prompting; unknown/changed files stay excluded and each exclusion is
509
+ // bus-visible.
399
510
  const { loadKlyroMdFiles } = await import('../context/klyro-md.js');
400
511
  const { ContextTrust } = await import('../context/trust.js');
401
512
  const { globalBus } = await import('../events/bus.js');
513
+ const trust = new ContextTrust();
514
+ // Headless approve-if-store-known (no prompts): approve(file) is
515
+ // trust.isTrusted(file), so REPL-persisted approvals are honored;
516
+ // unknown/changed files stay excluded.
517
+ const approve = (file) => trust.isTrusted(file);
402
518
  const files = await loadKlyroMdFiles(cwd);
403
- const { trusted, untrusted } = new ContextTrust().check(files);
519
+ const { trusted, untrusted } = trust.check(files);
404
520
  for (const { file, reason } of untrusted) {
405
521
  globalBus.emit({ type: 'context.trust_prompt', ts: Date.now(), sessionId: 'ephemeral', path: file.path, reason, trusted: false });
406
522
  }
407
523
  if (untrusted.length > 0) {
408
524
  stderr.write(`klyro: trust gate excluded ${untrusted.length} unapproved context file(s): ${untrusted.map((u) => u.file.path).join(', ')}\n`);
409
525
  }
410
- const kept = trusted.map((f) => `# ${f.path}\n${f.content}`).join('\n\n---\n\n');
526
+ const keptFiles = trusted.filter(approve);
527
+ const kept = keptFiles.map((f) => `# ${f.path}\n${f.content}`).join('\n\n---\n\n');
411
528
  if (kept)
412
529
  klyroBlock = `\n\n<KLYRO.md>\n${kept.slice(0, 4000)}\n</KLYRO.md>`;
413
530
  }
414
531
  catch { /* ignore */ }
415
532
  return (ctx) => {
416
- const t = ctx.telemetry ? '\n\n' + ctx.telemetry : '';
417
- return base(ctx) + prefix + klyroBlock + t;
533
+ const r = base(ctx);
534
+ if (typeof r === 'string') {
535
+ // Legacy string fn: keep the exact legacy concatenation behavior.
536
+ const t = ctx.telemetry ? '\n\n' + ctx.telemetry : '';
537
+ return r + prefix + klyroBlock + memBlock + t;
538
+ }
539
+ // Split shape: Level-6/KLYRO.md join the stable prefix; telemetry stays
540
+ // the volatile suffix for cache-friendly adapters.
541
+ return { system: r.system + prefix + klyroBlock + memBlock, ...(r.suffix !== undefined ? { suffix: r.suffix } : {}) };
418
542
  };
419
543
  }
420
544
  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) {
@@ -27,7 +27,10 @@ export async function loadKlyroMdFiles(cwd) {
27
27
  catch { /* ignore */ }
28
28
  }
29
29
  }
30
- 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']) {
31
34
  const p = path.join(cwd, name);
32
35
  try {
33
36
  const t = await fs.readFile(p, 'utf-8');
@@ -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;
@@ -1,15 +1,49 @@
1
1
  /**
2
2
  * 8.4 — Working memory & reminders: memory_write → .klyro/memory/session-notes.md (≤1k tokens) + todos re-inject
3
+ *
4
+ * Durability: writes are atomic (tmp + rename in the same dir) so a crash
5
+ * never leaves a half-written note file. Oversized single writes are
6
+ * rejected with an Error (the memory_write tool's safe() wrapper converts
7
+ * that into a ToolResult error) instead of being silently truncated.
3
8
  */
4
9
  import * as fs from 'node:fs/promises';
10
+ import { readFileSync } from 'node:fs';
5
11
  import * as path from 'node:path';
12
+ import { redact } from '../policy/secret-redactor.js';
13
+ /** Single-write ceiling — anything larger is rejected, not sliced. */
14
+ export const MEMORY_WRITE_LIMIT_CHARS = 8000;
15
+ /** Steady-state cap (~1k tokens ≈ 4k chars) kept via tail slice. */
16
+ export const MEMORY_STEADY_STATE_CHARS = 4000;
6
17
  export async function memoryWrite(cwd, content) {
18
+ if (content.length > MEMORY_WRITE_LIMIT_CHARS) {
19
+ throw new Error(`memory budget exceeded: single write is ${content.length} chars (max ${MEMORY_WRITE_LIMIT_CHARS}) — split it into smaller notes`);
20
+ }
7
21
  const dir = path.join(cwd, '.klyro', 'memory');
8
22
  await fs.mkdir(dir, { recursive: true });
9
23
  const p = path.join(dir, 'session-notes.md');
10
24
  const prev = await fs.readFile(p, 'utf-8').catch(() => '');
11
- const next = (prev + '\n' + content).slice(-4000); // ≤1k tokens ~4k chars
12
- await fs.writeFile(p, next, 'utf-8');
25
+ // S4-at-rest: redact before appending — redact() only fires on secret
26
+ // shapes (key/token/password with [:=-]), so normal prose survives.
27
+ const next = (prev + '\n' + redact(content)).slice(-MEMORY_STEADY_STATE_CHARS); // ≤1k tokens ~4k chars
28
+ const tmp = path.join(dir, `.session-notes.md.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
29
+ await fs.writeFile(tmp, next, 'utf-8');
30
+ try {
31
+ const fh = await fs.open(tmp, 'r+');
32
+ try {
33
+ await fh.sync();
34
+ }
35
+ finally {
36
+ await fh.close();
37
+ }
38
+ }
39
+ catch { /* ignore on Windows */ }
40
+ try {
41
+ await fs.rename(tmp, p);
42
+ }
43
+ catch {
44
+ await fs.unlink(tmp).catch(() => undefined);
45
+ throw new Error('Failed to write memory file');
46
+ }
13
47
  return p;
14
48
  }
15
49
  export async function loadMemory(cwd) {
@@ -20,6 +54,20 @@ export async function loadMemory(cwd) {
20
54
  return '';
21
55
  }
22
56
  }
57
+ /** Synchronous read for the system-prompt build path (run on every turn). */
58
+ export function loadMemorySync(cwd) {
59
+ try {
60
+ return readFileSync(path.join(cwd, '.klyro', 'memory', 'session-notes.md'), 'utf-8');
61
+ }
62
+ catch {
63
+ return '';
64
+ }
65
+ }
66
+ /** Wrap persisted notes into the injected prompt block; '' when empty. */
67
+ export function memoryBlock(cwd) {
68
+ const notes = loadMemorySync(cwd).trim();
69
+ return notes ? `\n\n<memory>\n${notes.slice(0, 4000)}\n</memory>` : '';
70
+ }
23
71
  export function shouldRemind(turn, lastRemindTurn) {
24
72
  return turn - lastRemindTurn >= 20;
25
73
  }
@@ -45,6 +45,12 @@ export interface ProjectMap {
45
45
  hasDocker?: boolean;
46
46
  hasEnvExample?: boolean;
47
47
  }
48
+ /**
49
+ * Dirty-tree fingerprint: max mtimeMs over a bounded walk (≤200 files,
50
+ * depth ≤4, ignored dirs skipped). Cheap enough to run on every cache
51
+ * lookup; any edit under the tree changes the key and busts the cache.
52
+ */
53
+ export declare function dirtyMtime(root: string): Promise<number>;
48
54
  /** Build a project map for the repo rooted at `root`. */
49
55
  export declare function buildProjectMapCached(root: string): Promise<ProjectMap>;
50
56
  /** Build a project map for the repo rooted at `root`. */