@axplusb/kepler 2.0.7 → 2.3.0

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 (42) hide show
  1. package/KEPLER-README.md +157 -142
  2. package/README.md +37 -65
  3. package/package.json +2 -2
  4. package/src/config/cli-args.mjs +14 -0
  5. package/src/config/hook-runner.mjs +100 -0
  6. package/src/config/memory-loader.mjs +32 -0
  7. package/src/config/settings-loader.mjs +45 -0
  8. package/src/core/agent-loop.mjs +8 -2
  9. package/src/core/approval-log.mjs +104 -0
  10. package/src/core/approval.mjs +164 -24
  11. package/src/core/cache-control.mjs +92 -0
  12. package/src/core/context-envelope.mjs +54 -0
  13. package/src/core/headless.mjs +99 -10
  14. package/src/core/jsonl-writer.mjs +50 -0
  15. package/src/core/local-agent.mjs +121 -14
  16. package/src/core/local-store.mjs +486 -5
  17. package/src/core/policy-resolver.mjs +156 -0
  18. package/src/core/project-context-loader.mjs +139 -0
  19. package/src/core/rate-limit-display.mjs +97 -0
  20. package/src/core/resume-mode.mjs +154 -0
  21. package/src/core/risk-tier.mjs +88 -2
  22. package/src/core/safety.mjs +3 -0
  23. package/src/core/session-manager.mjs +53 -10
  24. package/src/core/stream-client.mjs +69 -10
  25. package/src/core/system-prompt.mjs +6 -1
  26. package/src/core/tasks.mjs +196 -0
  27. package/src/core/tool-executor.mjs +72 -6
  28. package/src/core/trust.mjs +158 -0
  29. package/src/core/work-scope.mjs +217 -0
  30. package/src/onboarding/preflight.mjs +27 -11
  31. package/src/permissions/command-classifier.mjs +78 -0
  32. package/src/terminal/init.mjs +145 -0
  33. package/src/terminal/main.mjs +9 -0
  34. package/src/terminal/repl.mjs +1822 -140
  35. package/src/tools/bash.mjs +57 -9
  36. package/src/tools/project-overview.mjs +83 -10
  37. package/src/ui/approval.mjs +154 -35
  38. package/src/ui/commands.mjs +5 -4
  39. package/src/ui/formatter.mjs +39 -5
  40. package/src/ui/mission-report.mjs +55 -22
  41. package/src/ui/slash-commands.mjs +57 -5
  42. package/src/ui/tool-card.mjs +51 -1
@@ -0,0 +1,32 @@
1
+ import * as fs from 'node:fs';
2
+ import * as os from 'node:os';
3
+ import * as path from 'node:path';
4
+
5
+ function readIfExists(filePath, maxChars = 12000) {
6
+ try {
7
+ if (!fs.existsSync(filePath)) return null;
8
+ const content = fs.readFileSync(filePath, 'utf-8');
9
+ return {
10
+ path: filePath,
11
+ content: content.length > maxChars
12
+ ? content.slice(0, maxChars) + '\n\n[...truncated...]'
13
+ : content,
14
+ };
15
+ } catch {
16
+ return null;
17
+ }
18
+ }
19
+
20
+ export function loadKeplerMemory({ cwd = process.cwd() } = {}) {
21
+ const files = [];
22
+ const global = readIfExists(path.join(os.homedir(), '.kepler', 'KEPLER.md'));
23
+ if (global) files.push({ source: 'global', ...global });
24
+
25
+ const topLevel = readIfExists(path.join(cwd, 'KEPLER.md'));
26
+ if (topLevel) files.push({ source: 'project-top-level', ...topLevel });
27
+
28
+ const project = readIfExists(path.join(cwd, '.kepler', 'KEPLER.md'));
29
+ if (project) files.push({ source: 'project', ...project });
30
+
31
+ return files;
32
+ }
@@ -0,0 +1,45 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import { deepMerge } from '../core/policy-resolver.mjs';
4
+
5
+ export const DEFAULT_KEPLER_SETTINGS = Object.freeze({
6
+ env: {},
7
+ permissions: {
8
+ shellAllowlist: [],
9
+ editDenylist: [],
10
+ },
11
+ hooks: {
12
+ UserPromptSubmit: [],
13
+ PreToolUse: [],
14
+ PostToolUse: [],
15
+ Stop: [],
16
+ },
17
+ });
18
+
19
+ function readJson(filePath) {
20
+ try {
21
+ if (!fs.existsSync(filePath)) return null;
22
+ return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
23
+ } catch (err) {
24
+ return { __error: err.message };
25
+ }
26
+ }
27
+
28
+ export function loadKeplerSettings({ cwd = process.cwd() } = {}) {
29
+ const base = path.join(cwd, '.kepler');
30
+ const layers = [
31
+ { name: 'default', path: null, data: DEFAULT_KEPLER_SETTINGS },
32
+ ];
33
+ for (const [name, file] of [
34
+ ['project', path.join(base, 'settings.json')],
35
+ ['local', path.join(base, 'settings.local.json')],
36
+ ]) {
37
+ const data = readJson(file);
38
+ if (data && !data.__error) layers.push({ name, path: file, data });
39
+ else if (data?.__error) layers.push({ name, path: file, error: data.__error, data: {} });
40
+ }
41
+
42
+ let settings = {};
43
+ for (const layer of layers) settings = deepMerge(settings, layer.data || {});
44
+ return { settings, layers };
45
+ }
@@ -6,6 +6,7 @@ import { streamResponse, accumulateStream } from './streaming.mjs';
6
6
  import { ContextManager } from './context-manager.mjs';
7
7
  import { buildSystemPrompt } from './system-prompt.mjs';
8
8
  import { createStagnationTracker, stagnationMessage } from './stagnation.mjs';
9
+ import { PromptCache } from './cache.mjs';
9
10
  import fs from 'fs';
10
11
  import path from 'path';
11
12
  export function createAgentLoop({ model, tools, permissions, settings, hooks }) {
@@ -23,10 +24,11 @@ export function createAgentLoop({ model, tools, permissions, settings, hooks })
23
24
  messages: [],
24
25
  systemPrompt: promptResult.full,
25
26
  turnCount: 0,
26
- tokenUsage: { input: 0, output: 0 },
27
+ tokenUsage: { input: 0, output: 0, cache_read: 0, cache_creation: 0 },
27
28
  model,
28
29
  tools,
29
30
  _contextManager: contextManager,
31
+ _promptCache: new PromptCache(),
30
32
  };
31
33
  const stagnation = createStagnationTracker({
32
34
  enabled: settings.stagnationDetection === true,
@@ -99,10 +101,14 @@ export function createAgentLoop({ model, tools, permissions, settings, hooks })
99
101
  return;
100
102
  }
101
103
 
102
- // Track token usage
104
+ // Track token usage (PRD-071 §1.1: also record cache hits/writes so
105
+ // /extra-usage and /status stop reporting zeros)
103
106
  if (response.usage) {
104
107
  state.tokenUsage.input += response.usage.input_tokens || 0;
105
108
  state.tokenUsage.output += response.usage.output_tokens || 0;
109
+ state.tokenUsage.cache_read += response.usage.cache_read_input_tokens || 0;
110
+ state.tokenUsage.cache_creation += response.usage.cache_creation_input_tokens || 0;
111
+ state._promptCache.updateStats(response.usage);
106
112
  }
107
113
 
108
114
  // Build assistant message for history
@@ -0,0 +1,104 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+
4
+ const REDACTED = 'REDACTED';
5
+ const SENSITIVE_KEY_RE = /^(?:authorization|api[-_]?key|apikey|key|token|access[-_]?token|refresh[-_]?token|secret|password|passwd|pwd|access[-_]?key|secret[-_]?access[-_]?key)$|(?:^|[-_])(?:api[-_]?key|apikey|token|secret|password|passwd|pwd|access[-_]?key|secret[-_]?access[-_]?key)(?:$|[-_])/i;
6
+ const SENSITIVE_ASSIGNMENT_KEY = String.raw`[A-Z0-9_-]*(?:API[-_]?KEY|APIKEY|TOKEN|SECRET|PASSWORD|PASSWD|PWD|ACCESS[-_]?KEY|SECRET[-_]?ACCESS[-_]?KEY)[A-Z0-9_-]*`;
7
+ const SENSITIVE_JSON_KEY = String.raw`(?:authorization|api[-_]?key|apikey|key|token|access[-_]?token|refresh[-_]?token|secret|password|passwd|pwd|access[-_]?key|secret[-_]?access[-_]?key|[A-Z0-9_-]*(?:API[-_]?KEY|APIKEY|TOKEN|SECRET|PASSWORD|PASSWD|PWD|ACCESS[-_]?KEY|SECRET[-_]?ACCESS[-_]?KEY)[A-Z0-9_-]*)`;
8
+
9
+ export function redactSensitive(str) {
10
+ let s = String(str ?? '');
11
+
12
+ // Authorization headers in shell commands, curl args, and copied HTTP snippets.
13
+ s = s.replace(/(Authorization\s*:\s*(?:Bearer|Basic)\s+)(?:"[^"]*"|'[^']*'|[^\s"',;]+)/gi, `$1${REDACTED}`);
14
+
15
+ // Secret-bearing query/form parameters.
16
+ s = s.replace(/([?&](?:api_key|apikey|api-key|key|token|access_token|refresh_token|password|secret)=)([^&\s"']+)/gi, `$1${REDACTED}`);
17
+
18
+ // Env-style assignments, including quoted values.
19
+ s = s.replace(
20
+ new RegExp(`(^|[\\s;,])(${SENSITIVE_ASSIGNMENT_KEY}\\s*=\\s*)(?:"[^"]*"|'[^']*'|[^\\s"',;]+)`, 'gi'),
21
+ (_match, prefix, key) => `${prefix}${key}${REDACTED}`
22
+ );
23
+
24
+ // JSON fragments that arrive as strings rather than structured objects.
25
+ s = s.replace(
26
+ new RegExp(`("(?:${SENSITIVE_JSON_KEY})"\\s*:\\s*)(?:"(?:\\\\.|[^"\\\\])*"|[^,}\\s]+)`, 'gi'),
27
+ `$1"${REDACTED}"`
28
+ );
29
+
30
+ return s;
31
+ }
32
+
33
+ function isSensitiveKey(key) {
34
+ return SENSITIVE_KEY_RE.test(String(key || ''));
35
+ }
36
+
37
+ function sanitizeForLog(value, depth = 0) {
38
+ if (depth > 12) return '[MaxDepth]';
39
+ if (typeof value === 'string') return redactSensitive(value);
40
+ if (value == null || typeof value !== 'object') return value;
41
+ if (Array.isArray(value)) return value.slice(0, 100).map(item => sanitizeForLog(item, depth + 1));
42
+
43
+ const out = {};
44
+ for (const [key, item] of Object.entries(value)) {
45
+ out[key] = isSensitiveKey(key) ? REDACTED : sanitizeForLog(item, depth + 1);
46
+ }
47
+ return out;
48
+ }
49
+
50
+ function safeArgs(args) {
51
+ if (args?.command) return redactSensitive(String(args.command)).slice(0, 500);
52
+ if (args?.file_path || args?.path) return redactSensitive(String(args.file_path || args.path)).slice(0, 500);
53
+ try { return JSON.stringify(sanitizeForLog(args || {})).slice(0, 500); }
54
+ catch { return redactSensitive(String(args || '')).slice(0, 500); }
55
+ }
56
+
57
+ function safeText(value, max = 500) {
58
+ if (!value) return undefined;
59
+ return redactSensitive(String(value)).slice(0, max);
60
+ }
61
+
62
+ export class ApprovalLog {
63
+ constructor({ cwd = process.cwd() } = {}) {
64
+ this.cwd = cwd;
65
+ this.filePath = path.join(cwd, '.kepler', 'approvals.log');
66
+ }
67
+
68
+ append(entry) {
69
+ try {
70
+ fs.mkdirSync(path.dirname(this.filePath), { recursive: true, mode: 0o700 });
71
+ const line = JSON.stringify({
72
+ ts: new Date().toISOString(),
73
+ tier: entry.tier,
74
+ tool: entry.tool,
75
+ args: safeArgs(entry.args),
76
+ decision: entry.decision,
77
+ scope: entry.scope || 'once',
78
+ rule_id: entry.rule_id || null,
79
+ reason: safeText(entry.reason),
80
+ });
81
+ fs.appendFileSync(this.filePath, line + '\n', { mode: 0o600 });
82
+ try { fs.chmodSync(this.filePath, 0o600); } catch {}
83
+ } catch { /* approval logging must not block execution */ }
84
+ }
85
+
86
+ readRecent(limit = 20) {
87
+ try {
88
+ if (!fs.existsSync(this.filePath)) return [];
89
+ return fs.readFileSync(this.filePath, 'utf-8')
90
+ .trim()
91
+ .split('\n')
92
+ .filter(Boolean)
93
+ .slice(-limit)
94
+ .map(line => {
95
+ try { return JSON.parse(line); }
96
+ catch { return null; }
97
+ })
98
+ .filter(Boolean)
99
+ .reverse();
100
+ } catch {
101
+ return [];
102
+ }
103
+ }
104
+ }
@@ -20,9 +20,12 @@ import {
20
20
  } from './risk-tier.mjs';
21
21
  import {
22
22
  renderApprovalPrompt,
23
- renderInlinePrompt,
23
+ renderTrustedApproval,
24
24
  defaultOptions as approvalOptions,
25
25
  } from '../ui/approval.mjs';
26
+ import { ApprovalLog } from './approval-log.mjs';
27
+ import { TrustStore } from './trust.mjs';
28
+ import { loadEffectivePolicy } from './policy-resolver.mjs';
26
29
 
27
30
  // ── Tool Classification ──
28
31
  //
@@ -36,6 +39,8 @@ const WRITE_TOOLS = new Set([
36
39
 
37
40
  function defaultWhy(tier, tool, args) {
38
41
  switch (tier) {
42
+ case TIERS.SENSITIVE_READ:
43
+ return `Reads a sensitive path (${toolDisplaySummary(tool, args) || 'secret-like file'}). Confirm before exposing its contents to the agent.`;
39
44
  case TIERS.SHELL_DANGEROUS:
40
45
  return `Shell command matches a high-risk pattern (rm -rf, sudo, force push, etc.). Confirm before running.`;
41
46
  case TIERS.DESTRUCTIVE:
@@ -66,12 +71,17 @@ const write = (s) => process.stderr.write(s);
66
71
  // ── Approval Manager ──
67
72
 
68
73
  export class ApprovalManager {
69
- constructor({ autoApprove = false, planMode = false } = {}) {
74
+ constructor({ autoApprove = false, planMode = false, cwd = process.cwd(), policy = null, trustStore = null, approvalLog = null } = {}) {
70
75
  this.autoApprove = autoApprove;
71
76
  this.planMode = planMode;
77
+ this.cwd = cwd;
78
+ this.policy = policy || loadEffectivePolicy({ cwd }).policy;
79
+ this.trustStore = trustStore || new TrustStore({ cwd, policy: this.policy });
80
+ this.approvalLog = approvalLog || new ApprovalLog({ cwd });
72
81
  this.approveAll = false;
73
82
  this.approvedToolTypes = new Set();
74
83
  this.history = [];
84
+ this.rejectionHints = [];
75
85
  this._rl = null;
76
86
  }
77
87
 
@@ -86,7 +96,8 @@ export class ApprovalManager {
86
96
  const wasActive = this.approveAll || this.approvedToolTypes.size > 0;
87
97
  this.approveAll = false;
88
98
  this.approvedToolTypes.clear();
89
- return wasActive;
99
+ const trustActive = this.trustStore?.revoke?.() || false;
100
+ return wasActive || trustActive;
90
101
  }
91
102
 
92
103
  getModeLabel() {
@@ -122,14 +133,32 @@ export class ApprovalManager {
122
133
  return { approved: true, tier, requireCheckpoint: true };
123
134
  }
124
135
 
136
+ const trust = this.trustStore?.find?.(toolName, args, tier);
137
+ if (trust?.decision === 'deny') {
138
+ this.history.push({ tool: toolName, decision: 'trusted-deny', tier, time: Date.now(), rule_id: trust.rule?.id });
139
+ this.approvalLog.append({ tool: toolName, args, tier, decision: 'deny_trusted', scope: trust.rule?.scope, rule_id: trust.rule?.id });
140
+ return { approved: false, tier, reason: `Denied by trust rule ${trust.rule?.id || ''}`.trim() };
141
+ }
142
+ if (trust?.decision === 'allow') {
143
+ this.history.push({ tool: toolName, decision: 'auto_trusted', tier, time: Date.now(), rule_id: trust.rule?.id });
144
+ this.approvalLog.append({ tool: toolName, args, tier, decision: 'auto_trusted', scope: trust.rule?.scope, rule_id: trust.rule?.id });
145
+ write(renderTrustedApproval({ tool: toolName, args, scope: trust.rule?.scope, ruleId: trust.rule?.id }));
146
+ return { approved: true, tier, scope: trust.rule?.scope, rule_id: trust.rule?.id };
147
+ }
148
+ if (trust?.decision === 'reask' && trust.reason) {
149
+ context.reason = context.reason || context.why || `Re-asking: ${trust.reason}`;
150
+ }
151
+
125
152
  // Honor approve-all / type-allow shortcuts for non-explicit tiers only.
126
153
  if (!requiresExplicitApproval(tier)) {
127
154
  if (this.approveAll) {
128
155
  this.history.push({ tool: toolName, decision: 'auto-all', tier, time: Date.now() });
156
+ this.approvalLog.append({ tool: toolName, args, tier, decision: 'auto-all', scope: 'session' });
129
157
  return { approved: true, tier };
130
158
  }
131
159
  if (this.approvedToolTypes.has(toolName)) {
132
160
  this.history.push({ tool: toolName, decision: 'type-auto', tier, time: Date.now() });
161
+ this.approvalLog.append({ tool: toolName, args, tier, decision: 'type-auto', scope: 'session' });
133
162
  return { approved: true, tier };
134
163
  }
135
164
  }
@@ -139,10 +168,9 @@ export class ApprovalManager {
139
168
 
140
169
  async _prompt(toolName, args, context = {}) {
141
170
  const tier = context.tier || classifyTier(toolName, args);
142
- const explicit = requiresExplicitApproval(tier);
143
171
  const why = context.reason || context.why || defaultWhy(tier, toolName, args);
144
172
  const summary = toolDisplaySummary(toolName, args);
145
- const options = approvalOptions(tier);
173
+ const options = this._optionsFor(tier);
146
174
 
147
175
  let selected = 0; // arrow-driven cursor
148
176
  let printedHeight = 0;
@@ -151,12 +179,10 @@ export class ApprovalManager {
151
179
  // live. For non-TTYs / pipes we just print once and read a line.
152
180
  const isInteractive = process.stdin.isTTY;
153
181
  if (!isInteractive) {
154
- write(explicit
155
- ? renderApprovalPrompt({ tool: toolName, args, tier, why, selected, options }) + '\n'
156
- : renderInlinePrompt({ tool: toolName, args, tier, why }) + '\n');
182
+ write(renderApprovalPrompt({ tool: toolName, args, tier, why, selected, options }) + '\n');
157
183
  }
158
184
 
159
- const drawExplicit = () => {
185
+ const drawPrompt = () => {
160
186
  // Move up over the previous render before re-printing.
161
187
  if (printedHeight > 0) {
162
188
  write(`\x1b[${printedHeight}F`); // cursor to start of N lines above
@@ -167,8 +193,7 @@ export class ApprovalManager {
167
193
  printedHeight = block.split('\n').length;
168
194
  };
169
195
 
170
- if (isInteractive && explicit) drawExplicit();
171
- if (isInteractive && !explicit) write(renderInlinePrompt({ tool: toolName, args, tier, why }) + '\n');
196
+ if (isInteractive) drawPrompt();
172
197
 
173
198
  // ── Input loop ─────────────────────────────────────────────────
174
199
  const choose = async () => {
@@ -176,15 +201,15 @@ export class ApprovalManager {
176
201
  const k = await this._readKey();
177
202
 
178
203
  if (k === 'up' || k === 'left') {
179
- if (!explicit || !isInteractive) continue;
204
+ if (!isInteractive) continue;
180
205
  selected = (selected - 1 + options.length) % options.length;
181
- drawExplicit();
206
+ drawPrompt();
182
207
  continue;
183
208
  }
184
209
  if (k === 'down' || k === 'right' || k === 'tab') {
185
- if (!explicit || !isInteractive) continue;
210
+ if (!isInteractive) continue;
186
211
  selected = (selected + 1) % options.length;
187
- drawExplicit();
212
+ drawPrompt();
188
213
  continue;
189
214
  }
190
215
  if (k === 'return') {
@@ -198,7 +223,7 @@ export class ApprovalManager {
198
223
  const idx = options.findIndex(o => o.key === lower);
199
224
  if (idx >= 0) {
200
225
  selected = idx;
201
- if (isInteractive && explicit) drawExplicit();
226
+ if (isInteractive) drawPrompt();
202
227
  return options[idx].value;
203
228
  }
204
229
  }
@@ -212,25 +237,51 @@ export class ApprovalManager {
212
237
  case 'approve':
213
238
  write(` ${GREEN}✓${RST} ${DIM}${toolName}${RST} ${DIM}${summary.slice(0, 60)}${RST}\n\n`);
214
239
  this.history.push({ tool: toolName, decision: 'yes', tier, time: Date.now() });
240
+ this.approvalLog.append({ tool: toolName, args, tier, decision: 'approve', scope: 'once' });
215
241
  return { approved: true, tier };
216
242
 
243
+ case 'allow-session': {
244
+ if (!this.policy.hitl?.allowSessionTrust) return this._prompt(toolName, args, context);
245
+ const rule = this.trustStore.add({ tool: toolName, args, tier, scope: 'SESSION' });
246
+ write(` ${GREEN}✓${RST} ${DIM}trusted for this session: ${rule.pattern}${RST}\n\n`);
247
+ this.history.push({ tool: toolName, decision: 'session-trust', tier, time: Date.now(), rule_id: rule.id });
248
+ this.approvalLog.append({ tool: toolName, args, tier, decision: 'approve_trusted', scope: 'SESSION', rule_id: rule.id });
249
+ return { approved: true, tier, scope: 'SESSION', rule_id: rule.id };
250
+ }
251
+
252
+ case 'allow-project': {
253
+ if (!this.policy.hitl?.allowProjectTrust) return this._prompt(toolName, args, context);
254
+ const rule = this.trustStore.add({ tool: toolName, args, tier, scope: 'PROJECT' });
255
+ write(` ${GREEN}✓${RST} ${DIM}trusted for this project: ${rule.pattern}${RST}\n\n`);
256
+ this.history.push({ tool: toolName, decision: 'project-trust', tier, time: Date.now(), rule_id: rule.id });
257
+ this.approvalLog.append({ tool: toolName, args, tier, decision: 'approve_trusted', scope: 'PROJECT', rule_id: rule.id });
258
+ return { approved: true, tier, scope: 'PROJECT', rule_id: rule.id };
259
+ }
260
+
217
261
  case 'reject':
218
- write(` ${RED}✗${RST} ${DIM}denied${RST}\n\n`);
219
- this.history.push({ tool: toolName, decision: 'no', tier, time: Date.now() });
220
- return { approved: false, tier, reason: 'User denied' };
262
+ {
263
+ const reason = 'User stopped the command';
264
+ write(` ${RED}✗${RST} ${DIM}stopped${RST}\n\n`);
265
+ this.history.push({ tool: toolName, decision: 'no', tier, time: Date.now(), reason });
266
+ this.approvalLog.append({ tool: toolName, args, tier, decision: 'reject', scope: 'once', reason });
267
+ this._rememberRejection({ tool: toolName, args, tier, decision: 'reject', reason, note: '' });
268
+ return { approved: false, tier, reason };
269
+ }
221
270
 
222
271
  case 'allow-all':
223
- if (explicit) return this._prompt(toolName, args, context);
272
+ if (requiresExplicitApproval(tier)) return this._prompt(toolName, args, context);
224
273
  this.approveAll = true;
225
274
  write(` ${GREEN}✓✓${RST} ${DIM}allow-all activated${RST}\n\n`);
226
275
  this.history.push({ tool: toolName, decision: 'approve-all', tier, time: Date.now() });
276
+ this.approvalLog.append({ tool: toolName, args, tier, decision: 'approve-all', scope: 'session' });
227
277
  return { approved: true, tier };
228
278
 
229
279
  case 'allow-type':
230
- if (explicit) return this._prompt(toolName, args, context);
280
+ if (requiresExplicitApproval(tier)) return this._prompt(toolName, args, context);
231
281
  this.approvedToolTypes.add(toolName);
232
282
  write(` ${GREEN}✓${RST} ${DIM}always allow ${toolName}${RST}\n\n`);
233
283
  this.history.push({ tool: toolName, decision: 'type-approve', tier, time: Date.now() });
284
+ this.approvalLog.append({ tool: toolName, args, tier, decision: 'type-approve', scope: 'session' });
234
285
  return { approved: true, tier };
235
286
 
236
287
  case 'why':
@@ -240,9 +291,15 @@ export class ApprovalManager {
240
291
 
241
292
  case 'edit':
242
293
  case 'replan':
243
- write(` ${YELLOW}↩${RST} ${DIM}reject with hint — rework the plan${RST}\n\n`);
244
- this.history.push({ tool: toolName, decision: 'replan', tier, time: Date.now() });
245
- return { approved: false, tier, reason: 'User asked to re-plan' };
294
+ {
295
+ const note = await this._readLinePrompt(` ${DIM}How would you like to proceed? ${RST}`);
296
+ const reason = note ? `User asked to re-plan: ${note}` : 'User asked to re-plan';
297
+ write(` ${YELLOW}↩${RST} ${DIM}${note ? `re-plan — ${truncateNote(note)}` : 'reject with hint — rework the plan'}${RST}\n\n`);
298
+ this.history.push({ tool: toolName, decision: 'replan', tier, time: Date.now(), reason });
299
+ this.approvalLog.append({ tool: toolName, args, tier, decision: 'replan', scope: 'once', reason });
300
+ this._rememberRejection({ tool: toolName, args, tier, decision: 'replan', reason, note });
301
+ return { approved: false, tier, reason };
302
+ }
246
303
 
247
304
  default:
248
305
  return this._prompt(toolName, args, context);
@@ -287,6 +344,70 @@ export class ApprovalManager {
287
344
  });
288
345
  }
289
346
 
347
+ _readLinePrompt(label) {
348
+ return new Promise((resolve) => {
349
+ if (!process.stdin.isTTY) {
350
+ resolve('');
351
+ return;
352
+ }
353
+
354
+ if (this._execPause) this._execPause();
355
+ if (this._rl) this._rl.pause();
356
+
357
+ const wasRaw = process.stdin.isRaw;
358
+ if (typeof process.stdin.setRawMode === 'function') {
359
+ process.stdin.setRawMode(false);
360
+ }
361
+ process.stdin.resume();
362
+ write(label);
363
+
364
+ let buffer = '';
365
+ const cleanup = () => {
366
+ process.stdin.off('data', onData);
367
+ if (typeof process.stdin.setRawMode === 'function') {
368
+ process.stdin.setRawMode(wasRaw || false);
369
+ }
370
+ if (this._rl) this._rl.resume();
371
+ if (this._execResume) this._execResume();
372
+ };
373
+ const finish = () => {
374
+ cleanup();
375
+ resolve(buffer.trim());
376
+ };
377
+ const onData = (data) => {
378
+ const str = data.toString();
379
+ if (data[0] === 0x03) process.exit(0);
380
+ if (data[0] === 0x1b) {
381
+ buffer = '';
382
+ write('\n');
383
+ finish();
384
+ return;
385
+ }
386
+ if (str.includes('\n') || str.includes('\r')) {
387
+ buffer += str.replace(/[\r\n].*$/s, '');
388
+ finish();
389
+ return;
390
+ }
391
+ buffer += str;
392
+ };
393
+
394
+ process.stdin.on('data', onData);
395
+ });
396
+ }
397
+
398
+ _optionsFor(tier) {
399
+ const options = approvalOptions(tier);
400
+ if (requiresExplicitApproval(tier)) {
401
+ if (this.policy.hitl?.allowSessionTrust) {
402
+ options.splice(1, 0, { key: 's', label: 'session', value: 'allow-session', hint: 'trust this pattern until expiry' });
403
+ }
404
+ if (this.policy.hitl?.allowProjectTrust) {
405
+ options.splice(1, 0, { key: 'a', label: 'project', value: 'allow-project', hint: 'trust this pattern in this repo' });
406
+ }
407
+ }
408
+ return options;
409
+ }
410
+
290
411
  getSummary() {
291
412
  const approved = this.history.filter(h => h.decision !== 'no').length;
292
413
  const denied = this.history.filter(h => h.decision === 'no').length;
@@ -296,6 +417,25 @@ export class ApprovalManager {
296
417
  denied,
297
418
  autoApproveAll: this.approveAll,
298
419
  autoApprovedTypes: [...this.approvedToolTypes],
420
+ trust: this.trustStore?.summary?.() || { sessionRules: 0, projectRules: 0 },
299
421
  };
300
422
  }
423
+
424
+ _rememberRejection(entry) {
425
+ this.rejectionHints.push({ ...entry, time: Date.now() });
426
+ if (this.rejectionHints.length > 10) {
427
+ this.rejectionHints.splice(0, this.rejectionHints.length - 10);
428
+ }
429
+ }
430
+
431
+ consumeRejectionHints() {
432
+ const hints = [...this.rejectionHints];
433
+ this.rejectionHints = [];
434
+ return hints;
435
+ }
436
+ }
437
+
438
+ function truncateNote(note) {
439
+ const text = String(note || '').trim();
440
+ return text.length <= 120 ? text : text.slice(0, 119) + '…';
301
441
  }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * cache_control breakpoints for Anthropic prompt caching (PRD-071 Phase 2).
3
+ *
4
+ * Shared by every direct-API call site that talks to Anthropic — LocalAgent
5
+ * (Anthropic direct + OpenRouter passthrough) and agent-loop's Task sub-agents.
6
+ *
7
+ * Anthropic allows 4 breakpoints per request; we spend 3:
8
+ * 1) End of system prompt (1h TTL — persistent across long-idle sessions)
9
+ * 2) Last tool schema (1h TTL — persistent)
10
+ * 3) Second-to-last user message (5min TTL — rolls each turn, cheaper to write)
11
+ *
12
+ * The 4th slot stays reserved (attachments, future retrieval prefix).
13
+ *
14
+ * Extended 1-hour TTL is a beta — pass ANTHROPIC_BETA_HEADER on the request
15
+ * whenever any block carries ttl:'1h'.
16
+ */
17
+
18
+ export const ANTHROPIC_BETA_HEADER = 'extended-cache-ttl-2025-04-11';
19
+
20
+ const CACHE_1H = { type: 'ephemeral', ttl: '1h' };
21
+ const CACHE_5M = { type: 'ephemeral' };
22
+
23
+ /**
24
+ * Turn a `system` string into a content-block array with a cache_control
25
+ * breakpoint on the tail. If the caller already passed blocks, returns them
26
+ * unchanged. Undefined / non-string inputs pass through as-is.
27
+ */
28
+ export function cacheableSystem(systemPrompt) {
29
+ if (Array.isArray(systemPrompt)) return systemPrompt;
30
+ if (!systemPrompt || typeof systemPrompt !== 'string') return systemPrompt;
31
+ return [{ type: 'text', text: systemPrompt, cache_control: CACHE_1H }];
32
+ }
33
+
34
+ /**
35
+ * Return a copy of `tools` with cache_control on the LAST tool. Anthropic
36
+ * caches system + tools as one prefix from that breakpoint, so this single
37
+ * marker covers the whole tool schema regardless of length.
38
+ */
39
+ export function cacheableTools(tools) {
40
+ if (!Array.isArray(tools) || tools.length === 0) return tools || [];
41
+ const out = tools.slice();
42
+ const last = out[out.length - 1];
43
+ out[out.length - 1] = { ...last, cache_control: CACHE_1H };
44
+ return out;
45
+ }
46
+
47
+ /**
48
+ * Tag the SECOND-to-last user message with a 5-min cache_control breakpoint.
49
+ * Leaves the last turn write-through so the next round extends the cache
50
+ * instead of re-writing it. Returns messages unchanged when there aren't
51
+ * enough user turns yet (< 2).
52
+ *
53
+ * Handles both content shapes: string (wrapped into blocks) and block array
54
+ * (tagged on the last block).
55
+ */
56
+ export function withMessageBreakpoint(messages) {
57
+ if (!Array.isArray(messages) || messages.length < 2) return messages;
58
+ const userIdx = [];
59
+ for (let i = 0; i < messages.length; i++) {
60
+ if (messages[i].role === 'user') userIdx.push(i);
61
+ }
62
+ if (userIdx.length < 2) return messages;
63
+ const targetIdx = userIdx[userIdx.length - 2];
64
+ const msg = messages[targetIdx];
65
+
66
+ if (typeof msg.content === 'string') {
67
+ return messages.map((m, i) => i === targetIdx ? {
68
+ ...m,
69
+ content: [{ type: 'text', text: m.content, cache_control: CACHE_5M }],
70
+ } : m);
71
+ }
72
+
73
+ if (Array.isArray(msg.content) && msg.content.length > 0) {
74
+ const blocks = msg.content.slice();
75
+ const last = blocks[blocks.length - 1];
76
+ blocks[blocks.length - 1] = { ...last, cache_control: CACHE_5M };
77
+ return messages.map((m, i) => i === targetIdx ? { ...m, content: blocks } : m);
78
+ }
79
+
80
+ return messages;
81
+ }
82
+
83
+ /**
84
+ * True if the given model id needs Anthropic-style explicit cache_control
85
+ * (vs OpenAI/DeepSeek which auto-cache). Handles bare Claude ids and
86
+ * OpenRouter's `anthropic/*` prefix.
87
+ */
88
+ export function needsExplicitCacheControl(model) {
89
+ if (!model) return false;
90
+ const m = model.toLowerCase();
91
+ return m.startsWith('claude') || m.startsWith('anthropic/');
92
+ }