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.
Files changed (58) hide show
  1. package/README.md +13 -0
  2. package/dist/agent/orchestrator.d.ts +7 -1
  3. package/dist/agent/orchestrator.js +18 -7
  4. package/dist/agent/runtime.d.ts +10 -0
  5. package/dist/agent/runtime.js +70 -13
  6. package/dist/chat.d.ts +10 -0
  7. package/dist/chat.js +39 -7
  8. package/dist/checkpoints/store.d.ts +2 -0
  9. package/dist/checkpoints/store.js +12 -0
  10. package/dist/cli/auth.d.ts +10 -3
  11. package/dist/cli/auth.js +43 -5
  12. package/dist/cli/doctor.js +0 -1
  13. package/dist/cli/eval.js +22 -16
  14. package/dist/cli/hooks.d.ts +21 -1
  15. package/dist/cli/hooks.js +34 -2
  16. package/dist/cli/keychain.d.ts +10 -0
  17. package/dist/cli/keychain.js +86 -0
  18. package/dist/cli/repl.js +54 -17
  19. package/dist/cli/setup.js +3 -2
  20. package/dist/cli/slash/parser.d.ts +1 -1
  21. package/dist/cli/slash/parser.js +6 -3
  22. package/dist/cli/update.d.ts +3 -1
  23. package/dist/cli/update.js +16 -1
  24. package/dist/context/accounting.d.ts +6 -0
  25. package/dist/context/accounting.js +8 -2
  26. package/dist/context/compaction.d.ts +2 -1
  27. package/dist/context/compaction.js +39 -12
  28. package/dist/context/memory.d.ts +11 -0
  29. package/dist/context/memory.js +47 -9
  30. package/dist/eval/harness.d.ts +19 -2
  31. package/dist/eval/harness.js +72 -7
  32. package/dist/index.js +90 -4
  33. package/dist/mcp/auth.d.ts +85 -0
  34. package/dist/mcp/auth.js +249 -0
  35. package/dist/mcp/config.d.ts +28 -0
  36. package/dist/mcp/config.js +65 -0
  37. package/dist/mcp/registry.d.ts +13 -7
  38. package/dist/mcp/registry.js +90 -14
  39. package/dist/mcp/remote.d.ts +7 -0
  40. package/dist/mcp/remote.js +56 -2
  41. package/dist/mcp/sse.d.ts +42 -0
  42. package/dist/mcp/sse.js +310 -0
  43. package/dist/persistence/audit.d.ts +15 -3
  44. package/dist/persistence/audit.js +84 -13
  45. package/dist/persistence/store.d.ts +9 -0
  46. package/dist/persistence/store.js +17 -0
  47. package/dist/policy/engine.js +9 -0
  48. package/dist/providers.js +4 -4
  49. package/dist/tools/shell/shell-exec.d.ts +13 -0
  50. package/dist/tools/shell/shell-exec.js +64 -2
  51. package/dist/tui/app.js +35 -5
  52. package/dist/tui/app.test.js +3 -2
  53. package/dist/tui/approval.js +3 -1
  54. package/dist/tui/scroll-model.d.ts +2 -2
  55. package/dist/tui/scroll-model.js +9 -3
  56. package/dist/tui/tokens.d.ts +8 -11
  57. package/dist/tui/tokens.js +18 -11
  58. package/package.json +1 -1
@@ -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: stdout.slice(0, MAX_HOOK_OUTPUT_CHARS),
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 also reports reverted files.
1192
- const { listCheckpointInfo, undo } = await import('../checkpoints/store.js');
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
- if (cmd.n === undefined && !cmd.summary) {
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 (cmd.summary) {
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 (cmd.summary) {
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 { getStoredKey } = await import('./auth.js');
1713
- const rows = ['openai', 'anthropic'].map((p) => {
1714
- const hasFile = !!getStoredKey(p);
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}: ${hasFile ? 'stored key (0600)' : hasEnv ? 'env key' : '—'}`;
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 argList = m[2] ? m[2].split(/\s+/) : [];
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], args);
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, getStoredKey, saveKey } from './auth.js';
16
+ import { LOGIN_DEFAULTS, saveKey } from './auth.js';
17
17
  import { loadConfig, saveConfig } from './config.js';
18
18
  import { assertSafeBaseURL } from '../chat.js';
19
19
  function isAbort(err) {
@@ -70,7 +70,8 @@ export async function runFirstRunSetup(ask) {
70
70
  const defs = LOGIN_DEFAULTS[name] ?? LOGIN_DEFAULTS.openai;
71
71
  const storeProvider = name === 'local' ? 'openai' : name;
72
72
  // Reuse an already-saved key when present — don't make the user re-paste.
73
- const hasKey = !!getStoredKey(storeProvider);
73
+ const { getStoredKeyAsync } = await import('./auth.js');
74
+ const hasKey = !!(await getStoredKeyAsync(storeProvider));
74
75
  let key = '';
75
76
  if (hasKey) {
76
77
  const keep = await ask(`API key already saved for ${storeProvider} — keep it? [Y/n]: `);
@@ -43,7 +43,7 @@ export type SlashCommand = {
43
43
  } | {
44
44
  kind: 'rewind';
45
45
  n?: number;
46
- summary?: boolean;
46
+ mode?: 'summary' | 'preview' | 'full';
47
47
  } | {
48
48
  kind: 'checkpoints';
49
49
  } | {
@@ -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), optional
44
- // post-restore summary of the reverted changes.
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
- ...(parts.slice(1).join(' ').toLowerCase() === 'summary' ? { summary: true } : {}),
53
+ ...(mode ? { mode } : {}),
51
54
  };
52
55
  }
53
56
  case 'checkpoints': return { kind: 'checkpoints' };
@@ -12,4 +12,6 @@
12
12
  /** Minimal semver compare for `x.y.z[-prerelease]`; null when unparseable. */
13
13
  export declare function compareSemver(a: string, b: string): number | null;
14
14
  export declare function checkForUpdate(current: string): Promise<string | null>;
15
- export declare function runUpdate(): Promise<number>;
15
+ export declare function runUpdate(opts?: {
16
+ apply?: boolean;
17
+ }): Promise<number>;
@@ -138,7 +138,7 @@ export async function checkForUpdate(current) {
138
138
  }
139
139
  return null;
140
140
  }
141
- export async function runUpdate() {
141
+ export async function runUpdate(opts = {}) {
142
142
  const here = await import('../index.js').then(() => '');
143
143
  // Get version from package.json via dynamic import
144
144
  const { readFileSync } = await import('node:fs');
@@ -151,6 +151,21 @@ export async function runUpdate() {
151
151
  const latest = await checkForUpdate(cur);
152
152
  if (latest) {
153
153
  process.stdout.write(`Update available: ${cur} → ${latest} (integrity verified)\n npm i -g klyro@latest\n`);
154
+ if (opts.apply) {
155
+ // Opt-in self-apply: the tarball was already hash-verified by
156
+ // checkForUpdate, so npm installs exactly the verified version.
157
+ process.stdout.write(`Applying update to klyro@${latest}...\n`);
158
+ const { spawnSync } = await import('node:child_process');
159
+ const npmCli = process.env['npm_execpath'];
160
+ const r = npmCli
161
+ ? spawnSync(process.execPath, [npmCli, 'i', '-g', `klyro@${latest}`], { stdio: 'inherit' })
162
+ : spawnSync('npm', ['i', '-g', `klyro@${latest}`], { stdio: 'inherit', shell: process.platform === 'win32' });
163
+ if (r.error) {
164
+ process.stderr.write(`klyro update: apply failed: ${r.error.message}\n`);
165
+ return 1;
166
+ }
167
+ return r.status === 0 ? 0 : 1;
168
+ }
154
169
  }
155
170
  else {
156
171
  process.stdout.write(`klyro ${cur} is latest\n`);
@@ -7,6 +7,12 @@ export interface ContextAccounting {
7
7
  compactAt: number;
8
8
  toolResultMax: number;
9
9
  }
10
+ /**
11
+ * Single output-reserve used by every budget in the harness (displayed
12
+ * accounting, runtime enforcement, compaction elide). One constant so the
13
+ * meter and the enforcer can never disagree.
14
+ */
15
+ export declare const RESERVE_OUTPUT_TOKENS = 8000;
10
16
  export declare function accounting(system: string | undefined, messages: Message[], opts?: {
11
17
  cap?: number;
12
18
  reserveOutput?: number;
@@ -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 ?? 16_000;
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 = 16_000) {
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: 16_000 });
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
- const n = Math.floor(messages.length * 0.6);
10
- const oldest = messages.slice(0, n);
11
- const newest = messages.slice(n);
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.includes(f.split('/').pop() ?? f));
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: [{ kind: 'text', text: summary }] };
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.includes(f.split('/').pop() ?? f))) {
32
- const summaryMsg2 = { role: 'user', content: [{ kind: 'text', text: retry }] };
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
  }
@@ -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). */