@axplusb/kepler 1.0.10 → 2.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -2
- package/pulse/app/api/benchmark/route.ts +113 -0
- package/pulse/app/api/benchmarks/route.ts +195 -0
- package/pulse/app/benchmarks/page.tsx +224 -0
- package/pulse/components/layout/bottom-nav.tsx +2 -1
- package/pulse/components/layout/sidebar.tsx +2 -1
- package/src/context/retriever.mjs +42 -4
- package/src/context/symbol-indexer.mjs +375 -0
- package/src/core/approval.mjs +154 -95
- package/src/core/backend-url.mjs +2 -2
- package/src/core/headless.mjs +5 -0
- package/src/core/risk-tier.mjs +245 -0
- package/src/core/stream-client.mjs +24 -1
- package/src/core/tool-executor.mjs +58 -5
- package/src/onboarding/preflight.mjs +292 -0
- package/src/state/orbit.mjs +263 -0
- package/src/state/verbosity.mjs +99 -0
- package/src/terminal/ansi.mjs +44 -22
- package/src/terminal/repl.mjs +487 -133
- package/src/tools/project-overview.mjs +109 -16
- package/src/ui/approval.mjs +167 -0
- package/src/ui/banner.mjs +133 -122
- package/src/ui/dock.mjs +88 -0
- package/src/ui/icons.mjs +164 -0
- package/src/ui/mission-report.mjs +264 -0
- package/src/ui/palette.mjs +189 -0
- package/src/ui/spinner.mjs +116 -0
- package/src/ui/status-bar.mjs +275 -0
- package/src/ui/sub-agent.mjs +152 -0
- package/src/ui/term.mjs +159 -0
- package/src/ui/tool-card.mjs +322 -0
- package/src/ui/tool-details.mjs +277 -0
package/src/core/backend-url.mjs
CHANGED
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
|
|
10
10
|
const BACKEND_URLS = {
|
|
11
11
|
local: 'http://127.0.0.1:8000',
|
|
12
|
-
development: 'https://
|
|
13
|
-
production: 'https://
|
|
12
|
+
development: 'https://codekepler-backend-dev.kindisland-9034322d.eastus.azurecontainerapps.io',
|
|
13
|
+
production: 'https://codekepler-backend-prod.redsky-6d31f3e5.eastus.azurecontainerapps.io',
|
|
14
14
|
};
|
|
15
15
|
|
|
16
16
|
// Aliases
|
package/src/core/headless.mjs
CHANGED
|
@@ -188,6 +188,11 @@ export async function runHeadless({ instruction, model, timeout = 300, maxCost,
|
|
|
188
188
|
toolBreakdown[t.tool] = (toolBreakdown[t.tool] || 0) + 1;
|
|
189
189
|
}
|
|
190
190
|
|
|
191
|
+
// Include sub-agent tool counts in the total
|
|
192
|
+
for (const sa of subAgents) {
|
|
193
|
+
toolCount += sa.tool_calls || 0;
|
|
194
|
+
}
|
|
195
|
+
|
|
191
196
|
emit({
|
|
192
197
|
type: 'complete',
|
|
193
198
|
tools: toolCount,
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Risk tier classifier — Mission Control (PRD-055 §8.1).
|
|
3
|
+
*
|
|
4
|
+
* import { classify, TIERS, behavior } from './risk-tier.mjs';
|
|
5
|
+
* const tier = classify('shell', { command: 'rm -rf node_modules' });
|
|
6
|
+
* // → 'shell-dangerous'
|
|
7
|
+
*
|
|
8
|
+
* Pure: no I/O, no async. Tested in isolation; the rest of the CLI relies on
|
|
9
|
+
* the return value to decide whether to auto-approve, auto-approve with
|
|
10
|
+
* checkpoint, or hold for explicit approval.
|
|
11
|
+
*
|
|
12
|
+
* The CLI never asks the backend for the tier. The backend's job is to say
|
|
13
|
+
* "this is what I want to run"; we map that to a tier locally so dangerous
|
|
14
|
+
* intent can't be hidden behind a friendly description.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// ── Tier enum ────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
export const TIERS = Object.freeze({
|
|
20
|
+
READ: 'read',
|
|
21
|
+
LOCAL_EDIT: 'local-edit',
|
|
22
|
+
SHELL_SAFE: 'shell-safe',
|
|
23
|
+
SHELL_MEDIUM: 'shell-medium',
|
|
24
|
+
SHELL_DANGEROUS: 'shell-dangerous',
|
|
25
|
+
DESTRUCTIVE: 'destructive',
|
|
26
|
+
NETWORK: 'network',
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Default behavior by tier:
|
|
31
|
+
* auto — proceed silently
|
|
32
|
+
* auto-with-undo — proceed but record a checkpoint first
|
|
33
|
+
* prompt-safe — prompt with Enter=approve default
|
|
34
|
+
* prompt-explicit — magenta-bordered prompt, no default
|
|
35
|
+
*/
|
|
36
|
+
export const BEHAVIOR = Object.freeze({
|
|
37
|
+
[TIERS.READ]: 'auto',
|
|
38
|
+
[TIERS.LOCAL_EDIT]: 'auto-with-undo',
|
|
39
|
+
[TIERS.SHELL_SAFE]: 'auto',
|
|
40
|
+
[TIERS.SHELL_MEDIUM]: 'prompt-safe',
|
|
41
|
+
[TIERS.SHELL_DANGEROUS]: 'prompt-explicit',
|
|
42
|
+
[TIERS.DESTRUCTIVE]: 'prompt-explicit',
|
|
43
|
+
[TIERS.NETWORK]: 'prompt-safe',
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
export function behavior(tier) {
|
|
47
|
+
return BEHAVIOR[tier] || 'prompt-safe';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ── Tool → tier (non-shell) ─────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
const READ_TOOLS = new Set([
|
|
53
|
+
'read_file', 'read_files',
|
|
54
|
+
'search_code', 'search_files', 'grep',
|
|
55
|
+
'list_files', 'get_file_info', 'get_project_overview',
|
|
56
|
+
'git_status', 'git_diff',
|
|
57
|
+
'analyze_code',
|
|
58
|
+
'validate_file', 'validate_structure',
|
|
59
|
+
]);
|
|
60
|
+
|
|
61
|
+
const LOCAL_EDIT_TOOLS = new Set([
|
|
62
|
+
'edit_file', 'write_file', 'write_project',
|
|
63
|
+
]);
|
|
64
|
+
|
|
65
|
+
const DESTRUCTIVE_TOOLS = new Set([
|
|
66
|
+
'delete_file',
|
|
67
|
+
]);
|
|
68
|
+
|
|
69
|
+
const NETWORK_TOOLS = new Set([
|
|
70
|
+
'WebFetch', 'fetch_url',
|
|
71
|
+
]);
|
|
72
|
+
|
|
73
|
+
// ── Shell sub-classifier ────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
const SHELL_SAFE_RE = [
|
|
76
|
+
// Inspection / read-only + harmless shell navigation built-ins.
|
|
77
|
+
// `cd` / `pushd` / `popd` only change the process working directory; if
|
|
78
|
+
// chained with something dangerous, the multi-segment classifier still
|
|
79
|
+
// catches the danger (`cd /x && rm -rf .` → SHELL_DANGEROUS).
|
|
80
|
+
/^\s*(cd|pushd|popd|ls|cat|head|tail|less|more|wc|file|stat|tree|find|grep|rg|ag|fd|echo|printf|pwd|whoami|date|which|type|env|printenv|uname|hostname|id|df|du|uptime|free|top|ps|lsof)\b/i,
|
|
81
|
+
// mkdir -p / touch are creation primitives but harmless in scope.
|
|
82
|
+
/^\s*mkdir\s+-p\b/i,
|
|
83
|
+
/^\s*touch\s/i,
|
|
84
|
+
/^\s*git\s+(status|log|diff|show|branch|tag|remote|stash\s+list|blame|shortlog|describe|rev-parse|ls-files|ls-tree|config\s+--get)\b/i,
|
|
85
|
+
// Test-only invocations
|
|
86
|
+
/^\s*(npm|pnpm|yarn)\s+(test|run\s+test|run\s+lint|list|ls|view|info|outdated)\b/i,
|
|
87
|
+
/^\s*node\s+--check\b/i,
|
|
88
|
+
/^\s*python3?\s+-m\s+py_compile\b/i,
|
|
89
|
+
/^\s*pytest\b(?!.*--?(delete|rm|destructive))/i,
|
|
90
|
+
/^\s*cargo\s+(check|test|clippy|build)\b/i,
|
|
91
|
+
/^\s*go\s+(test|vet|build)\b/i,
|
|
92
|
+
/^\s*make\s+(test|check|lint|build)\b/i,
|
|
93
|
+
];
|
|
94
|
+
|
|
95
|
+
const SHELL_DANGEROUS_RE = [
|
|
96
|
+
/\brm\s+-r/i,
|
|
97
|
+
/\brm\s+--recursive/i,
|
|
98
|
+
/\brm\s+-rf?\b/i,
|
|
99
|
+
/\bunlink\s/i,
|
|
100
|
+
/\brmdir\s+-/i,
|
|
101
|
+
/\bgit\s+push.*--force/i,
|
|
102
|
+
/\bgit\s+push.*-f\b/i,
|
|
103
|
+
/\bgit\s+reset\s+--hard/i,
|
|
104
|
+
/\bgit\s+clean\s+-f/i,
|
|
105
|
+
/\bgit\s+checkout\s+\./i,
|
|
106
|
+
/\bgit\s+stash\s+drop/i,
|
|
107
|
+
/\bgit\s+branch\s+-D/i,
|
|
108
|
+
/\bgit\s+filter-branch/i,
|
|
109
|
+
/\bsudo\b/i,
|
|
110
|
+
/\bsu\s+-/i,
|
|
111
|
+
/\bcurl\b.*\|\s*(sh|bash|zsh)/i,
|
|
112
|
+
/\bwget\b.*\|\s*(sh|bash|zsh)/i,
|
|
113
|
+
/\beval\s+["'$(]/i,
|
|
114
|
+
/\bkubectl\s+delete/i,
|
|
115
|
+
/\bdocker\s+(rm|rmi|system\s+prune|volume\s+rm|network\s+rm)/i,
|
|
116
|
+
/\bdrop\s+(table|database|schema)/i,
|
|
117
|
+
/\btruncate\s+table/i,
|
|
118
|
+
/\bmkfs\b/i,
|
|
119
|
+
/\bdd\s+if=/i,
|
|
120
|
+
/:\s*\(\s*\)\s*\{.*:\|/i, // fork bomb
|
|
121
|
+
/>\s*\/dev\/sda/i,
|
|
122
|
+
];
|
|
123
|
+
|
|
124
|
+
const SHELL_MEDIUM_RE = [
|
|
125
|
+
/^\s*(npm|pnpm|yarn)\s+(install|i|add|remove|uninstall|update|upgrade|publish|deploy)\b/i,
|
|
126
|
+
/^\s*pip\s+(install|uninstall|--upgrade)\b/i,
|
|
127
|
+
/^\s*pipx\s+(install|uninstall)\b/i,
|
|
128
|
+
/^\s*brew\s+(install|uninstall|upgrade|update)\b/i,
|
|
129
|
+
/^\s*apt(-get)?\s+(install|remove|upgrade|update)\b/i,
|
|
130
|
+
/^\s*cargo\s+(install|uninstall|publish|run)\b/i,
|
|
131
|
+
/^\s*go\s+(install|get|mod\s+tidy|mod\s+download)\b/i,
|
|
132
|
+
/^\s*make(\s|$)/i,
|
|
133
|
+
/^\s*git\s+(commit|push|pull|merge|rebase|fetch|checkout(?!\s+\.)|cherry-pick|revert|tag|stash(?!\s+drop))/i,
|
|
134
|
+
/^\s*docker\s+(build|run|exec|compose|pull|push|tag)/i,
|
|
135
|
+
];
|
|
136
|
+
|
|
137
|
+
export function classifyShell(command) {
|
|
138
|
+
const cmd = String(command || '').trim();
|
|
139
|
+
if (!cmd) return TIERS.SHELL_MEDIUM;
|
|
140
|
+
|
|
141
|
+
// Dangerous wins over safe — never let a safe-looking prefix mask `&& rm -rf`.
|
|
142
|
+
if (SHELL_DANGEROUS_RE.some(re => re.test(cmd))) return TIERS.SHELL_DANGEROUS;
|
|
143
|
+
|
|
144
|
+
// For a chained command, classify each segment and take the riskiest —
|
|
145
|
+
// never let a safe-looking prefix mask `&& npm install` or worse.
|
|
146
|
+
if (/&&|\|\||;|\|(?!\|)/.test(cmd)) {
|
|
147
|
+
const segments = splitShellSegments(cmd);
|
|
148
|
+
if (segments.length > 1) {
|
|
149
|
+
let worst = TIERS.SHELL_SAFE;
|
|
150
|
+
for (const seg of segments) {
|
|
151
|
+
const t = classifyShell(seg);
|
|
152
|
+
worst = riskier(worst, t);
|
|
153
|
+
if (worst === TIERS.SHELL_DANGEROUS) return worst;
|
|
154
|
+
}
|
|
155
|
+
return worst;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (SHELL_MEDIUM_RE.some(re => re.test(cmd))) return TIERS.SHELL_MEDIUM;
|
|
160
|
+
if (SHELL_SAFE_RE.some(re => re.test(cmd))) return TIERS.SHELL_SAFE;
|
|
161
|
+
return TIERS.SHELL_MEDIUM;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function splitShellSegments(cmd) {
|
|
165
|
+
// Split on top-level &&, ||, ;, | — naive but enough for the classifier.
|
|
166
|
+
return cmd.split(/&&|\|\||;|\|/).map(s => s.trim()).filter(Boolean);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const TIER_ORDER = [
|
|
170
|
+
TIERS.READ,
|
|
171
|
+
TIERS.SHELL_SAFE,
|
|
172
|
+
TIERS.LOCAL_EDIT,
|
|
173
|
+
TIERS.NETWORK,
|
|
174
|
+
TIERS.SHELL_MEDIUM,
|
|
175
|
+
TIERS.DESTRUCTIVE,
|
|
176
|
+
TIERS.SHELL_DANGEROUS,
|
|
177
|
+
];
|
|
178
|
+
|
|
179
|
+
function riskier(a, b) {
|
|
180
|
+
return TIER_ORDER.indexOf(b) > TIER_ORDER.indexOf(a) ? b : a;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ── Top-level classify ──────────────────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Classify a tool call into a risk tier. Always returns one of TIERS.
|
|
187
|
+
*
|
|
188
|
+
* @param {string} tool Tool name (e.g. 'shell', 'edit_file')
|
|
189
|
+
* @param {object} args Tool arguments (e.g. { command: 'rm -rf x' })
|
|
190
|
+
*/
|
|
191
|
+
export function classify(tool, args = {}) {
|
|
192
|
+
if (!tool) return TIERS.SHELL_MEDIUM;
|
|
193
|
+
|
|
194
|
+
if (READ_TOOLS.has(tool)) return TIERS.READ;
|
|
195
|
+
if (LOCAL_EDIT_TOOLS.has(tool)) return TIERS.LOCAL_EDIT;
|
|
196
|
+
if (DESTRUCTIVE_TOOLS.has(tool)) return TIERS.DESTRUCTIVE;
|
|
197
|
+
if (NETWORK_TOOLS.has(tool)) return TIERS.NETWORK;
|
|
198
|
+
|
|
199
|
+
if (tool === 'shell' || tool === 'run_tests' || tool === 'validate_build' || tool === 'lint_check') {
|
|
200
|
+
return classifyShell(args.command || args.cmd || '');
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Sub-agents inherit their parent's risk (read-ish by default).
|
|
204
|
+
if (['explore', 'plan', 'verify', 'debug', 'refactor', 'analyze_code'].includes(tool)) {
|
|
205
|
+
return TIERS.READ;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// MCP tools: assume network unless the name implies read.
|
|
209
|
+
if (tool.startsWith('mcp')) {
|
|
210
|
+
return /(?:read|get|list|search|describe|info|status)/i.test(tool) ? TIERS.READ : TIERS.NETWORK;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return TIERS.SHELL_MEDIUM;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Convenience: human label for a tier (used in approval prompts and the
|
|
218
|
+
* status bar). Returned strings already uppercased / hyphenated.
|
|
219
|
+
*/
|
|
220
|
+
export function label(tier) {
|
|
221
|
+
switch (tier) {
|
|
222
|
+
case TIERS.READ: return 'READ';
|
|
223
|
+
case TIERS.LOCAL_EDIT: return 'LOCAL-EDIT';
|
|
224
|
+
case TIERS.SHELL_SAFE: return 'SHELL-SAFE';
|
|
225
|
+
case TIERS.SHELL_MEDIUM: return 'SHELL-MEDIUM';
|
|
226
|
+
case TIERS.SHELL_DANGEROUS: return 'SHELL-DANGEROUS';
|
|
227
|
+
case TIERS.DESTRUCTIVE: return 'DESTRUCTIVE';
|
|
228
|
+
case TIERS.NETWORK: return 'NETWORK';
|
|
229
|
+
default: return String(tier || '').toUpperCase();
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Whether the tool needs an explicit human keystroke before running.
|
|
235
|
+
*/
|
|
236
|
+
export function requiresExplicitApproval(tier) {
|
|
237
|
+
return behavior(tier) === 'prompt-explicit';
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Whether the tier should auto-create an undo checkpoint before running.
|
|
242
|
+
*/
|
|
243
|
+
export function requiresCheckpoint(tier) {
|
|
244
|
+
return behavior(tier) === 'auto-with-undo';
|
|
245
|
+
}
|
|
@@ -93,14 +93,23 @@ export class TarangStreamClient {
|
|
|
93
93
|
};
|
|
94
94
|
if (this.token) headers['Authorization'] = `Bearer ${this.token}`;
|
|
95
95
|
|
|
96
|
+
// Abort controller so cancel() can break out of a stalled reader
|
|
97
|
+
// instead of waiting for the next SSE event to notice _cancelled.
|
|
98
|
+
this._abort = new AbortController();
|
|
99
|
+
|
|
96
100
|
let response;
|
|
97
101
|
try {
|
|
98
102
|
response = await fetch(url, {
|
|
99
103
|
method: 'POST',
|
|
100
104
|
headers,
|
|
101
105
|
body: JSON.stringify(body),
|
|
106
|
+
signal: this._abort.signal,
|
|
102
107
|
});
|
|
103
108
|
} catch (err) {
|
|
109
|
+
if (err.name === 'AbortError') {
|
|
110
|
+
yield { type: EVENT_TYPES.STATUS, data: { message: 'Cancelled by user.' } };
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
104
113
|
yield { type: EVENT_TYPES.ERROR, data: { message: `Network error: ${err.message}. Check your connection or use --local mode.`, fatal: true } };
|
|
105
114
|
return;
|
|
106
115
|
}
|
|
@@ -175,7 +184,15 @@ export class TarangStreamClient {
|
|
|
175
184
|
|
|
176
185
|
try {
|
|
177
186
|
while (true) {
|
|
178
|
-
|
|
187
|
+
let read;
|
|
188
|
+
try {
|
|
189
|
+
read = await reader.read();
|
|
190
|
+
} catch (err) {
|
|
191
|
+
// Aborted via cancel() — treat as a clean end-of-stream.
|
|
192
|
+
if (err && (err.name === 'AbortError' || this._cancelled)) break;
|
|
193
|
+
throw err;
|
|
194
|
+
}
|
|
195
|
+
const { done, value } = read;
|
|
179
196
|
if (done) break;
|
|
180
197
|
|
|
181
198
|
buffer += decoder.decode(value, { stream: true });
|
|
@@ -335,6 +352,7 @@ export class TarangStreamClient {
|
|
|
335
352
|
/** Cancel the current stream. */
|
|
336
353
|
async cancel() {
|
|
337
354
|
this._cancelled = true;
|
|
355
|
+
// Best-effort backend POST — the stream may already be torn down.
|
|
338
356
|
if (this.currentTaskId) {
|
|
339
357
|
try {
|
|
340
358
|
await fetch(`${this.baseUrl}/api/cancel/${this.currentTaskId}`, {
|
|
@@ -343,6 +361,11 @@ export class TarangStreamClient {
|
|
|
343
361
|
});
|
|
344
362
|
} catch { /* best effort */ }
|
|
345
363
|
}
|
|
364
|
+
// Force the in-flight SSE reader to abort so the REPL returns to the
|
|
365
|
+
// prompt immediately instead of waiting on a parked reader.read().
|
|
366
|
+
if (this._abort) {
|
|
367
|
+
try { this._abort.abort(); } catch {}
|
|
368
|
+
}
|
|
346
369
|
}
|
|
347
370
|
|
|
348
371
|
/** Pause the current stream. */
|
|
@@ -28,6 +28,7 @@ import { execSync } from 'node:child_process';
|
|
|
28
28
|
export function createToolExecutor({
|
|
29
29
|
projectRegistry = new ProjectRegistry(),
|
|
30
30
|
skillsLoader = new SkillsLoader().load(process.cwd()),
|
|
31
|
+
checkpoints = null,
|
|
31
32
|
} = {}) {
|
|
32
33
|
const occRegistry = createToolRegistry();
|
|
33
34
|
const skillTool = occRegistry.get('Skill');
|
|
@@ -90,6 +91,12 @@ export function createToolExecutor({
|
|
|
90
91
|
'.rs': (file) => `rustfmt --check "${file}" 2>&1`,
|
|
91
92
|
};
|
|
92
93
|
|
|
94
|
+
// tsc --pretty and eslint emit ANSI codes (including background-red
|
|
95
|
+
// highlights) which bleed when our renderer slices the first 80 chars.
|
|
96
|
+
// Strip color codes so the stored lint string is always plain text.
|
|
97
|
+
const ANSI_RE = /\x1b\[[0-9;]*[a-zA-Z]/g;
|
|
98
|
+
function stripAnsi(s) { return String(s || '').replace(ANSI_RE, ''); }
|
|
99
|
+
|
|
93
100
|
function autoLint(filePath) {
|
|
94
101
|
const ext = path.extname(filePath);
|
|
95
102
|
const cmdFn = LINT_COMMANDS[ext];
|
|
@@ -101,13 +108,14 @@ export function createToolExecutor({
|
|
|
101
108
|
timeout: 15_000,
|
|
102
109
|
cwd: process.cwd(),
|
|
103
110
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
111
|
+
env: { ...process.env, FORCE_COLOR: '0', NO_COLOR: '1', TERM: 'dumb' },
|
|
104
112
|
});
|
|
105
|
-
const trimmed = output.trim();
|
|
113
|
+
const trimmed = stripAnsi(output).trim();
|
|
106
114
|
if (!trimmed) return null;
|
|
107
115
|
return trimmed;
|
|
108
116
|
} catch (err) {
|
|
109
117
|
// Non-zero exit means lint errors found
|
|
110
|
-
const output = (err.stderr || err.stdout || '').trim();
|
|
118
|
+
const output = stripAnsi(err.stderr || err.stdout || '').trim();
|
|
111
119
|
if (!output) return null;
|
|
112
120
|
return output;
|
|
113
121
|
}
|
|
@@ -132,6 +140,30 @@ export function createToolExecutor({
|
|
|
132
140
|
return parts.length ? `\n--- Verify ---\n${parts.join('\n')}` : '';
|
|
133
141
|
}
|
|
134
142
|
|
|
143
|
+
// ── Solution nudge after exploration ───────────────────────
|
|
144
|
+
// After the agent has read enough code, nudge it to formulate
|
|
145
|
+
// a solution based on the goal — not to blindly edit, but to
|
|
146
|
+
// synthesize what it learned into a fix approach.
|
|
147
|
+
let _codeReadsCount = 0;
|
|
148
|
+
let _hasEdited = false;
|
|
149
|
+
|
|
150
|
+
function solutionNudge(filePath) {
|
|
151
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
152
|
+
const isCode = ['.py', '.js', '.ts', '.tsx', '.mjs', '.go', '.rs', '.java', '.rb'].includes(ext);
|
|
153
|
+
if (!isCode || _hasEdited) return '';
|
|
154
|
+
|
|
155
|
+
_codeReadsCount++;
|
|
156
|
+
if (_codeReadsCount < 4) return '';
|
|
157
|
+
|
|
158
|
+
// Only nudge once at threshold, not every read after
|
|
159
|
+
if (_codeReadsCount === 4) {
|
|
160
|
+
return '\n\n--- You have explored enough code to formulate a solution. ' +
|
|
161
|
+
'Based on what you have read, determine the fix and apply it. ' +
|
|
162
|
+
'If the approach is unclear, call plan() with your findings. ---';
|
|
163
|
+
}
|
|
164
|
+
return '';
|
|
165
|
+
}
|
|
166
|
+
|
|
135
167
|
// ── Tool mapping table ──────────────────────────────────────
|
|
136
168
|
|
|
137
169
|
const toolMap = {
|
|
@@ -258,10 +290,11 @@ export function createToolExecutor({
|
|
|
258
290
|
});
|
|
259
291
|
const output = typeof result === 'string' ? result : String(result);
|
|
260
292
|
const content = output.replace(/^\s*\d+[→\t]/gm, '');
|
|
293
|
+
const actNudge = solutionNudge(filePath);
|
|
261
294
|
return {
|
|
262
295
|
success: !isError(output),
|
|
263
296
|
content,
|
|
264
|
-
output: output + nudge,
|
|
297
|
+
output: output + nudge + actNudge,
|
|
265
298
|
_tool: 'read_file',
|
|
266
299
|
_output_type: 'file_content',
|
|
267
300
|
};
|
|
@@ -284,6 +317,10 @@ export function createToolExecutor({
|
|
|
284
317
|
await occRegistry.call('Read', { file_path: filePath, limit: 1 });
|
|
285
318
|
}
|
|
286
319
|
} catch { /* file may not exist yet */ }
|
|
320
|
+
// Checkpoint before overwrite so /undo can restore the previous content.
|
|
321
|
+
if (checkpoints && fs.existsSync(filePath)) {
|
|
322
|
+
try { checkpoints.save(filePath); } catch { /* best effort */ }
|
|
323
|
+
}
|
|
287
324
|
const result = await occRegistry.call('Write', {
|
|
288
325
|
file_path: filePath,
|
|
289
326
|
content: args.content,
|
|
@@ -380,6 +417,11 @@ export function createToolExecutor({
|
|
|
380
417
|
await occRegistry.call('Read', { file_path: filePath, limit: 1 });
|
|
381
418
|
} catch { /* best effort */ }
|
|
382
419
|
|
|
420
|
+
// Checkpoint before edit so /undo can restore the previous content.
|
|
421
|
+
if (checkpoints) {
|
|
422
|
+
try { checkpoints.save(filePath); } catch { /* best effort */ }
|
|
423
|
+
}
|
|
424
|
+
|
|
383
425
|
let result;
|
|
384
426
|
try {
|
|
385
427
|
result = await occRegistry.call('Edit', {
|
|
@@ -418,6 +460,7 @@ print('OK: replaced')
|
|
|
418
460
|
|
|
419
461
|
const wrapped = wrapResult(result, 'edit_file');
|
|
420
462
|
updateProjectIndex(filePath);
|
|
463
|
+
_hasEdited = true;
|
|
421
464
|
|
|
422
465
|
// Auto-lint the edited file
|
|
423
466
|
const lintOutput = autoLint(filePath);
|
|
@@ -484,9 +527,16 @@ print('OK: replaced')
|
|
|
484
527
|
}
|
|
485
528
|
} catch { /* rg not found or no results */ }
|
|
486
529
|
|
|
487
|
-
// Layer 2:
|
|
530
|
+
// Layer 2: Symbol search — AST-extracted functions/classes with signatures
|
|
488
531
|
if (project?.retriever) {
|
|
489
532
|
if (!project.retriever.index) project.retriever.loadIndex();
|
|
533
|
+
const symbols = project.retriever.searchSymbols(query, 5);
|
|
534
|
+
if (symbols.length > 0) {
|
|
535
|
+
const symOutput = project.retriever.formatSymbolResults(symbols);
|
|
536
|
+
parts.push(`## Symbols (functions/classes)\n${symOutput}`);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// Layer 3: BM25 chunks — broader context when symbols aren't enough
|
|
490
540
|
const chunks = project.retriever.retrieve(query, 5);
|
|
491
541
|
if (chunks.length > 0) {
|
|
492
542
|
const bm25Output = chunks.map(c => {
|
|
@@ -609,7 +659,7 @@ print('OK: replaced')
|
|
|
609
659
|
return { success: true, files: results, _tool: 'read_files' };
|
|
610
660
|
},
|
|
611
661
|
|
|
612
|
-
// 9. delete_file + safety check
|
|
662
|
+
// 9. delete_file + safety check + checkpoint for undo
|
|
613
663
|
delete_file: async (args) => {
|
|
614
664
|
try {
|
|
615
665
|
const filePath = resolvePath(args.file_path || args.path, args);
|
|
@@ -617,6 +667,9 @@ print('OK: replaced')
|
|
|
617
667
|
if (!delCheck.safe) {
|
|
618
668
|
return { success: false, output: `🛡️ BLOCKED: ${delCheck.reason}`, _tool: 'delete_file', _blocked: true };
|
|
619
669
|
}
|
|
670
|
+
if (checkpoints) {
|
|
671
|
+
try { checkpoints.save(filePath); } catch { /* best effort */ }
|
|
672
|
+
}
|
|
620
673
|
fs.unlinkSync(filePath);
|
|
621
674
|
updateProjectIndex(filePath);
|
|
622
675
|
return { success: true, message: `Deleted ${args.path}`, _tool: 'delete_file' };
|