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.
- package/dist/agent/anthropic-adapter.d.ts +49 -5
- package/dist/agent/anthropic-adapter.js +86 -17
- package/dist/agent/capabilities.d.ts +23 -0
- package/dist/agent/capabilities.js +53 -6
- package/dist/agent/child-worker.d.ts +104 -0
- package/dist/agent/child-worker.js +250 -0
- package/dist/agent/orchestrator.d.ts +124 -6
- package/dist/agent/orchestrator.js +425 -58
- package/dist/agent/provider-adapter.d.ts +8 -0
- package/dist/agent/provider-adapter.js +12 -3
- package/dist/agent/retry.d.ts +1 -1
- package/dist/agent/retry.js +54 -12
- package/dist/agent/runtime.d.ts +84 -8
- package/dist/agent/runtime.js +352 -39
- package/dist/agent/stream-budget.d.ts +36 -0
- package/dist/agent/stream-budget.js +121 -0
- package/dist/agent/worktree-manager.d.ts +74 -0
- package/dist/agent/worktree-manager.js +189 -0
- package/dist/checkpoints/store.d.ts +9 -0
- package/dist/checkpoints/store.js +56 -5
- package/dist/cli/auth.js +16 -1
- package/dist/cli/commit.d.ts +31 -0
- package/dist/cli/commit.js +142 -0
- package/dist/cli/config.d.ts +54 -3
- package/dist/cli/config.js +146 -3
- package/dist/cli/doctor.d.ts +1 -0
- package/dist/cli/doctor.js +71 -6
- package/dist/cli/eval.d.ts +6 -1
- package/dist/cli/eval.js +9 -0
- package/dist/cli/hooks.d.ts +47 -0
- package/dist/cli/hooks.js +181 -0
- package/dist/cli/repl.js +196 -29
- package/dist/cli/run.d.ts +13 -11
- package/dist/cli/run.js +144 -20
- package/dist/cli/update.d.ts +5 -0
- package/dist/cli/update.js +62 -10
- package/dist/context/import-graph.d.ts +2 -0
- package/dist/context/import-graph.js +31 -3
- package/dist/context/klyro-md.js +4 -1
- package/dist/context/memory.d.ts +8 -0
- package/dist/context/memory.js +50 -2
- package/dist/context/project-map.d.ts +6 -0
- package/dist/context/project-map.js +50 -2
- package/dist/context/repo-map.d.ts +2 -0
- package/dist/context/repo-map.js +31 -1
- package/dist/events/catalog.d.ts +37 -0
- package/dist/events/catalog.js +9 -0
- package/dist/index.js +177 -8
- package/dist/mcp/client.d.ts +6 -4
- package/dist/mcp/client.js +83 -14
- package/dist/mcp/config.d.ts +10 -0
- package/dist/mcp/config.js +18 -1
- package/dist/mcp/registry.d.ts +23 -19
- package/dist/mcp/registry.js +127 -8
- package/dist/mcp/schema.d.ts +11 -4
- package/dist/mcp/schema.js +27 -16
- package/dist/mcp/trust.d.ts +20 -0
- package/dist/mcp/trust.js +74 -0
- package/dist/persistence/audit.d.ts +28 -0
- package/dist/persistence/audit.js +101 -1
- package/dist/persistence/store.d.ts +26 -2
- package/dist/persistence/store.js +140 -13
- package/dist/policy/approval.d.ts +14 -0
- package/dist/policy/approval.js +44 -2
- package/dist/policy/engine.d.ts +17 -0
- package/dist/policy/engine.js +162 -9
- package/dist/policy/path-guard.d.ts +24 -0
- package/dist/policy/path-guard.js +46 -0
- package/dist/policy/secret-redactor.js +4 -0
- package/dist/providers/model-info.d.ts +23 -0
- package/dist/providers/model-info.js +43 -2
- package/dist/repl.d.ts +6 -0
- package/dist/repl.js +12 -7
- package/dist/tools/agent/spawn-agent.js +5 -5
- package/dist/tools/agent/task-apply.d.ts +4 -0
- package/dist/tools/agent/task-apply.js +44 -0
- package/dist/tools/agent/task-stop.d.ts +6 -0
- package/dist/tools/agent/task-stop.js +39 -0
- package/dist/tools/agent/task-wait.d.ts +17 -0
- package/dist/tools/agent/task-wait.js +79 -0
- package/dist/tools/fs/apply-patch.js +77 -1
- package/dist/tools/fs/edit-file.js +69 -1
- package/dist/tools/fs/multi-edit.d.ts +4 -0
- package/dist/tools/fs/multi-edit.js +70 -1
- package/dist/tools/fs/write-file.js +83 -6
- package/dist/tools/plan/todo-write.js +1 -1
- package/dist/tools/registry.js +6 -0
- package/dist/tools/shell/background.js +6 -3
- package/dist/tools/shell/sandbox.d.ts +51 -0
- package/dist/tools/shell/sandbox.js +143 -0
- package/dist/tools/shell/shell-exec.d.ts +29 -0
- package/dist/tools/shell/shell-exec.js +170 -12
- package/dist/tools/shell/worker-entry.d.ts +12 -0
- package/dist/tools/shell/worker-entry.js +43 -0
- package/dist/tools/types.d.ts +6 -0
- package/dist/tools/verify/run-verify.js +3 -1
- package/dist/trace/writer.d.ts +20 -0
- package/dist/trace/writer.js +62 -4
- package/dist/tui/app.js +1 -1
- package/dist/tui/approval.js +20 -21
- package/dist/util.d.ts +1 -0
- package/dist/util.js +1 -0
- package/dist/verification/baseline.js +17 -3
- package/dist/verification/classify.js +27 -15
- package/dist/verification/engine.d.ts +8 -0
- package/dist/verification/engine.js +28 -1
- package/dist/verification/registry.d.ts +2 -0
- package/dist/verification/registry.js +44 -0
- package/dist/verification/scoped.js +64 -11
- 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
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
//
|
|
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
|
|
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
|
-
}, {
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
375
|
-
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 ?
|
|
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
|
|
398
|
-
//
|
|
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 } =
|
|
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
|
|
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
|
|
417
|
-
|
|
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) {
|
package/dist/cli/update.d.ts
CHANGED
|
@@ -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>;
|
package/dist/cli/update.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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) {
|
package/dist/context/klyro-md.js
CHANGED
|
@@ -27,7 +27,10 @@ export async function loadKlyroMdFiles(cwd) {
|
|
|
27
27
|
catch { /* ignore */ }
|
|
28
28
|
}
|
|
29
29
|
}
|
|
30
|
-
|
|
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');
|
package/dist/context/memory.d.ts
CHANGED
|
@@ -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;
|
package/dist/context/memory.js
CHANGED
|
@@ -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
|
-
|
|
12
|
-
|
|
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`. */
|