klyro 1.0.6 → 1.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -0
- package/dist/agent/orchestrator.d.ts +7 -1
- package/dist/agent/orchestrator.js +18 -7
- package/dist/agent/runtime.d.ts +10 -0
- package/dist/agent/runtime.js +70 -13
- package/dist/chat.d.ts +10 -0
- package/dist/chat.js +39 -7
- package/dist/checkpoints/store.d.ts +2 -0
- package/dist/checkpoints/store.js +12 -0
- package/dist/cli/auth.d.ts +10 -3
- package/dist/cli/auth.js +43 -5
- package/dist/cli/doctor.js +0 -1
- package/dist/cli/eval.js +22 -16
- package/dist/cli/hooks.d.ts +21 -1
- package/dist/cli/hooks.js +34 -2
- package/dist/cli/keychain.d.ts +10 -0
- package/dist/cli/keychain.js +86 -0
- package/dist/cli/repl.js +54 -17
- package/dist/cli/setup.js +3 -2
- package/dist/cli/slash/parser.d.ts +1 -1
- package/dist/cli/slash/parser.js +6 -3
- package/dist/cli/update.d.ts +3 -1
- package/dist/cli/update.js +16 -1
- package/dist/context/accounting.d.ts +6 -0
- package/dist/context/accounting.js +8 -2
- package/dist/context/compaction.d.ts +2 -1
- package/dist/context/compaction.js +39 -12
- package/dist/context/memory.d.ts +11 -0
- package/dist/context/memory.js +47 -9
- package/dist/eval/harness.d.ts +19 -2
- package/dist/eval/harness.js +72 -7
- package/dist/index.js +90 -4
- package/dist/mcp/auth.d.ts +85 -0
- package/dist/mcp/auth.js +249 -0
- package/dist/mcp/config.d.ts +28 -0
- package/dist/mcp/config.js +65 -0
- package/dist/mcp/registry.d.ts +13 -7
- package/dist/mcp/registry.js +90 -14
- package/dist/mcp/remote.d.ts +7 -0
- package/dist/mcp/remote.js +56 -2
- package/dist/mcp/sse.d.ts +42 -0
- package/dist/mcp/sse.js +310 -0
- package/dist/persistence/audit.d.ts +15 -3
- package/dist/persistence/audit.js +84 -13
- package/dist/persistence/store.d.ts +9 -0
- package/dist/persistence/store.js +17 -0
- package/dist/policy/engine.js +9 -0
- package/dist/providers.js +4 -4
- package/dist/tools/shell/shell-exec.d.ts +13 -0
- package/dist/tools/shell/shell-exec.js +64 -2
- package/dist/tui/app.js +35 -5
- package/dist/tui/app.test.js +3 -2
- package/dist/tui/approval.js +3 -1
- package/dist/tui/scroll-model.d.ts +2 -2
- package/dist/tui/scroll-model.js +9 -3
- package/dist/tui/tokens.d.ts +8 -11
- package/dist/tui/tokens.js +18 -11
- package/package.json +1 -1
package/dist/cli/hooks.d.ts
CHANGED
|
@@ -12,7 +12,13 @@
|
|
|
12
12
|
* Schema: `{ hooks: Array<{ name, event, command, matcher?, timeoutMs? }> }`.
|
|
13
13
|
* `matcher` is a regex tested against the tool name — lifecycle events
|
|
14
14
|
* (`sessionStart`/`sessionEnd`/`stop`) always match; tool events without a
|
|
15
|
-
* matcher match every tool.
|
|
15
|
+
* matcher match every tool; invalid regex never matches.
|
|
16
|
+
*
|
|
17
|
+
* Verdict contract: stdout parsed as JSON yields `{decision, message,
|
|
18
|
+
* context, continue|cont}`. preToolUse `decision:"deny"` blocks with
|
|
19
|
+
* `message` (wins over exit code); `decision:"allow"` + `context` attaches
|
|
20
|
+
* model-visible context to the tool result. stop `continue:true` grants one
|
|
21
|
+
* more turn (max 3/run). Non-JSON stdout keeps pure exit-code semantics.
|
|
16
22
|
*
|
|
17
23
|
* `loadHooks` never throws — a missing file is `[]`, an invalid file is
|
|
18
24
|
* `[]` plus a one-time stderr warning per path. `runHook` spawns the
|
|
@@ -49,6 +55,20 @@ export interface HookResult {
|
|
|
49
55
|
exitCode: number | null;
|
|
50
56
|
stdout: string;
|
|
51
57
|
stderr: string;
|
|
58
|
+
/**
|
|
59
|
+
* Structured verdict parsed from stdout when it is a JSON object
|
|
60
|
+
* (mirrors CC hook JSON output). Fields:
|
|
61
|
+
* - decision: 'allow' | 'deny' (preToolUse; deny wins over exit code)
|
|
62
|
+
* - message: denial reason / continuation note
|
|
63
|
+
* - context: extra model-visible context (preToolUse allow only)
|
|
64
|
+
* - cont: stop-hook request to continue the loop one more turn
|
|
65
|
+
*/
|
|
66
|
+
verdict?: {
|
|
67
|
+
decision?: string;
|
|
68
|
+
message?: string;
|
|
69
|
+
context?: string;
|
|
70
|
+
cont?: boolean;
|
|
71
|
+
};
|
|
52
72
|
}
|
|
53
73
|
export declare const DEFAULT_HOOK_TIMEOUT_MS = 30000;
|
|
54
74
|
/**
|
package/dist/cli/hooks.js
CHANGED
|
@@ -12,7 +12,13 @@
|
|
|
12
12
|
* Schema: `{ hooks: Array<{ name, event, command, matcher?, timeoutMs? }> }`.
|
|
13
13
|
* `matcher` is a regex tested against the tool name — lifecycle events
|
|
14
14
|
* (`sessionStart`/`sessionEnd`/`stop`) always match; tool events without a
|
|
15
|
-
* matcher match every tool.
|
|
15
|
+
* matcher match every tool; invalid regex never matches.
|
|
16
|
+
*
|
|
17
|
+
* Verdict contract: stdout parsed as JSON yields `{decision, message,
|
|
18
|
+
* context, continue|cont}`. preToolUse `decision:"deny"` blocks with
|
|
19
|
+
* `message` (wins over exit code); `decision:"allow"` + `context` attaches
|
|
20
|
+
* model-visible context to the tool result. stop `continue:true` grants one
|
|
21
|
+
* more turn (max 3/run). Non-JSON stdout keeps pure exit-code semantics.
|
|
16
22
|
*
|
|
17
23
|
* `loadHooks` never throws — a missing file is `[]`, an invalid file is
|
|
18
24
|
* `[]` plus a one-time stderr warning per path. `runHook` spawns the
|
|
@@ -214,11 +220,13 @@ export function runHook(hook, ctx, stdinJson) {
|
|
|
214
220
|
});
|
|
215
221
|
child.on('close', (code) => {
|
|
216
222
|
const exitCode = typeof code === 'number' ? code : -1;
|
|
223
|
+
const out = stdout.slice(0, MAX_HOOK_OUTPUT_CHARS);
|
|
217
224
|
resolve({
|
|
218
225
|
ok: exitCode === 0,
|
|
219
226
|
exitCode,
|
|
220
|
-
stdout:
|
|
227
|
+
stdout: out,
|
|
221
228
|
stderr: stderr.slice(0, MAX_HOOK_OUTPUT_CHARS),
|
|
229
|
+
...parseVerdict(out),
|
|
222
230
|
});
|
|
223
231
|
});
|
|
224
232
|
// Deliver the stdin JSON contract, then close so the child never hangs
|
|
@@ -234,6 +242,30 @@ export function runHook(hook, ctx, stdinJson) {
|
|
|
234
242
|
catch { /* ignore */ }
|
|
235
243
|
});
|
|
236
244
|
}
|
|
245
|
+
/** Parse a structured JSON verdict from hook stdout (best-effort). */
|
|
246
|
+
function parseVerdict(out) {
|
|
247
|
+
const trimmed = out.trim();
|
|
248
|
+
if (!trimmed.startsWith('{') || !trimmed.endsWith('}'))
|
|
249
|
+
return {};
|
|
250
|
+
try {
|
|
251
|
+
const p = JSON.parse(trimmed);
|
|
252
|
+
const verdict = {};
|
|
253
|
+
if (typeof p['decision'] === 'string')
|
|
254
|
+
verdict.decision = p['decision'];
|
|
255
|
+
if (typeof p['message'] === 'string')
|
|
256
|
+
verdict.message = p['message'].slice(0, 2000);
|
|
257
|
+
if (typeof p['context'] === 'string')
|
|
258
|
+
verdict.context = p['context'].slice(0, 2000);
|
|
259
|
+
if (typeof p['continue'] === 'boolean')
|
|
260
|
+
verdict.cont = p['continue'];
|
|
261
|
+
if (typeof p['cont'] === 'boolean' && verdict.cont === undefined)
|
|
262
|
+
verdict.cont = p['cont'];
|
|
263
|
+
return Object.keys(verdict).length > 0 ? { verdict } : {};
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
return {};
|
|
267
|
+
}
|
|
268
|
+
}
|
|
237
269
|
/**
|
|
238
270
|
* Run all `sessionEnd` hooks for a finished run (best-effort, sequential).
|
|
239
271
|
* Returns hook outputs for logging. Never throws.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export declare const KEYCHAIN_SERVICE = "klyro";
|
|
2
|
+
export type KeychainBackend = 'macos' | 'linux';
|
|
3
|
+
/** Which OS backend (if any) can store keys on this machine. */
|
|
4
|
+
export declare function keychainAvailable(): KeychainBackend | null;
|
|
5
|
+
/** Read a secret. Returns null when unavailable, missing, or denied. */
|
|
6
|
+
export declare function keychainGet(account: string): Promise<string | null>;
|
|
7
|
+
/** Store a secret. Returns false when unavailable or denied (caller falls back). */
|
|
8
|
+
export declare function keychainSet(account: string, secret: string): Promise<boolean>;
|
|
9
|
+
/** Delete a secret. Returns false when unavailable (caller falls back). */
|
|
10
|
+
export declare function keychainDelete(account: string): Promise<boolean>;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OS keychain for provider API keys — no new dependencies, platform CLIs only.
|
|
3
|
+
*
|
|
4
|
+
* - macOS: `security` (Keychain Services; always present)
|
|
5
|
+
* - Linux: `secret-tool` (libsecret; present on most GNOME desktops)
|
|
6
|
+
* - Windows: unavailable (no dependency-free read path) → file fallback
|
|
7
|
+
*
|
|
8
|
+
* Every function is best-effort and never throws: missing CLIs, locked
|
|
9
|
+
* keychains, and headless sessions all degrade to `null`/`false`, and
|
|
10
|
+
* callers fall back to the 0600 credentials file. Service name is fixed
|
|
11
|
+
* (`klyro`), accounts are provider ids (`openai`, `anthropic`).
|
|
12
|
+
*/
|
|
13
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
14
|
+
export const KEYCHAIN_SERVICE = 'klyro';
|
|
15
|
+
/** Which OS backend (if any) can store keys on this machine. */
|
|
16
|
+
export function keychainAvailable() {
|
|
17
|
+
if (process.platform === 'darwin')
|
|
18
|
+
return 'macos';
|
|
19
|
+
if (process.platform === 'linux') {
|
|
20
|
+
try {
|
|
21
|
+
const r = spawnSync('secret-tool', ['--version'], { stdio: 'ignore', timeout: 5000 });
|
|
22
|
+
if (r.error)
|
|
23
|
+
return null;
|
|
24
|
+
return typeof r.status === 'number' && r.status === 0 ? 'linux' : null;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
function run(args, input) {
|
|
33
|
+
try {
|
|
34
|
+
const res = execFileSync(args[0], args.slice(1), {
|
|
35
|
+
input: input ?? undefined,
|
|
36
|
+
encoding: 'utf-8',
|
|
37
|
+
stdio: ['pipe', 'pipe', 'ignore'],
|
|
38
|
+
timeout: 10_000,
|
|
39
|
+
});
|
|
40
|
+
return { ok: true, out: typeof res === 'string' ? res : '' };
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return { ok: false, out: '' };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/** Read a secret. Returns null when unavailable, missing, or denied. */
|
|
47
|
+
export async function keychainGet(account) {
|
|
48
|
+
const backend = keychainAvailable();
|
|
49
|
+
if (!backend)
|
|
50
|
+
return null;
|
|
51
|
+
if (backend === 'macos') {
|
|
52
|
+
const r = run(['security', 'find-generic-password', '-s', KEYCHAIN_SERVICE, '-a', account, '-w']);
|
|
53
|
+
const v = r.out.trim();
|
|
54
|
+
return r.ok && v ? v : null;
|
|
55
|
+
}
|
|
56
|
+
const r = run(['secret-tool', 'lookup', 'service', KEYCHAIN_SERVICE, 'account', account]);
|
|
57
|
+
const v = r.out.replace(/\n$/, '');
|
|
58
|
+
return r.ok && v ? v : null;
|
|
59
|
+
}
|
|
60
|
+
/** Store a secret. Returns false when unavailable or denied (caller falls back). */
|
|
61
|
+
export async function keychainSet(account, secret) {
|
|
62
|
+
if (!secret)
|
|
63
|
+
return false;
|
|
64
|
+
const backend = keychainAvailable();
|
|
65
|
+
if (!backend)
|
|
66
|
+
return false;
|
|
67
|
+
if (backend === 'macos') {
|
|
68
|
+
// -U updates an existing item instead of erroring on duplicates.
|
|
69
|
+
const r = run(['security', 'add-generic-password', '-U', '-s', KEYCHAIN_SERVICE, '-a', account, '-w', secret]);
|
|
70
|
+
return r.ok;
|
|
71
|
+
}
|
|
72
|
+
const r = run(['secret-tool', 'store', '--label', `klyro ${account}`, 'service', KEYCHAIN_SERVICE, 'account', account], secret);
|
|
73
|
+
return r.ok;
|
|
74
|
+
}
|
|
75
|
+
/** Delete a secret. Returns false when unavailable (caller falls back). */
|
|
76
|
+
export async function keychainDelete(account) {
|
|
77
|
+
const backend = keychainAvailable();
|
|
78
|
+
if (!backend)
|
|
79
|
+
return false;
|
|
80
|
+
if (backend === 'macos') {
|
|
81
|
+
const r = run(['security', 'delete-generic-password', '-s', KEYCHAIN_SERVICE, '-a', account]);
|
|
82
|
+
return r.ok;
|
|
83
|
+
}
|
|
84
|
+
const r = run(['secret-tool', 'clear', 'service', KEYCHAIN_SERVICE, 'account', account]);
|
|
85
|
+
return r.ok;
|
|
86
|
+
}
|
package/dist/cli/repl.js
CHANGED
|
@@ -1188,30 +1188,55 @@ export async function startRepl(opts = {}) {
|
|
|
1188
1188
|
}
|
|
1189
1189
|
case 'rewind': {
|
|
1190
1190
|
// Numbered rewind menu: bare /rewind lists snapshots (1 = latest),
|
|
1191
|
-
// /rewind <n> restores, /rewind <n> summary
|
|
1192
|
-
|
|
1191
|
+
// /rewind <n> restores code, /rewind <n> summary adds a revert
|
|
1192
|
+
// report, /rewind <n> preview dry-runs, /rewind <n> full also
|
|
1193
|
+
// truncates the conversation after the snapshot.
|
|
1194
|
+
const { listCheckpointInfo, snapshotFiles, undo } = await import('../checkpoints/store.js');
|
|
1193
1195
|
const infos = await listCheckpointInfo(cwd);
|
|
1194
1196
|
if (infos.length === 0) {
|
|
1195
1197
|
queuedAppend({ id: `rewind-${Date.now()}`, kind: 'text', text: 'No checkpoints yet — snapshots are taken after each file mutation.', role: 'assistant' });
|
|
1196
1198
|
return;
|
|
1197
1199
|
}
|
|
1198
1200
|
const n = cmd.n ?? 1;
|
|
1199
|
-
|
|
1201
|
+
const mode = cmd.mode;
|
|
1202
|
+
if (cmd.n === undefined && !mode) {
|
|
1200
1203
|
const rows = infos.slice(0, 10).map((c) => {
|
|
1201
1204
|
const age = c.ts > 0 ? ` (${Math.max(1, Math.round((Date.now() - c.ts) / 60000))}m ago)` : '';
|
|
1202
1205
|
return ` ${c.index}. ${c.id.slice(0, 12)} · ${c.files} file(s)${age}`;
|
|
1203
1206
|
});
|
|
1204
|
-
queuedAppend({ id: `rewind-${Date.now()}`, kind: 'text', text: `Checkpoints (1 = latest):\n${rows.join('\n')}\nrun /rewind <n> to restore, /rewind <n> summary for a revert report`, role: 'assistant' });
|
|
1207
|
+
queuedAppend({ id: `rewind-${Date.now()}`, kind: 'text', text: `Checkpoints (1 = latest):\n${rows.join('\n')}\nrun /rewind <n> to restore, /rewind <n> preview to dry-run, /rewind <n> summary for a revert report, /rewind <n> full to also rewind the conversation`, role: 'assistant' });
|
|
1205
1208
|
return;
|
|
1206
1209
|
}
|
|
1207
1210
|
if (n < 1 || n > infos.length) {
|
|
1208
1211
|
queuedAppend({ id: `rewind-err-${Date.now()}`, kind: 'error', message: `rewind failed: only ${infos.length} checkpoint(s), got n=${n}` });
|
|
1209
1212
|
return;
|
|
1210
1213
|
}
|
|
1214
|
+
const target = infos[n - 1];
|
|
1215
|
+
if (mode === 'preview') {
|
|
1216
|
+
// Dry run: list what WOULD be restored + current dirty state.
|
|
1217
|
+
const files = await snapshotFiles(cwd, target.id);
|
|
1218
|
+
let dirty = '';
|
|
1219
|
+
try {
|
|
1220
|
+
const { execFileSync } = await import('node:child_process');
|
|
1221
|
+
dirty = execFileSync('git', ['status', '--porcelain'], { cwd, encoding: 'utf-8', timeout: 5000 });
|
|
1222
|
+
}
|
|
1223
|
+
catch {
|
|
1224
|
+
dirty = '';
|
|
1225
|
+
}
|
|
1226
|
+
const listed = files.slice(0, 20).map((f) => ` ${f}`).join('\n') || ' (no files recorded)';
|
|
1227
|
+
const dirtyLines = dirty.split('\n').map((l) => l.trim()).filter(Boolean).slice(0, 10);
|
|
1228
|
+
queuedAppend({
|
|
1229
|
+
id: `rewind-${Date.now()}`,
|
|
1230
|
+
kind: 'text',
|
|
1231
|
+
text: `Preview: /rewind ${n} would restore checkpoint ${target.id.slice(0, 12)}:\n${listed}${files.length > 20 ? `\n … +${files.length - 20} more` : ''}${dirtyLines.length > 0 ? `\nCurrent dirty files that would be overwritten:\n${dirtyLines.map((f) => ` ${f}`).join('\n')}` : '\nWorking tree is clean.'}\nRun /rewind ${n} to apply.`,
|
|
1232
|
+
role: 'assistant',
|
|
1233
|
+
});
|
|
1234
|
+
return;
|
|
1235
|
+
}
|
|
1211
1236
|
try {
|
|
1212
1237
|
// Capture the pre-restore dirty state so `summary` can report it.
|
|
1213
1238
|
let before = '';
|
|
1214
|
-
if (
|
|
1239
|
+
if (mode === 'summary' || mode === 'full') {
|
|
1215
1240
|
try {
|
|
1216
1241
|
const { execFileSync } = await import('node:child_process');
|
|
1217
1242
|
before = execFileSync('git', ['status', '--porcelain'], { cwd, encoding: 'utf-8', timeout: 5000 });
|
|
@@ -1221,12 +1246,27 @@ export async function startRepl(opts = {}) {
|
|
|
1221
1246
|
}
|
|
1222
1247
|
}
|
|
1223
1248
|
await undo(cwd, n);
|
|
1224
|
-
const target = infos[n - 1];
|
|
1225
1249
|
let text = `Rewound to checkpoint ${n} (${target.id.slice(0, 12)}, ${target.files} file(s))`;
|
|
1226
|
-
if (
|
|
1250
|
+
if (mode === 'summary' || mode === 'full') {
|
|
1227
1251
|
const files = before.split('\n').map((l) => l.trim()).filter(Boolean).slice(0, 20);
|
|
1228
1252
|
text += files.length > 0 ? `\nReverted working-tree changes:\n${files.map((f) => ` ${f}`).join('\n')}` : '\nWorking tree was clean before restore.';
|
|
1229
1253
|
}
|
|
1254
|
+
if (mode === 'full') {
|
|
1255
|
+
// Conversation rewind: drop persisted messages/observations at
|
|
1256
|
+
// or after the snapshot, so resume continues from that point.
|
|
1257
|
+
if (tuiSessionId) {
|
|
1258
|
+
try {
|
|
1259
|
+
const removed = await tuiStore.truncateMessages(tuiSessionId, target.ts > 0 ? target.ts : Date.now());
|
|
1260
|
+
text += `\nConversation rewound: dropped ${removed.messages} message(s), ${removed.observations} observation(s).`;
|
|
1261
|
+
}
|
|
1262
|
+
catch (err) {
|
|
1263
|
+
text += `\nConversation rewind failed (code restored): ${err instanceof Error ? err.message : String(err)}`;
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
else {
|
|
1267
|
+
text += '\nNo active session — code restored, conversation untouched.';
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1230
1270
|
queuedAppend({ id: `rewind-${Date.now()}`, kind: 'text', text, role: 'assistant' });
|
|
1231
1271
|
}
|
|
1232
1272
|
catch (err) {
|
|
@@ -1709,12 +1749,12 @@ export async function startRepl(opts = {}) {
|
|
|
1709
1749
|
return;
|
|
1710
1750
|
}
|
|
1711
1751
|
case 'auth': {
|
|
1712
|
-
const {
|
|
1713
|
-
const rows = ['openai', 'anthropic'].map((p) => {
|
|
1714
|
-
const
|
|
1752
|
+
const { getStoredKeyAsync } = await import('./auth.js');
|
|
1753
|
+
const rows = await Promise.all(['openai', 'anthropic'].map(async (p) => {
|
|
1754
|
+
const hasKeychain = !!(await getStoredKeyAsync(p));
|
|
1715
1755
|
const hasEnv = !!(p === 'openai' ? process.env.OPENAI_API_KEY : process.env.ANTHROPIC_API_KEY) || !!process.env.KLYRO_API_KEY;
|
|
1716
|
-
return ` ${p}: ${
|
|
1717
|
-
});
|
|
1756
|
+
return ` ${p}: ${hasKeychain ? 'stored key (keychain/file)' : hasEnv ? 'env key' : '—'}`;
|
|
1757
|
+
}));
|
|
1718
1758
|
queuedAppend({ id: `auth-${Date.now()}`, kind: 'text', text: `Auth:\n${rows.join('\n')}\ncurrent provider: ${currentProvider}\nmanage via /login /logout`, role: 'assistant' });
|
|
1719
1759
|
return;
|
|
1720
1760
|
}
|
|
@@ -2924,12 +2964,9 @@ export async function startRepl(opts = {}) {
|
|
|
2924
2964
|
const pm = /^mcp__([A-Za-z0-9_-]{1,20})__([A-Za-z0-9_-]+)$/.exec(m[1].toLowerCase());
|
|
2925
2965
|
if (pm) {
|
|
2926
2966
|
const { runMcpPrompt } = await import('../mcp/registry.js');
|
|
2927
|
-
const
|
|
2928
|
-
const args = {};
|
|
2929
|
-
argList.forEach((a, i) => { args[`arg${i + 1}`] = a; });
|
|
2930
|
-
args['input'] = m[2] ?? '';
|
|
2967
|
+
const tokens = m[2] ? m[2].split(/\s+/).filter(Boolean) : [];
|
|
2931
2968
|
try {
|
|
2932
|
-
const text = await runMcpPrompt(cwd, pm[1], pm[2],
|
|
2969
|
+
const text = await runMcpPrompt(cwd, pm[1], pm[2], tokens);
|
|
2933
2970
|
await runWithBridge(text);
|
|
2934
2971
|
}
|
|
2935
2972
|
catch (err) {
|
package/dist/cli/setup.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
*
|
|
14
14
|
* The `ask` callback is injected so this is unit-testable without a TTY.
|
|
15
15
|
*/
|
|
16
|
-
import { LOGIN_DEFAULTS,
|
|
16
|
+
import { LOGIN_DEFAULTS, saveKey } from './auth.js';
|
|
17
17
|
import { loadConfig, saveConfig } from './config.js';
|
|
18
18
|
import { assertSafeBaseURL } from '../chat.js';
|
|
19
19
|
function isAbort(err) {
|
|
@@ -70,7 +70,8 @@ export async function runFirstRunSetup(ask) {
|
|
|
70
70
|
const defs = LOGIN_DEFAULTS[name] ?? LOGIN_DEFAULTS.openai;
|
|
71
71
|
const storeProvider = name === 'local' ? 'openai' : name;
|
|
72
72
|
// Reuse an already-saved key when present — don't make the user re-paste.
|
|
73
|
-
const
|
|
73
|
+
const { getStoredKeyAsync } = await import('./auth.js');
|
|
74
|
+
const hasKey = !!(await getStoredKeyAsync(storeProvider));
|
|
74
75
|
let key = '';
|
|
75
76
|
if (hasKey) {
|
|
76
77
|
const keep = await ask(`API key already saved for ${storeProvider} — keep it? [Y/n]: `);
|
package/dist/cli/slash/parser.js
CHANGED
|
@@ -40,14 +40,17 @@ export function parse(input) {
|
|
|
40
40
|
return { kind: 'undo', n: Number.isInteger(un) && un > 0 ? un : 1 };
|
|
41
41
|
}
|
|
42
42
|
case 'rewind': {
|
|
43
|
-
// /rewind [n] [summary] — nth-back snapshot (1 = latest)
|
|
44
|
-
// post-restore
|
|
43
|
+
// /rewind [n] [summary|preview|full] — nth-back snapshot (1 = latest).
|
|
44
|
+
// summary: post-restore revert report; preview: dry-run file list;
|
|
45
|
+
// full: code restore + truncate conversation after the snapshot.
|
|
45
46
|
const parts = rest.split(/\s+/).filter(Boolean);
|
|
46
47
|
const n = parts.length > 0 ? Number(parts[0]) : 1;
|
|
48
|
+
const modeWord = parts.slice(1).join(' ').toLowerCase();
|
|
49
|
+
const mode = modeWord === 'summary' || modeWord === 'preview' || modeWord === 'full' ? modeWord : undefined;
|
|
47
50
|
return {
|
|
48
51
|
kind: 'rewind',
|
|
49
52
|
n: Number.isInteger(n) && n > 0 ? n : 1,
|
|
50
|
-
...(
|
|
53
|
+
...(mode ? { mode } : {}),
|
|
51
54
|
};
|
|
52
55
|
}
|
|
53
56
|
case 'checkpoints': return { kind: 'checkpoints' };
|
package/dist/cli/update.d.ts
CHANGED
|
@@ -12,4 +12,6 @@
|
|
|
12
12
|
/** Minimal semver compare for `x.y.z[-prerelease]`; null when unparseable. */
|
|
13
13
|
export declare function compareSemver(a: string, b: string): number | null;
|
|
14
14
|
export declare function checkForUpdate(current: string): Promise<string | null>;
|
|
15
|
-
export declare function runUpdate(
|
|
15
|
+
export declare function runUpdate(opts?: {
|
|
16
|
+
apply?: boolean;
|
|
17
|
+
}): Promise<number>;
|
package/dist/cli/update.js
CHANGED
|
@@ -138,7 +138,7 @@ export async function checkForUpdate(current) {
|
|
|
138
138
|
}
|
|
139
139
|
return null;
|
|
140
140
|
}
|
|
141
|
-
export async function runUpdate() {
|
|
141
|
+
export async function runUpdate(opts = {}) {
|
|
142
142
|
const here = await import('../index.js').then(() => '');
|
|
143
143
|
// Get version from package.json via dynamic import
|
|
144
144
|
const { readFileSync } = await import('node:fs');
|
|
@@ -151,6 +151,21 @@ export async function runUpdate() {
|
|
|
151
151
|
const latest = await checkForUpdate(cur);
|
|
152
152
|
if (latest) {
|
|
153
153
|
process.stdout.write(`Update available: ${cur} → ${latest} (integrity verified)\n npm i -g klyro@latest\n`);
|
|
154
|
+
if (opts.apply) {
|
|
155
|
+
// Opt-in self-apply: the tarball was already hash-verified by
|
|
156
|
+
// checkForUpdate, so npm installs exactly the verified version.
|
|
157
|
+
process.stdout.write(`Applying update to klyro@${latest}...\n`);
|
|
158
|
+
const { spawnSync } = await import('node:child_process');
|
|
159
|
+
const npmCli = process.env['npm_execpath'];
|
|
160
|
+
const r = npmCli
|
|
161
|
+
? spawnSync(process.execPath, [npmCli, 'i', '-g', `klyro@${latest}`], { stdio: 'inherit' })
|
|
162
|
+
: spawnSync('npm', ['i', '-g', `klyro@${latest}`], { stdio: 'inherit', shell: process.platform === 'win32' });
|
|
163
|
+
if (r.error) {
|
|
164
|
+
process.stderr.write(`klyro update: apply failed: ${r.error.message}\n`);
|
|
165
|
+
return 1;
|
|
166
|
+
}
|
|
167
|
+
return r.status === 0 ? 0 : 1;
|
|
168
|
+
}
|
|
154
169
|
}
|
|
155
170
|
else {
|
|
156
171
|
process.stdout.write(`klyro ${cur} is latest\n`);
|
|
@@ -7,6 +7,12 @@ export interface ContextAccounting {
|
|
|
7
7
|
compactAt: number;
|
|
8
8
|
toolResultMax: number;
|
|
9
9
|
}
|
|
10
|
+
/**
|
|
11
|
+
* Single output-reserve used by every budget in the harness (displayed
|
|
12
|
+
* accounting, runtime enforcement, compaction elide). One constant so the
|
|
13
|
+
* meter and the enforcer can never disagree.
|
|
14
|
+
*/
|
|
15
|
+
export declare const RESERVE_OUTPUT_TOKENS = 8000;
|
|
10
16
|
export declare function accounting(system: string | undefined, messages: Message[], opts?: {
|
|
11
17
|
cap?: number;
|
|
12
18
|
reserveOutput?: number;
|
|
@@ -4,8 +4,14 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { totalTokens } from './tokenizer.js';
|
|
6
6
|
import { getModelInfo } from '../providers/model-info.js';
|
|
7
|
+
/**
|
|
8
|
+
* Single output-reserve used by every budget in the harness (displayed
|
|
9
|
+
* accounting, runtime enforcement, compaction elide). One constant so the
|
|
10
|
+
* meter and the enforcer can never disagree.
|
|
11
|
+
*/
|
|
12
|
+
export const RESERVE_OUTPUT_TOKENS = 8000;
|
|
7
13
|
export function accounting(system, messages, opts = {}) {
|
|
8
|
-
const reserveOutput = opts.reserveOutput ??
|
|
14
|
+
const reserveOutput = opts.reserveOutput ?? RESERVE_OUTPUT_TOKENS;
|
|
9
15
|
// Window-aware default: the legacy 120k ceiling overflows small-window
|
|
10
16
|
// models (e.g. 8k local models) and wastes large ones — size to the model.
|
|
11
17
|
const cap = opts.cap ?? capForModel(opts.model, reserveOutput);
|
|
@@ -21,7 +27,7 @@ export function accounting(system, messages, opts = {}) {
|
|
|
21
27
|
* tiny windows still function. Unknown models use the registry fallback
|
|
22
28
|
* window (100k); a missing model name keeps the legacy 120k.
|
|
23
29
|
*/
|
|
24
|
-
export function capForModel(model, reserveOutput =
|
|
30
|
+
export function capForModel(model, reserveOutput = RESERVE_OUTPUT_TOKENS) {
|
|
25
31
|
if (!model)
|
|
26
32
|
return 120_000;
|
|
27
33
|
const window = getModelInfo(model).contextWindow;
|
|
@@ -2,13 +2,14 @@
|
|
|
2
2
|
* 8.3 — Auto-compaction: (a) elide → (b) summarize 60% with model.small → (c) keep last N verbatim
|
|
3
3
|
* Trigger at compactAt (80%) or /compact [focus]. Validates summary mentions every checkpointed file else fallback.
|
|
4
4
|
*/
|
|
5
|
-
import type { Message } from '../agent/message.js';
|
|
5
|
+
import type { Message, ContentBlock } from '../agent/message.js';
|
|
6
6
|
export interface CompactionResult {
|
|
7
7
|
messages: Message[];
|
|
8
8
|
summary: string;
|
|
9
9
|
dropped: number;
|
|
10
10
|
method: 'elide' | 'summarize' | 'fallback';
|
|
11
11
|
}
|
|
12
|
+
export declare function textBlock(s: string): ContentBlock;
|
|
12
13
|
export declare function compact(messages: Message[], opts: {
|
|
13
14
|
system?: string;
|
|
14
15
|
cap?: number;
|
|
@@ -1,14 +1,37 @@
|
|
|
1
1
|
import { compressTranscript } from './tokenizer.js';
|
|
2
|
-
import { capForModel } from './accounting.js';
|
|
2
|
+
import { capForModel, RESERVE_OUTPUT_TOKENS } from './accounting.js';
|
|
3
|
+
export function textBlock(s) {
|
|
4
|
+
return { kind: 'text', text: s };
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Tightened checkpoint validation: every checkpointed file must be mentioned
|
|
8
|
+
* by a path segment, not a loose basename substring. `src/util.ts` is matched
|
|
9
|
+
* by "util.ts" only when it names a segment; `foo` never matches `foo.png`
|
|
10
|
+
* nor a path nested inside it.
|
|
11
|
+
*/
|
|
12
|
+
function mentionsFile(summary, filePath) {
|
|
13
|
+
const segs = filePath.split('/').filter(Boolean);
|
|
14
|
+
const tail = segs[segs.length - 1] ?? filePath;
|
|
15
|
+
// Match the full basename OR any path segment borne by the file.
|
|
16
|
+
// Prefer exact-path segments (src/util.ts or util.ts) to avoid the
|
|
17
|
+
// substring false-positive the old `includes(basename)` produced.
|
|
18
|
+
const quoted = filePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
19
|
+
const quotedTail = tail.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
20
|
+
return new RegExp(`(^|[\\W_])${quotedTail}([\\W_]|$)`).test(summary) || summary.includes(quoted);
|
|
21
|
+
}
|
|
3
22
|
export async function compact(messages, opts) {
|
|
4
23
|
const cap = opts.cap ?? capForModel(opts.model);
|
|
5
24
|
// (a) elide old tool results
|
|
6
|
-
const elided = compressTranscript(opts.system, messages, { total: cap, reservedOutput:
|
|
25
|
+
const elided = compressTranscript(opts.system, messages, { total: cap, reservedOutput: RESERVE_OUTPUT_TOKENS });
|
|
7
26
|
if (opts.checkpointedFiles && elided.dropped > 0) {
|
|
8
|
-
// (b) try summarize oldest 60% using strict template (mock small model)
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
27
|
+
// (b) try summarize oldest 60% using strict template (mock small model).
|
|
28
|
+
// Slice from the ELIDED sequence so the summarize pass operates on what
|
|
29
|
+
// actually survives elision (previously it sliced the raw `messages`,
|
|
30
|
+
// silently dropping the elide benefit on the oldest portion).
|
|
31
|
+
const elidedBest = elided.messages;
|
|
32
|
+
const n = Math.floor(elidedBest.length * 0.6);
|
|
33
|
+
const oldest = elidedBest.slice(0, n);
|
|
34
|
+
const newest = elidedBest.slice(n);
|
|
12
35
|
const template = `Summarize the following ${oldest.length} messages, mentioning every file: ${opts.checkpointedFiles.join(', ')}.\nFocus: ${opts.focus ?? 'general'}\n\n` + oldest.map((m) => JSON.stringify(m.content).slice(0, 500)).join('\n');
|
|
13
36
|
let summary = `Earlier in session: ${oldest.length} turns covering ${opts.checkpointedFiles.join(', ')}`;
|
|
14
37
|
if (opts.summarizeFn) {
|
|
@@ -17,24 +40,28 @@ export async function compact(messages, opts) {
|
|
|
17
40
|
}
|
|
18
41
|
catch { /* fallback */ }
|
|
19
42
|
}
|
|
20
|
-
// validate: every checkpointed file mentioned
|
|
21
|
-
const missing = opts.checkpointedFiles.filter((f) => !summary
|
|
43
|
+
// validate: every checkpointed file mentioned (path-segment match).
|
|
44
|
+
const missing = opts.checkpointedFiles.filter((f) => !mentionsFile(summary, f));
|
|
22
45
|
if (missing.length === 0) {
|
|
23
|
-
// (c) keep last N verbatim + summary as first message
|
|
24
|
-
const summaryMsg = { role: 'user', content: [
|
|
46
|
+
// (c) keep last N verbatim + summary as first message (typed block).
|
|
47
|
+
const summaryMsg = { role: 'user', content: [textBlock(summary)] };
|
|
25
48
|
return { messages: [summaryMsg, ...newest], summary, dropped: elided.dropped, method: 'summarize' };
|
|
26
49
|
}
|
|
27
50
|
// retry once then fallback to elide
|
|
28
51
|
if (opts.summarizeFn) {
|
|
29
52
|
try {
|
|
30
53
|
const retry = await opts.summarizeFn(template + '\nEnsure to mention: ' + missing.join(', '));
|
|
31
|
-
if (missing.every((f) => retry
|
|
32
|
-
const summaryMsg2 = { role: 'user', content: [
|
|
54
|
+
if (missing.every((f) => mentionsFile(retry, f))) {
|
|
55
|
+
const summaryMsg2 = { role: 'user', content: [textBlock(retry)] };
|
|
33
56
|
return { messages: [summaryMsg2, ...newest], summary: retry, dropped: elided.dropped, method: 'summarize' };
|
|
34
57
|
}
|
|
35
58
|
}
|
|
36
59
|
catch { /* fallback */ }
|
|
37
60
|
}
|
|
38
61
|
}
|
|
62
|
+
// Post-check the elide path too: if it still exceeds cap, keep eliding.
|
|
63
|
+
if (elided.dropped === 0) {
|
|
64
|
+
return { messages, summary: '', dropped: 0, method: 'fallback' };
|
|
65
|
+
}
|
|
39
66
|
return { messages: elided.messages, summary: elided.dropped > 0 ? `Elided ${elided.dropped} observations` : '', dropped: elided.dropped, method: elided.dropped > 0 ? 'elide' : 'fallback' };
|
|
40
67
|
}
|
package/dist/context/memory.d.ts
CHANGED
|
@@ -3,6 +3,17 @@ import type { PlanStep } from '../agent/runtime.js';
|
|
|
3
3
|
export declare const MEMORY_WRITE_LIMIT_CHARS = 8000;
|
|
4
4
|
/** Steady-state cap (~1k tokens ≈ 4k chars) kept via tail slice. */
|
|
5
5
|
export declare const MEMORY_STEADY_STATE_CHARS = 4000;
|
|
6
|
+
/** Archive index filename (recall without an LLM pass). */
|
|
7
|
+
export declare const MEMORY_ARCHIVE_INDEX = "archive-index.json";
|
|
8
|
+
/** Max retained archives. */
|
|
9
|
+
export declare const MEMORY_ARCHIVE_KEEP = 20;
|
|
10
|
+
export interface MemoryArchiveEntry {
|
|
11
|
+
file: string;
|
|
12
|
+
ts: number;
|
|
13
|
+
chars: number;
|
|
14
|
+
/** First line of the archived head — lets the model decide what to re-read. */
|
|
15
|
+
preview: string;
|
|
16
|
+
}
|
|
6
17
|
export declare function memoryWrite(cwd: string, content: string): Promise<string>;
|
|
7
18
|
export declare function loadMemory(cwd: string): Promise<string>;
|
|
8
19
|
/** Synchronous read for the system-prompt build path (run on every turn). */
|