codeep 2.21.0 → 2.22.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.
- package/dist/api/index.js +26 -19
- package/dist/config/index.d.ts +20 -0
- package/dist/config/index.js +32 -0
- package/dist/renderer/commands/registry.js +1 -0
- package/dist/renderer/commands.js +17 -0
- package/dist/utils/agent.d.ts +7 -0
- package/dist/utils/agent.js +98 -6
- package/dist/utils/auditLog.d.ts +93 -0
- package/dist/utils/auditLog.js +217 -0
- package/dist/utils/headlessReview.d.ts +9 -1
- package/dist/utils/headlessReview.js +77 -3
- package/dist/utils/reviewFix.d.ts +65 -0
- package/dist/utils/reviewFix.js +141 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/api/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as http from 'node:http';
|
|
2
2
|
import * as https from 'node:https';
|
|
3
|
-
import { config, getApiKey, resolveBaseUrl } from '../config/index.js';
|
|
3
|
+
import { config, getApiKey, resolveBaseUrl, describeUnsendableKey } from '../config/index.js';
|
|
4
4
|
import { withRetry, isNetworkError } from '../utils/retry.js';
|
|
5
5
|
import { checkApiRateLimit } from '../utils/ratelimit.js';
|
|
6
6
|
import { getProvider, getProviderBaseUrl, getProviderAuthHeader, usesMaxCompletionTokens, requiresDefaultTemperature, modelRejectsSamplingParams, reasoningParamsFor } from '../config/providers.js';
|
|
@@ -147,6 +147,28 @@ function parseApiError(status, body) {
|
|
|
147
147
|
const truncated = body.length > 200 ? body.slice(0, 200) + '...' : body;
|
|
148
148
|
return `${status} - ${truncated}`;
|
|
149
149
|
}
|
|
150
|
+
/**
|
|
151
|
+
* Put the key in the right header, refusing early if it cannot be sent.
|
|
152
|
+
*
|
|
153
|
+
* `fetch` throws "Cannot convert argument to a ByteString because the character
|
|
154
|
+
* at index N…" for a header value outside Latin-1, counting from the start of
|
|
155
|
+
* `Bearer <key>` — so the index points four characters left of where anyone
|
|
156
|
+
* would look, and the message never mentions the key at all. Worse, the caller
|
|
157
|
+
* treats it as a transient API error and retries twice more, which cannot
|
|
158
|
+
* possibly help.
|
|
159
|
+
*/
|
|
160
|
+
function applyAuthHeader(headers, apiKey, authHeader) {
|
|
161
|
+
const problem = describeUnsendableKey(apiKey);
|
|
162
|
+
if (problem) {
|
|
163
|
+
throw new ApiError(`This API key cannot be used: ${problem}. Re-copy it and run /login to set it again.`, 400);
|
|
164
|
+
}
|
|
165
|
+
if (authHeader === 'Bearer') {
|
|
166
|
+
headers['Authorization'] = `Bearer ${apiKey}`;
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
headers['x-api-key'] = apiKey;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
150
172
|
export async function chat(message, history = [], onChunk, onRetry, projectContext, abortSignal) {
|
|
151
173
|
// Update project context if provided
|
|
152
174
|
if (projectContext !== undefined) {
|
|
@@ -377,12 +399,7 @@ async function chatOpenAI(message, history, model, apiKey, onChunk, abortSignal)
|
|
|
377
399
|
const headers = {
|
|
378
400
|
'Content-Type': 'application/json',
|
|
379
401
|
};
|
|
380
|
-
|
|
381
|
-
headers['Authorization'] = `Bearer ${apiKey}`;
|
|
382
|
-
}
|
|
383
|
-
else {
|
|
384
|
-
headers['x-api-key'] = apiKey;
|
|
385
|
-
}
|
|
402
|
+
applyAuthHeader(headers, apiKey, authHeader);
|
|
386
403
|
// OpenRouter: branding headers + opt in to `usage.cost` so the
|
|
387
404
|
// chat path reports authoritative per-call cost just like agentChat
|
|
388
405
|
// does. Kept identical to the agentChat block so the two paths stay
|
|
@@ -650,12 +667,7 @@ async function chatAnthropic(message, history, model, apiKey, onChunk, abortSign
|
|
|
650
667
|
'Content-Type': 'application/json',
|
|
651
668
|
'anthropic-version': '2023-06-01',
|
|
652
669
|
};
|
|
653
|
-
|
|
654
|
-
headers['Authorization'] = `Bearer ${apiKey}`;
|
|
655
|
-
}
|
|
656
|
-
else {
|
|
657
|
-
headers['x-api-key'] = apiKey;
|
|
658
|
-
}
|
|
670
|
+
applyAuthHeader(headers, apiKey, authHeader);
|
|
659
671
|
try {
|
|
660
672
|
// Anthropic prompt caching: wrap system as an array with a
|
|
661
673
|
// `cache_control` marker so the static system prompt (typically large
|
|
@@ -797,12 +809,7 @@ export async function validateApiKey(apiKey, providerId) {
|
|
|
797
809
|
const headers = {
|
|
798
810
|
'Content-Type': 'application/json',
|
|
799
811
|
};
|
|
800
|
-
|
|
801
|
-
headers['Authorization'] = `Bearer ${apiKey}`;
|
|
802
|
-
}
|
|
803
|
-
else {
|
|
804
|
-
headers['x-api-key'] = apiKey;
|
|
805
|
-
}
|
|
812
|
+
applyAuthHeader(headers, apiKey, authHeader);
|
|
806
813
|
if (protocol === 'anthropic') {
|
|
807
814
|
headers['anthropic-version'] = '2023-06-01';
|
|
808
815
|
}
|
package/dist/config/index.d.ts
CHANGED
|
@@ -37,6 +37,12 @@ export interface ConfigSchema {
|
|
|
37
37
|
* user (reply language, style, stack, preferences). Default true; set false
|
|
38
38
|
* to keep the profile files but stop injecting them. Managed via `/me`. */
|
|
39
39
|
userProfile: boolean;
|
|
40
|
+
/** Append a record of what each agent run touched to `.codeep/audit/`.
|
|
41
|
+
* Reads and refusals included — `history.ts` records neither, because it
|
|
42
|
+
* exists to undo writes rather than to say what happened. On unless set
|
|
43
|
+
* false: a record you must remember to enable is not one you can rely on
|
|
44
|
+
* having when you need it. */
|
|
45
|
+
auditLog: boolean;
|
|
40
46
|
/** Auto-learn: at session save, run one LLM pass to extract durable facts /
|
|
41
47
|
* preferences about the user and merge them into `~/.codeep/profile.learned.md`
|
|
42
48
|
* (injected alongside the hand-written profile). OFF by default — opt in via
|
|
@@ -318,3 +324,17 @@ export declare function loadProfile(name: string): Profile | null;
|
|
|
318
324
|
export declare function applyProfile(profile: Profile): void;
|
|
319
325
|
export declare function listProfiles(): string[];
|
|
320
326
|
export declare function deleteProfile(name: string): boolean;
|
|
327
|
+
/**
|
|
328
|
+
* Whether a key can survive being put in an HTTP header.
|
|
329
|
+
*
|
|
330
|
+
* `fetch` encodes header values as Latin-1 and throws "Cannot convert argument
|
|
331
|
+
* to a ByteString" on anything outside it. A key pasted from a web page or a
|
|
332
|
+
* chat message can pick up a non-breaking space, a zero-width character or a
|
|
333
|
+
* curly quote, and the resulting failure names neither the key nor the
|
|
334
|
+
* character — only "the character at index N", counted across the whole header
|
|
335
|
+
* value with `Bearer ` included, which is not where anyone would look.
|
|
336
|
+
*
|
|
337
|
+
* Returns null when the key is fine, or a description that locates the problem
|
|
338
|
+
* without ever reproducing the key itself.
|
|
339
|
+
*/
|
|
340
|
+
export declare function describeUnsendableKey(apiKey: string): string | null;
|
package/dist/config/index.js
CHANGED
|
@@ -175,6 +175,7 @@ function createConfig() {
|
|
|
175
175
|
autoSessionTitle: true,
|
|
176
176
|
autoSummarizeHistory: true,
|
|
177
177
|
userProfile: true,
|
|
178
|
+
auditLog: true,
|
|
178
179
|
autoLearnProfile: false,
|
|
179
180
|
trustedHookProjects: [],
|
|
180
181
|
currentSessionId: '',
|
|
@@ -1264,3 +1265,34 @@ export function deleteProfile(name) {
|
|
|
1264
1265
|
return false;
|
|
1265
1266
|
}
|
|
1266
1267
|
}
|
|
1268
|
+
/**
|
|
1269
|
+
* Whether a key can survive being put in an HTTP header.
|
|
1270
|
+
*
|
|
1271
|
+
* `fetch` encodes header values as Latin-1 and throws "Cannot convert argument
|
|
1272
|
+
* to a ByteString" on anything outside it. A key pasted from a web page or a
|
|
1273
|
+
* chat message can pick up a non-breaking space, a zero-width character or a
|
|
1274
|
+
* curly quote, and the resulting failure names neither the key nor the
|
|
1275
|
+
* character — only "the character at index N", counted across the whole header
|
|
1276
|
+
* value with `Bearer ` included, which is not where anyone would look.
|
|
1277
|
+
*
|
|
1278
|
+
* Returns null when the key is fine, or a description that locates the problem
|
|
1279
|
+
* without ever reproducing the key itself.
|
|
1280
|
+
*/
|
|
1281
|
+
export function describeUnsendableKey(apiKey) {
|
|
1282
|
+
for (let i = 0; i < apiKey.length; i++) {
|
|
1283
|
+
const code = apiKey.charCodeAt(i);
|
|
1284
|
+
if (code > 0xFF) {
|
|
1285
|
+
const name = code === 0x200B ? 'a zero-width space'
|
|
1286
|
+
: code === 0x2018 || code === 0x2019 ? 'a curly quote'
|
|
1287
|
+
: code === 0x201C || code === 0x201D ? 'a curly double quote'
|
|
1288
|
+
: `U+${code.toString(16).toUpperCase().padStart(4, '0')}`;
|
|
1289
|
+
return `character ${i + 1} of the key is ${name}, which cannot be sent in an HTTP header`;
|
|
1290
|
+
}
|
|
1291
|
+
// 0xA0 is inside Latin-1 and technically sendable, but a non-breaking space
|
|
1292
|
+
// in a key is never intentional and produces a 401 that reads as a bad key.
|
|
1293
|
+
if (code === 0xA0) {
|
|
1294
|
+
return `character ${i + 1} of the key is a non-breaking space — probably picked up when copying`;
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
return null;
|
|
1298
|
+
}
|
|
@@ -205,6 +205,7 @@ export const COMMANDS = [
|
|
|
205
205
|
usage: ['init [project]', 'learn [on|off]', 'sync', 'off', 'forget'],
|
|
206
206
|
},
|
|
207
207
|
{ name: 'agents', description: 'List sub-agents the agent can delegate to (researcher / reviewer / tester / your own)', category: 'settings' },
|
|
208
|
+
{ name: 'audit', description: 'What agents did in this project — runs, tools used, and anything the boundary refused', category: 'settings', usage: ['on', 'off'] },
|
|
208
209
|
{ name: 'insights', description: 'Activity summary — runs, files, tools, projects over the last N days (default 7)', category: 'settings', usage: ['--days N'] },
|
|
209
210
|
{ name: 'openrouter', description: 'OpenRouter routing prefs (prefer/ignore providers, fallbacks, privacy)', category: 'settings' },
|
|
210
211
|
// ── extensions & mcp ───────────────────────────────────────────────────────
|
|
@@ -445,6 +445,23 @@ export async function handleCommand(command, args, ctx) {
|
|
|
445
445
|
});
|
|
446
446
|
break;
|
|
447
447
|
}
|
|
448
|
+
case 'audit': {
|
|
449
|
+
const { formatAuditLog } = await import('../utils/auditLog.js');
|
|
450
|
+
const sub = args[0]?.toLowerCase();
|
|
451
|
+
if (sub === 'on' || sub === 'off') {
|
|
452
|
+
config.set('auditLog', sub === 'on');
|
|
453
|
+
ctx.app.notify(sub === 'on'
|
|
454
|
+
? 'Audit recording on — runs are recorded to .codeep/audit/.'
|
|
455
|
+
: 'Audit recording off. Records already written are kept.');
|
|
456
|
+
break;
|
|
457
|
+
}
|
|
458
|
+
if (sub) {
|
|
459
|
+
ctx.app.notify(`Unknown audit subcommand: ${sub}. Use /audit, /audit on, or /audit off`);
|
|
460
|
+
break;
|
|
461
|
+
}
|
|
462
|
+
ctx.app.addMessage({ role: 'system', content: formatAuditLog(ctx.projectPath) });
|
|
463
|
+
break;
|
|
464
|
+
}
|
|
448
465
|
case 'agents': {
|
|
449
466
|
// List sub-agents the agent can `delegate` to (built-in + .codeep/agents/).
|
|
450
467
|
const { formatAgentList } = await import('../utils/agents.js');
|
package/dist/utils/agent.d.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { ProjectContext } from './project';
|
|
7
7
|
import { loadProjectRules, loadProgressLog, writeProgressLog, formatChatHistoryForAgent } from './agentChat';
|
|
8
8
|
import type { AgentChatResponse } from './agentChat';
|
|
9
|
+
import { type Personality } from './personalities';
|
|
9
10
|
export { loadProjectRules, loadProgressLog, writeProgressLog, formatChatHistoryForAgent };
|
|
10
11
|
export type { AgentChatResponse };
|
|
11
12
|
import { ToolCall, ToolResult, ActionLog } from './tools';
|
|
@@ -83,6 +84,12 @@ export interface AgentOptions {
|
|
|
83
84
|
* sub-agent's tool actions still record into the parent's session, so undo
|
|
84
85
|
* spans delegation. */
|
|
85
86
|
nested?: boolean;
|
|
87
|
+
/** Run under this capability boundary instead of whatever the user has
|
|
88
|
+
* selected. Used by non-interactive callers that must pin the boundary
|
|
89
|
+
* themselves — a CI fix, for example, runs files+tests regardless of the
|
|
90
|
+
* machine's active bot. Enforced by the same gate as any other bot; this
|
|
91
|
+
* chooses which one applies, never whether one does. */
|
|
92
|
+
personalityOverride?: Personality;
|
|
86
93
|
/** Delegation depth. 0 = top-level orchestrator (gets the `delegate` tool);
|
|
87
94
|
* sub-agents run at depth 1 and cannot delegate further (v1). */
|
|
88
95
|
depth?: number;
|
package/dist/utils/agent.js
CHANGED
|
@@ -14,6 +14,7 @@ const debug = (...args) => {
|
|
|
14
14
|
import { agentChat, getAgentSystemPrompt, getFallbackSystemPrompt, loadProjectRules, loadProgressLog, writeProgressLog, formatChatHistoryForAgent, summarizeEarlierHistory, } from './agentChat.js';
|
|
15
15
|
import { ApiError } from '../api/index.js';
|
|
16
16
|
import { loadUserProfilePrompt } from './userProfile.js';
|
|
17
|
+
import { beginAuditRun, endAuditRun, recordAuditEvent, describeAuditTarget } from './auditLog.js';
|
|
17
18
|
import { getActivePersonality, getPersonalityToolAllowlist, isPersonalityToolCallAllowed, resolvePersonalityRuntimeModel, } from './personalities.js';
|
|
18
19
|
export { loadProjectRules, loadProgressLog, writeProgressLog, formatChatHistoryForAgent };
|
|
19
20
|
/**
|
|
@@ -172,6 +173,30 @@ const DEFAULT_OPTIONS = {
|
|
|
172
173
|
maxDuration: 20 * 60 * 1000, // 20 minutes
|
|
173
174
|
usePlanning: false, // Disable task planning - causes more problems than it solves
|
|
174
175
|
};
|
|
176
|
+
/**
|
|
177
|
+
* Sleep that gives up when the run is stopped.
|
|
178
|
+
*
|
|
179
|
+
* A plain `setTimeout` promise ignores the abort signal, so pressing Stop
|
|
180
|
+
* during "retrying in 10s" did nothing until the wait expired — and then the
|
|
181
|
+
* loop went on to retry anyway. Ctrl-C behaved the same way, because both
|
|
182
|
+
* routes set the same signal that nothing was reading.
|
|
183
|
+
*
|
|
184
|
+
* Resolves early on abort. Callers must still check `aborted` afterwards; this
|
|
185
|
+
* only stops the waiting, it does not decide what to do next.
|
|
186
|
+
*/
|
|
187
|
+
function abortableSleep(ms, signal) {
|
|
188
|
+
if (signal?.aborted)
|
|
189
|
+
return Promise.resolve();
|
|
190
|
+
return new Promise(resolve => {
|
|
191
|
+
const timer = setTimeout(finish, ms);
|
|
192
|
+
function finish() {
|
|
193
|
+
clearTimeout(timer);
|
|
194
|
+
signal?.removeEventListener('abort', finish);
|
|
195
|
+
resolve();
|
|
196
|
+
}
|
|
197
|
+
signal?.addEventListener('abort', finish, { once: true });
|
|
198
|
+
});
|
|
199
|
+
}
|
|
175
200
|
/**
|
|
176
201
|
* Run the agent loop
|
|
177
202
|
*/
|
|
@@ -190,7 +215,7 @@ export async function runAgent(prompt, projectContext, options = {}) {
|
|
|
190
215
|
const messages = [];
|
|
191
216
|
// A structured custom bot is resolved once per run. This keeps a cloud sync
|
|
192
217
|
// or file edit from changing policy halfway through an in-flight request.
|
|
193
|
-
const activePersonality = getActivePersonality(projectContext.root);
|
|
218
|
+
const activePersonality = opts.personalityOverride ?? getActivePersonality(projectContext.root);
|
|
194
219
|
const currentRuntime = {
|
|
195
220
|
providerId: String(config.get('provider')),
|
|
196
221
|
model: String(config.get('model')),
|
|
@@ -208,8 +233,18 @@ export async function runAgent(prompt, projectContext, options = {}) {
|
|
|
208
233
|
// The return value is unused — startSession's point here is the side
|
|
209
234
|
// effect of opening the history session. Binding it hid that from
|
|
210
235
|
// noUnusedLocals, so the dead binding is gone and the call stays.
|
|
236
|
+
const auditRoot = projectContext.root || process.cwd();
|
|
211
237
|
if (!opts.nested)
|
|
212
|
-
startSession(prompt,
|
|
238
|
+
startSession(prompt, auditRoot);
|
|
239
|
+
// A delegated sub-agent records into the parent's run for the same reason it
|
|
240
|
+
// shares the parent's history session: its actions are part of what the run
|
|
241
|
+
// did, not a separate story. Only the top-level call opens a run.
|
|
242
|
+
let auditFailure;
|
|
243
|
+
const auditRun = opts.nested ? '' : beginAuditRun(auditRoot, {
|
|
244
|
+
prompt,
|
|
245
|
+
agent: activePersonality?.displayName,
|
|
246
|
+
capabilities: activePersonality?.declaredTools,
|
|
247
|
+
});
|
|
213
248
|
// Task planning phase (if enabled)
|
|
214
249
|
// Use planning for complex keywords or multi-word prompts
|
|
215
250
|
let taskPlan = null;
|
|
@@ -431,6 +466,9 @@ export async function runAgent(prompt, projectContext, options = {}) {
|
|
|
431
466
|
messages.push({ role: 'user', content: initialPrompt });
|
|
432
467
|
let iteration = 0;
|
|
433
468
|
let finalResponse = '';
|
|
469
|
+
// Initialised rather than merely declared: the `finally` reads it to decide
|
|
470
|
+
// the audit outcome, and TypeScript is right that a throw before assignment
|
|
471
|
+
// would leave it unset.
|
|
434
472
|
let result;
|
|
435
473
|
let consecutiveTimeouts = 0;
|
|
436
474
|
let incompleteWorkRetries = 0;
|
|
@@ -575,7 +613,7 @@ export async function runAgent(prompt, projectContext, options = {}) {
|
|
|
575
613
|
const totalTokensEstimate = messages.reduce((sum, m) => sum + Math.ceil(m.content.length / 4), 0);
|
|
576
614
|
const throttleMs = Math.min(Math.floor(totalTokensEstimate / 10000) * 1000, 5000);
|
|
577
615
|
if (throttleMs > 0)
|
|
578
|
-
await
|
|
616
|
+
await abortableSleep(throttleMs, opts.abortSignal);
|
|
579
617
|
}
|
|
580
618
|
// Compress messages if context window is getting full (silent)
|
|
581
619
|
const compressed = compressMessages(messages, actions);
|
|
@@ -668,7 +706,11 @@ export async function runAgent(prompt, projectContext, options = {}) {
|
|
|
668
706
|
break;
|
|
669
707
|
}
|
|
670
708
|
// Wait before retry (exponential backoff)
|
|
671
|
-
await
|
|
709
|
+
await abortableSleep(1000 * retryCount, opts.abortSignal);
|
|
710
|
+
// Stopping during a backoff must actually stop. Without this the
|
|
711
|
+
// wait ended and the loop retried the request the user cancelled.
|
|
712
|
+
if (opts.abortSignal?.aborted)
|
|
713
|
+
break;
|
|
672
714
|
continue;
|
|
673
715
|
}
|
|
674
716
|
// Don't retry on 4xx client errors except 429 (rate limit)
|
|
@@ -732,7 +774,12 @@ export async function runAgent(prompt, projectContext, options = {}) {
|
|
|
732
774
|
});
|
|
733
775
|
break; // Break retry loop, continue main loop
|
|
734
776
|
}
|
|
735
|
-
await
|
|
777
|
+
await abortableSleep(waitSec * 1000, opts.abortSignal);
|
|
778
|
+
// Stopping during a rate-limit wait must actually stop. Without
|
|
779
|
+
// this the wait ran to completion and the loop retried the request
|
|
780
|
+
// the user had already cancelled.
|
|
781
|
+
if (opts.abortSignal?.aborted)
|
|
782
|
+
break;
|
|
736
783
|
continue;
|
|
737
784
|
}
|
|
738
785
|
}
|
|
@@ -837,6 +884,13 @@ export async function runAgent(prompt, projectContext, options = {}) {
|
|
|
837
884
|
};
|
|
838
885
|
opts.onToolResult?.(denied, toolCall);
|
|
839
886
|
actions.push(createActionLog(toolCall, denied));
|
|
887
|
+
// The one event nothing recorded before. A boundary you cannot audit
|
|
888
|
+
// is a boundary you have to take on faith.
|
|
889
|
+
recordAuditEvent(auditRoot, {
|
|
890
|
+
ts: Date.now(), run: auditRun, tool: toolCall.tool, action: 'refused',
|
|
891
|
+
target: describeAuditTarget(toolCall), outcome: 'refused',
|
|
892
|
+
detail: `blocked by custom bot "${activePersonality.displayName}"; allowed: ${allowed}`,
|
|
893
|
+
});
|
|
840
894
|
toolResults.push(`Tool ${toolCall.tool} is blocked by the active custom bot. Allowed capabilities: ${allowed}.`);
|
|
841
895
|
continue;
|
|
842
896
|
}
|
|
@@ -944,6 +998,14 @@ export async function runAgent(prompt, projectContext, options = {}) {
|
|
|
944
998
|
// Log action
|
|
945
999
|
const actionLog = createActionLog(toolCall, toolResult);
|
|
946
1000
|
actions.push(actionLog);
|
|
1001
|
+
// createActionLog already classified this; reuse its verdict rather than
|
|
1002
|
+
// re-deriving the action type in a second place that could drift.
|
|
1003
|
+
recordAuditEvent(auditRoot, {
|
|
1004
|
+
ts: Date.now(), run: auditRun, tool: toolCall.tool, action: actionLog.type,
|
|
1005
|
+
target: describeAuditTarget(toolCall),
|
|
1006
|
+
outcome: toolResult.success ? 'ok' : 'error',
|
|
1007
|
+
detail: toolResult.success ? undefined : toolResult.error,
|
|
1008
|
+
});
|
|
947
1009
|
// ── Infinite loop detection for write/edit ──────────────────────────
|
|
948
1010
|
if (toolCall.tool === 'write_file' || toolCall.tool === 'edit_file') {
|
|
949
1011
|
const filePath = toolCall.parameters.path || '';
|
|
@@ -1125,6 +1187,13 @@ export async function runAgent(prompt, projectContext, options = {}) {
|
|
|
1125
1187
|
};
|
|
1126
1188
|
opts.onToolResult?.(denied, toolCall);
|
|
1127
1189
|
actions.push(createActionLog(toolCall, denied));
|
|
1190
|
+
// The one event nothing recorded before. A boundary you cannot audit
|
|
1191
|
+
// is a boundary you have to take on faith.
|
|
1192
|
+
recordAuditEvent(auditRoot, {
|
|
1193
|
+
ts: Date.now(), run: auditRun, tool: toolCall.tool, action: 'refused',
|
|
1194
|
+
target: describeAuditTarget(toolCall), outcome: 'refused',
|
|
1195
|
+
detail: `blocked by custom bot "${activePersonality.displayName}"`,
|
|
1196
|
+
});
|
|
1128
1197
|
fixResults.push(`Tool ${toolCall.tool} blocked by the active custom bot.`);
|
|
1129
1198
|
continue;
|
|
1130
1199
|
}
|
|
@@ -1145,6 +1214,14 @@ export async function runAgent(prompt, projectContext, options = {}) {
|
|
|
1145
1214
|
opts.onToolResult?.(toolResult, toolCall);
|
|
1146
1215
|
const actionLog = createActionLog(toolCall, toolResult);
|
|
1147
1216
|
actions.push(actionLog);
|
|
1217
|
+
// createActionLog already classified this; reuse its verdict rather than
|
|
1218
|
+
// re-deriving the action type in a second place that could drift.
|
|
1219
|
+
recordAuditEvent(auditRoot, {
|
|
1220
|
+
ts: Date.now(), run: auditRun, tool: toolCall.tool, action: actionLog.type,
|
|
1221
|
+
target: describeAuditTarget(toolCall),
|
|
1222
|
+
outcome: toolResult.success ? 'ok' : 'error',
|
|
1223
|
+
detail: toolResult.success ? undefined : toolResult.error,
|
|
1224
|
+
});
|
|
1148
1225
|
if (toolResult.success) {
|
|
1149
1226
|
const truncated = truncateToolResult(toolResult.output, toolCall.tool);
|
|
1150
1227
|
fixResults.push(`Tool ${toolCall.tool} succeeded:\n${truncated}`);
|
|
@@ -1203,6 +1280,7 @@ export async function runAgent(prompt, projectContext, options = {}) {
|
|
|
1203
1280
|
}
|
|
1204
1281
|
catch (error) {
|
|
1205
1282
|
const err = error;
|
|
1283
|
+
auditFailure = err.message;
|
|
1206
1284
|
result = {
|
|
1207
1285
|
success: false,
|
|
1208
1286
|
iterations: iteration,
|
|
@@ -1215,8 +1293,22 @@ export async function runAgent(prompt, projectContext, options = {}) {
|
|
|
1215
1293
|
finally {
|
|
1216
1294
|
// End session and save history. Skipped for nested runs so we don't write
|
|
1217
1295
|
// a separate session file or null out the parent's open session.
|
|
1218
|
-
if (!opts.nested)
|
|
1296
|
+
if (!opts.nested) {
|
|
1219
1297
|
endSession();
|
|
1298
|
+
// In `finally`, so a run that throws still closes its record. An audit
|
|
1299
|
+
// trail that only survives success is worth very little — the runs you
|
|
1300
|
+
// most want to read are the ones that went wrong.
|
|
1301
|
+
//
|
|
1302
|
+
// The outcome comes from the result, not from whether an exception
|
|
1303
|
+
// escaped. Several failure paths — a user abort, a 4xx from the provider
|
|
1304
|
+
// — return `success: false` with a plain `return`, and reading only the
|
|
1305
|
+
// catch recorded those as successful runs. An audit record that says a
|
|
1306
|
+
// failed run passed is worse than having no record at all.
|
|
1307
|
+
const failed = auditFailure ?? (result === undefined || result.success
|
|
1308
|
+
? undefined
|
|
1309
|
+
: (result.aborted ? 'stopped by the user' : (result.error ?? 'run did not succeed')));
|
|
1310
|
+
endAuditRun(auditRoot, auditRun, failed ? 'error' : 'ok', failed);
|
|
1311
|
+
}
|
|
1220
1312
|
}
|
|
1221
1313
|
}
|
|
1222
1314
|
/**
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Audit record — what an agent actually touched.
|
|
3
|
+
*
|
|
4
|
+
* Distinct from `history.ts`, which exists to undo things: that journal keeps
|
|
5
|
+
* file contents so a write can be reversed, records only mutations, and lives
|
|
6
|
+
* in `~/.codeep/history/`. This one answers a different question — *what did
|
|
7
|
+
* this agent do to this project* — so it records reads and refusals too, keeps
|
|
8
|
+
* no file contents at all, and lives with the project it describes.
|
|
9
|
+
*
|
|
10
|
+
* The most valuable entry is the one nothing recorded before: a tool call the
|
|
11
|
+
* capability boundary refused. A boundary you cannot audit is a boundary you
|
|
12
|
+
* have to take on faith.
|
|
13
|
+
*
|
|
14
|
+
* Format is JSON Lines. Appending one line per event survives a crash mid-run,
|
|
15
|
+
* needs no read-modify-write, and stays greppable without a parser.
|
|
16
|
+
*
|
|
17
|
+
* PRIVACY: entries carry command lines and file paths, not file contents. A
|
|
18
|
+
* command can still contain a secret someone typed into it, exactly as shell
|
|
19
|
+
* history can — treat the directory like shell history, not like source.
|
|
20
|
+
*
|
|
21
|
+
* It sits under `.codeep/`, which most projects already ignore, but Codeep does
|
|
22
|
+
* not edit anyone's `.gitignore` and this module must not claim otherwise. If a
|
|
23
|
+
* project tracks `.codeep/`, the audit log will be committed with it.
|
|
24
|
+
*/
|
|
25
|
+
/** One thing an agent did, or was stopped from doing. */
|
|
26
|
+
export interface AuditEvent {
|
|
27
|
+
/** Epoch millis. */
|
|
28
|
+
ts: number;
|
|
29
|
+
/** Groups every event from one agent run. */
|
|
30
|
+
run: string;
|
|
31
|
+
/** Provider-facing tool name, e.g. `read_file`. Absent on run markers. */
|
|
32
|
+
tool?: string;
|
|
33
|
+
/** What kind of thing happened. `refused` is a boundary denial. */
|
|
34
|
+
action: 'run-start' | 'run-end' | 'read' | 'write' | 'edit' | 'delete' | 'command' | 'search' | 'list' | 'mkdir' | 'fetch' | 'refused';
|
|
35
|
+
/** File path, command line, or URL — truncated, never file contents. */
|
|
36
|
+
target?: string;
|
|
37
|
+
outcome?: 'ok' | 'error' | 'refused';
|
|
38
|
+
/** Short reason or note. Never a file body. */
|
|
39
|
+
detail?: string;
|
|
40
|
+
/** Run markers only: the active custom bot and what it was granted. */
|
|
41
|
+
agent?: string;
|
|
42
|
+
capabilities?: string[];
|
|
43
|
+
prompt?: string;
|
|
44
|
+
}
|
|
45
|
+
/** A one-line, content-free description of what a tool call was aimed at.
|
|
46
|
+
* Paths, commands and URLs are the point of the record; file bodies are not,
|
|
47
|
+
* and `content`/`old_string` style arguments are never read here. */
|
|
48
|
+
export declare function describeAuditTarget(call: {
|
|
49
|
+
tool: string;
|
|
50
|
+
parameters: Record<string, unknown>;
|
|
51
|
+
}): string;
|
|
52
|
+
/** Audit is on unless explicitly disabled. A record you have to remember to
|
|
53
|
+
* switch on is not a record you can rely on having. */
|
|
54
|
+
export declare function isAuditEnabled(): boolean;
|
|
55
|
+
/**
|
|
56
|
+
* Append one event. Never throws: an unwritable project (read-only checkout,
|
|
57
|
+
* full disk, a directory we lack permission for) must not take the agent down
|
|
58
|
+
* with it. A missing audit line is a gap in the record; a crashed run is worse.
|
|
59
|
+
*/
|
|
60
|
+
export declare function recordAuditEvent(projectRoot: string, event: AuditEvent): void;
|
|
61
|
+
/** Open a run and return its id. Records the agent and the capabilities it was
|
|
62
|
+
* granted, so a later reader can tell what the boundary *was* at the time —
|
|
63
|
+
* a bot edited afterwards must not rewrite the history of what it could do. */
|
|
64
|
+
export declare function beginAuditRun(projectRoot: string, opts: {
|
|
65
|
+
prompt: string;
|
|
66
|
+
agent?: string;
|
|
67
|
+
capabilities?: string[];
|
|
68
|
+
}): string;
|
|
69
|
+
export declare function endAuditRun(projectRoot: string, run: string, outcome: 'ok' | 'error', detail?: string): void;
|
|
70
|
+
/** One run, reassembled from its lines. */
|
|
71
|
+
export interface AuditRun {
|
|
72
|
+
run: string;
|
|
73
|
+
startedAt: number;
|
|
74
|
+
endedAt?: number;
|
|
75
|
+
prompt?: string;
|
|
76
|
+
agent?: string;
|
|
77
|
+
capabilities?: string[];
|
|
78
|
+
outcome?: 'ok' | 'error';
|
|
79
|
+
events: AuditEvent[];
|
|
80
|
+
refusals: number;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Read back the most recent runs, newest first.
|
|
84
|
+
*
|
|
85
|
+
* Malformed lines are skipped rather than throwing: an append-only log written
|
|
86
|
+
* by a process that may be killed mid-write will occasionally end in a partial
|
|
87
|
+
* line, and one torn line must not make the whole record unreadable.
|
|
88
|
+
*/
|
|
89
|
+
export declare function readAuditRuns(projectRoot: string, limit?: number): AuditRun[];
|
|
90
|
+
/** Render recent runs for `/audit`. Deliberately compact: the question this
|
|
91
|
+
* answers is "what has been happening here", and a wall of every event
|
|
92
|
+
* answers it worse than a summary with the refusals called out. */
|
|
93
|
+
export declare function formatAuditLog(projectRoot: string, limit?: number): string;
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Audit record — what an agent actually touched.
|
|
3
|
+
*
|
|
4
|
+
* Distinct from `history.ts`, which exists to undo things: that journal keeps
|
|
5
|
+
* file contents so a write can be reversed, records only mutations, and lives
|
|
6
|
+
* in `~/.codeep/history/`. This one answers a different question — *what did
|
|
7
|
+
* this agent do to this project* — so it records reads and refusals too, keeps
|
|
8
|
+
* no file contents at all, and lives with the project it describes.
|
|
9
|
+
*
|
|
10
|
+
* The most valuable entry is the one nothing recorded before: a tool call the
|
|
11
|
+
* capability boundary refused. A boundary you cannot audit is a boundary you
|
|
12
|
+
* have to take on faith.
|
|
13
|
+
*
|
|
14
|
+
* Format is JSON Lines. Appending one line per event survives a crash mid-run,
|
|
15
|
+
* needs no read-modify-write, and stays greppable without a parser.
|
|
16
|
+
*
|
|
17
|
+
* PRIVACY: entries carry command lines and file paths, not file contents. A
|
|
18
|
+
* command can still contain a secret someone typed into it, exactly as shell
|
|
19
|
+
* history can — treat the directory like shell history, not like source.
|
|
20
|
+
*
|
|
21
|
+
* It sits under `.codeep/`, which most projects already ignore, but Codeep does
|
|
22
|
+
* not edit anyone's `.gitignore` and this module must not claim otherwise. If a
|
|
23
|
+
* project tracks `.codeep/`, the audit log will be committed with it.
|
|
24
|
+
*/
|
|
25
|
+
import { existsSync, mkdirSync, appendFileSync, readFileSync, readdirSync, statSync } from 'fs';
|
|
26
|
+
import { join } from 'path';
|
|
27
|
+
import { randomBytes } from 'crypto';
|
|
28
|
+
import { config } from '../config/index.js';
|
|
29
|
+
/** A one-line, content-free description of what a tool call was aimed at.
|
|
30
|
+
* Paths, commands and URLs are the point of the record; file bodies are not,
|
|
31
|
+
* and `content`/`old_string` style arguments are never read here. */
|
|
32
|
+
export function describeAuditTarget(call) {
|
|
33
|
+
const p = call.parameters ?? {};
|
|
34
|
+
const str = (k) => (typeof p[k] === 'string' ? p[k] : undefined);
|
|
35
|
+
const command = str('command');
|
|
36
|
+
if (command) {
|
|
37
|
+
const args = Array.isArray(p.args) ? p.args.map(String) : [];
|
|
38
|
+
return [command, ...args].join(' ');
|
|
39
|
+
}
|
|
40
|
+
return str('path') ?? str('url') ?? str('pattern') ?? str('query') ?? str('directory') ?? call.tool;
|
|
41
|
+
}
|
|
42
|
+
const MAX_TARGET = 300;
|
|
43
|
+
const MAX_DETAIL = 200;
|
|
44
|
+
const MAX_PROMPT = 400;
|
|
45
|
+
function auditDir(projectRoot) {
|
|
46
|
+
return join(projectRoot, '.codeep', 'audit');
|
|
47
|
+
}
|
|
48
|
+
/** One file per day keeps a long-lived project from growing a single huge log
|
|
49
|
+
* while staying trivial to find — no index, no rotation logic. */
|
|
50
|
+
function auditFile(projectRoot) {
|
|
51
|
+
const day = new Date().toISOString().slice(0, 10);
|
|
52
|
+
return join(auditDir(projectRoot), `${day}.jsonl`);
|
|
53
|
+
}
|
|
54
|
+
function clip(value, max) {
|
|
55
|
+
if (value === undefined)
|
|
56
|
+
return undefined;
|
|
57
|
+
const flat = value.replace(/\s+/g, ' ').trim();
|
|
58
|
+
return flat.length > max ? flat.slice(0, max - 1) + '…' : flat;
|
|
59
|
+
}
|
|
60
|
+
/** Audit is on unless explicitly disabled. A record you have to remember to
|
|
61
|
+
* switch on is not a record you can rely on having. */
|
|
62
|
+
export function isAuditEnabled() {
|
|
63
|
+
return config.get('auditLog') !== false;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Append one event. Never throws: an unwritable project (read-only checkout,
|
|
67
|
+
* full disk, a directory we lack permission for) must not take the agent down
|
|
68
|
+
* with it. A missing audit line is a gap in the record; a crashed run is worse.
|
|
69
|
+
*/
|
|
70
|
+
export function recordAuditEvent(projectRoot, event) {
|
|
71
|
+
if (!isAuditEnabled())
|
|
72
|
+
return;
|
|
73
|
+
try {
|
|
74
|
+
const dir = auditDir(projectRoot);
|
|
75
|
+
if (!existsSync(dir))
|
|
76
|
+
mkdirSync(dir, { recursive: true });
|
|
77
|
+
const line = {
|
|
78
|
+
...event,
|
|
79
|
+
target: clip(event.target, MAX_TARGET),
|
|
80
|
+
detail: clip(event.detail, MAX_DETAIL),
|
|
81
|
+
prompt: clip(event.prompt, MAX_PROMPT),
|
|
82
|
+
};
|
|
83
|
+
// Drop undefined keys so a line stays small and diffs stay readable.
|
|
84
|
+
const compact = Object.fromEntries(Object.entries(line).filter(([, v]) => v !== undefined));
|
|
85
|
+
appendFileSync(auditFile(projectRoot), JSON.stringify(compact) + '\n');
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
/* an unwritable audit log must never fail the run */
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/** Open a run and return its id. Records the agent and the capabilities it was
|
|
92
|
+
* granted, so a later reader can tell what the boundary *was* at the time —
|
|
93
|
+
* a bot edited afterwards must not rewrite the history of what it could do. */
|
|
94
|
+
export function beginAuditRun(projectRoot, opts) {
|
|
95
|
+
const run = randomBytes(6).toString('hex');
|
|
96
|
+
recordAuditEvent(projectRoot, {
|
|
97
|
+
ts: Date.now(),
|
|
98
|
+
run,
|
|
99
|
+
action: 'run-start',
|
|
100
|
+
prompt: opts.prompt,
|
|
101
|
+
agent: opts.agent,
|
|
102
|
+
capabilities: opts.capabilities,
|
|
103
|
+
});
|
|
104
|
+
return run;
|
|
105
|
+
}
|
|
106
|
+
export function endAuditRun(projectRoot, run, outcome, detail) {
|
|
107
|
+
recordAuditEvent(projectRoot, { ts: Date.now(), run, action: 'run-end', outcome, detail });
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Read back the most recent runs, newest first.
|
|
111
|
+
*
|
|
112
|
+
* Malformed lines are skipped rather than throwing: an append-only log written
|
|
113
|
+
* by a process that may be killed mid-write will occasionally end in a partial
|
|
114
|
+
* line, and one torn line must not make the whole record unreadable.
|
|
115
|
+
*/
|
|
116
|
+
export function readAuditRuns(projectRoot, limit = 20) {
|
|
117
|
+
const dir = auditDir(projectRoot);
|
|
118
|
+
if (!existsSync(dir))
|
|
119
|
+
return [];
|
|
120
|
+
let files;
|
|
121
|
+
try {
|
|
122
|
+
files = readdirSync(dir)
|
|
123
|
+
.filter(f => f.endsWith('.jsonl'))
|
|
124
|
+
.sort()
|
|
125
|
+
.reverse();
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
return [];
|
|
129
|
+
}
|
|
130
|
+
const runs = new Map();
|
|
131
|
+
for (const file of files) {
|
|
132
|
+
let raw;
|
|
133
|
+
try {
|
|
134
|
+
const path = join(dir, file);
|
|
135
|
+
if (statSync(path).size > 8 * 1024 * 1024)
|
|
136
|
+
continue; // skip an absurd file
|
|
137
|
+
raw = readFileSync(path, 'utf8');
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
for (const line of raw.split('\n')) {
|
|
143
|
+
if (!line.trim())
|
|
144
|
+
continue;
|
|
145
|
+
let event;
|
|
146
|
+
try {
|
|
147
|
+
event = JSON.parse(line);
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if (!event || typeof event.run !== 'string' || typeof event.ts !== 'number')
|
|
153
|
+
continue;
|
|
154
|
+
let entry = runs.get(event.run);
|
|
155
|
+
if (!entry) {
|
|
156
|
+
entry = { run: event.run, startedAt: event.ts, events: [], refusals: 0 };
|
|
157
|
+
runs.set(event.run, entry);
|
|
158
|
+
}
|
|
159
|
+
if (event.action === 'run-start') {
|
|
160
|
+
entry.startedAt = event.ts;
|
|
161
|
+
entry.prompt = event.prompt;
|
|
162
|
+
entry.agent = event.agent;
|
|
163
|
+
entry.capabilities = event.capabilities;
|
|
164
|
+
}
|
|
165
|
+
else if (event.action === 'run-end') {
|
|
166
|
+
entry.endedAt = event.ts;
|
|
167
|
+
entry.outcome = event.outcome === 'error' ? 'error' : 'ok';
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
entry.events.push(event);
|
|
171
|
+
if (event.action === 'refused')
|
|
172
|
+
entry.refusals++;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
if (runs.size >= limit * 2)
|
|
176
|
+
break; // enough files read to satisfy `limit`
|
|
177
|
+
}
|
|
178
|
+
return [...runs.values()]
|
|
179
|
+
.sort((a, b) => b.startedAt - a.startedAt)
|
|
180
|
+
.slice(0, limit);
|
|
181
|
+
}
|
|
182
|
+
/** Render recent runs for `/audit`. Deliberately compact: the question this
|
|
183
|
+
* answers is "what has been happening here", and a wall of every event
|
|
184
|
+
* answers it worse than a summary with the refusals called out. */
|
|
185
|
+
export function formatAuditLog(projectRoot, limit = 10) {
|
|
186
|
+
const runs = readAuditRuns(projectRoot, limit);
|
|
187
|
+
if (runs.length === 0) {
|
|
188
|
+
return isAuditEnabled()
|
|
189
|
+
? 'No agent runs recorded in this project yet.\n\nThe record starts at the next run and lives in `.codeep/audit/`.'
|
|
190
|
+
: 'Audit recording is off for this project. Turn it on with `/audit on`.';
|
|
191
|
+
}
|
|
192
|
+
const lines = ['**Recent agent runs** — `.codeep/audit/`', ''];
|
|
193
|
+
for (const run of runs) {
|
|
194
|
+
const when = new Date(run.startedAt).toLocaleString();
|
|
195
|
+
const took = run.endedAt ? `${Math.max(1, Math.round((run.endedAt - run.startedAt) / 1000))}s` : 'unfinished';
|
|
196
|
+
const who = run.agent ? `**${run.agent}**` : 'default agent';
|
|
197
|
+
const grant = run.capabilities?.length ? ` (${run.capabilities.join(', ')})` : '';
|
|
198
|
+
const mark = run.outcome === 'error' ? '✗' : '✓';
|
|
199
|
+
lines.push(`${mark} ${when} · ${who}${grant} · ${took}`);
|
|
200
|
+
if (run.prompt)
|
|
201
|
+
lines.push(` ${run.prompt}`);
|
|
202
|
+
// Summarise by action so a hundred reads collapse to "read ×100".
|
|
203
|
+
const tally = new Map();
|
|
204
|
+
for (const e of run.events)
|
|
205
|
+
tally.set(e.action, (tally.get(e.action) ?? 0) + 1);
|
|
206
|
+
const summary = [...tally.entries()].map(([a, n]) => (n > 1 ? `${a} ×${n}` : a)).join(', ');
|
|
207
|
+
if (summary)
|
|
208
|
+
lines.push(` ${summary}`);
|
|
209
|
+
// Refusals are the reason this record exists, so they are never collapsed.
|
|
210
|
+
for (const e of run.events.filter(e => e.action === 'refused')) {
|
|
211
|
+
lines.push(` ⨯ refused: ${e.tool}${e.target ? ` → ${e.target}` : ''}`);
|
|
212
|
+
}
|
|
213
|
+
lines.push('');
|
|
214
|
+
}
|
|
215
|
+
lines.push('_Paths and commands only — file contents are never recorded._');
|
|
216
|
+
return lines.join('\n');
|
|
217
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type FixPlan } from './reviewFix.js';
|
|
1
2
|
import { ReviewResult } from './codeReview.js';
|
|
2
3
|
export type FailOn = 'error' | 'warning' | 'info' | 'none';
|
|
3
4
|
export interface ReviewArgs {
|
|
@@ -7,8 +8,12 @@ export interface ReviewArgs {
|
|
|
7
8
|
rules: boolean;
|
|
8
9
|
ai: boolean;
|
|
9
10
|
help: boolean;
|
|
11
|
+
/** Hand the findings to an agent and let it edit the working tree. */
|
|
12
|
+
fix: boolean;
|
|
13
|
+
/** Lowest severity the fix run may act on. Suggestions are never eligible. */
|
|
14
|
+
fixMinSeverity: 'error' | 'warning';
|
|
10
15
|
}
|
|
11
|
-
export declare const REVIEW_HELP = "Usage: codeep review [options] [files...]\n\nRun a deterministic, offline code review (no API key required). With no files,\nreviews your unstaged git changes, falling back to a src/ scan when the tree is\nclean. Pass files (or let your CI pass the PR's changed files) to scope it.\n\nCustom/disabled rules come from .codeep/review.yml (or .json) in the repo.\n\nOptions:\n --json Print the result as JSON instead of the markdown report\n --fail-on <level> Exit non-zero when an issue at or above <level> is found:\n error | warning | info | none (default: error)\n --rules List the built-in rule ids (for \"disable\" in .codeep/review.*) and exit\n --ai After the offline pass, ask your configured provider for a\n contextual second opinion on the working-tree diff\n (advisory; needs an API key; never affects the exit code)\n -h, --help Show this help\n\nExit code: 0 when nothing at/above --fail-on is found, 1 otherwise.";
|
|
16
|
+
export declare const REVIEW_HELP = "Usage: codeep review [options] [files...]\n\nRun a deterministic, offline code review (no API key required). With no files,\nreviews your unstaged git changes, falling back to a src/ scan when the tree is\nclean. Pass files (or let your CI pass the PR's changed files) to scope it.\n\nCustom/disabled rules come from .codeep/review.yml (or .json) in the repo.\n\nOptions:\n --json Print the result as JSON instead of the markdown report\n --fail-on <level> Exit non-zero when an issue at or above <level> is found:\n error | warning | info | none (default: error)\n --fix After the review, let an agent fix what it found. Edits the\n working tree and stops there \u2014 it never commits, branches\n or pushes. Runs under a files+tests boundary: no shell, no\n network, no git. Needs an API key.\n --fix-min-severity Lowest severity --fix may act on: error | warning\n (default: warning). Suggestions are never eligible.\n --rules List the built-in rule ids (for \"disable\" in .codeep/review.*) and exit\n --ai After the offline pass, ask your configured provider for a\n contextual second opinion on the working-tree diff\n (advisory; needs an API key; never affects the exit code)\n -h, --help Show this help\n\nExit code: 0 when nothing at/above --fail-on is found, 1 otherwise.";
|
|
12
17
|
/** Parse `codeep review` argv (everything after the subcommand). Pure. */
|
|
13
18
|
export declare function parseReviewArgs(argv: string[]): ReviewArgs;
|
|
14
19
|
/** Exit code for a result under a fail-on threshold. Pure. */
|
|
@@ -29,6 +34,9 @@ export interface ReviewDeps {
|
|
|
29
34
|
provider?: string;
|
|
30
35
|
model?: string;
|
|
31
36
|
};
|
|
37
|
+
/** Run an agent over a fix plan. Returns a human summary, or null when it
|
|
38
|
+
* could not run at all (no API key, provider unreachable). */
|
|
39
|
+
applyFixes: (plan: FixPlan) => Promise<string | null>;
|
|
32
40
|
}
|
|
33
41
|
/**
|
|
34
42
|
* Orchestrate a headless review and return the process exit code. Side effects
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { buildFixPlan, summariseFixPlan } from './reviewFix.js';
|
|
1
2
|
// Headless `codeep review` — a non-interactive entry point around the
|
|
2
3
|
// deterministic reviewer in codeReview.ts. No API key, no TUI: it scans, prints
|
|
3
4
|
// a report (markdown or JSON), and exits non-zero when issues at/above a chosen
|
|
@@ -24,6 +25,12 @@ Options:
|
|
|
24
25
|
--json Print the result as JSON instead of the markdown report
|
|
25
26
|
--fail-on <level> Exit non-zero when an issue at or above <level> is found:
|
|
26
27
|
error | warning | info | none (default: error)
|
|
28
|
+
--fix After the review, let an agent fix what it found. Edits the
|
|
29
|
+
working tree and stops there — it never commits, branches
|
|
30
|
+
or pushes. Runs under a files+tests boundary: no shell, no
|
|
31
|
+
network, no git. Needs an API key.
|
|
32
|
+
--fix-min-severity Lowest severity --fix may act on: error | warning
|
|
33
|
+
(default: warning). Suggestions are never eligible.
|
|
27
34
|
--rules List the built-in rule ids (for "disable" in .codeep/review.*) and exit
|
|
28
35
|
--ai After the offline pass, ask your configured provider for a
|
|
29
36
|
contextual second opinion on the working-tree diff
|
|
@@ -33,7 +40,10 @@ Options:
|
|
|
33
40
|
Exit code: 0 when nothing at/above --fail-on is found, 1 otherwise.`;
|
|
34
41
|
/** Parse `codeep review` argv (everything after the subcommand). Pure. */
|
|
35
42
|
export function parseReviewArgs(argv) {
|
|
36
|
-
const out = {
|
|
43
|
+
const out = {
|
|
44
|
+
files: [], json: false, failOn: 'error', rules: false, ai: false, help: false,
|
|
45
|
+
fix: false, fixMinSeverity: 'warning',
|
|
46
|
+
};
|
|
37
47
|
for (let i = 0; i < argv.length; i++) {
|
|
38
48
|
const arg = argv[i];
|
|
39
49
|
if (arg === '--json') {
|
|
@@ -45,6 +55,19 @@ export function parseReviewArgs(argv) {
|
|
|
45
55
|
else if (arg === '--ai') {
|
|
46
56
|
out.ai = true;
|
|
47
57
|
}
|
|
58
|
+
else if (arg === '--fix') {
|
|
59
|
+
out.fix = true;
|
|
60
|
+
}
|
|
61
|
+
else if (arg === '--fix-min-severity') {
|
|
62
|
+
const v = argv[++i];
|
|
63
|
+
if (v === 'error' || v === 'warning')
|
|
64
|
+
out.fixMinSeverity = v;
|
|
65
|
+
}
|
|
66
|
+
else if (arg.startsWith('--fix-min-severity=')) {
|
|
67
|
+
const v = arg.slice('--fix-min-severity='.length);
|
|
68
|
+
if (v === 'error' || v === 'warning')
|
|
69
|
+
out.fixMinSeverity = v;
|
|
70
|
+
}
|
|
48
71
|
else if (arg === '-h' || arg === '--help') {
|
|
49
72
|
out.help = true;
|
|
50
73
|
}
|
|
@@ -104,12 +127,26 @@ export async function runHeadlessReview(argv, deps = defaultDeps()) {
|
|
|
104
127
|
}
|
|
105
128
|
const result = deps.review(args.files.length ? args.files : undefined);
|
|
106
129
|
const aiText = args.ai ? await deps.aiReview(result) : null;
|
|
130
|
+
// Fixing happens after reporting, and never changes the exit code. CI decides
|
|
131
|
+
// pass or fail from what the reviewer found; whether an agent then managed to
|
|
132
|
+
// repair some of it is a separate question, and letting a successful fix turn
|
|
133
|
+
// a red check green would hide the finding rather than resolve it.
|
|
134
|
+
let fixSummary = null;
|
|
135
|
+
if (args.fix) {
|
|
136
|
+
const plan = buildFixPlan(result.issues, { minSeverity: args.fixMinSeverity });
|
|
137
|
+
fixSummary = plan.skipped ? summariseFixPlan(plan) : await deps.applyFixes(plan);
|
|
138
|
+
}
|
|
107
139
|
if (args.json) {
|
|
108
|
-
deps.write(JSON.stringify(
|
|
140
|
+
deps.write(JSON.stringify({
|
|
141
|
+
...result,
|
|
142
|
+
...(args.ai ? { aiReview: aiText } : {}),
|
|
143
|
+
...(args.fix ? { fix: fixSummary } : {}),
|
|
144
|
+
}, null, 2));
|
|
109
145
|
}
|
|
110
146
|
else {
|
|
111
147
|
const md = formatReviewResult(result);
|
|
112
|
-
|
|
148
|
+
const withAi = args.ai ? appendAiSection(md, aiText, deps.aiMeta()) : md;
|
|
149
|
+
deps.write(fixSummary ? `${withAi}\n\n## Fix run\n\n${fixSummary}\n` : withAi);
|
|
113
150
|
}
|
|
114
151
|
return exitCodeForResult(result, args.failOn);
|
|
115
152
|
}
|
|
@@ -132,6 +169,7 @@ function defaultDeps() {
|
|
|
132
169
|
review: (files) => performCodeReview(minimalContext(cwd), files),
|
|
133
170
|
write: (text) => process.stdout.write(text + '\n'),
|
|
134
171
|
listRules: () => formatBuiltinRules(),
|
|
172
|
+
applyFixes: (plan) => runFixPlan(plan, minimalContext(cwd)),
|
|
135
173
|
aiMeta: () => {
|
|
136
174
|
try {
|
|
137
175
|
return { provider: getCurrentProvider().name, model: String(config.get('model') || '') };
|
|
@@ -165,3 +203,39 @@ function defaultDeps() {
|
|
|
165
203
|
},
|
|
166
204
|
};
|
|
167
205
|
}
|
|
206
|
+
/**
|
|
207
|
+
* Run a fix plan through the agent.
|
|
208
|
+
*
|
|
209
|
+
* The plan's personality is passed as the active one, so the same enforcement
|
|
210
|
+
* any custom bot gets applies here: the model is offered `files` and `tests`
|
|
211
|
+
* and nothing else. It edits the working tree and stops — branching, committing
|
|
212
|
+
* and opening a pull request belong to whatever called this, which in CI is the
|
|
213
|
+
* action that holds the token.
|
|
214
|
+
*/
|
|
215
|
+
async function runFixPlan(plan, context) {
|
|
216
|
+
try {
|
|
217
|
+
const { runAgent } = await import('./agent.js');
|
|
218
|
+
// No cast here. `as never` on this call once hid the fact that
|
|
219
|
+
// personalityOverride did not exist, which would have run the CI fix with
|
|
220
|
+
// no boundary at all while the tests happily asserted otherwise.
|
|
221
|
+
const result = await runAgent(plan.prompt, context, {
|
|
222
|
+
personalityOverride: plan.personality,
|
|
223
|
+
maxIterations: 12,
|
|
224
|
+
});
|
|
225
|
+
const edited = new Set(result.actions
|
|
226
|
+
.filter(a => a.type === 'write' || a.type === 'edit')
|
|
227
|
+
.map(a => a.target));
|
|
228
|
+
if (!result.success) {
|
|
229
|
+
return `${summariseFixPlan(plan)} The run did not finish: ${result.error ?? 'unknown error'}.`;
|
|
230
|
+
}
|
|
231
|
+
if (edited.size === 0) {
|
|
232
|
+
return `${summariseFixPlan(plan)} Nothing was changed — the agent judged the findings not mechanically fixable.`;
|
|
233
|
+
}
|
|
234
|
+
return `${summariseFixPlan(plan)} Edited ${edited.size} file${edited.size === 1 ? '' : 's'}: ${[...edited].join(', ')}.`;
|
|
235
|
+
}
|
|
236
|
+
catch (error) {
|
|
237
|
+
// A missing key or an unreachable provider must not fail the review. The
|
|
238
|
+
// findings are already reported and the exit code already decided.
|
|
239
|
+
return `Could not run the fix: ${error.message}`;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deciding what a CI agent may attempt to fix, and under what boundary.
|
|
3
|
+
*
|
|
4
|
+
* The reviewer already finds problems. This decides which of them are worth
|
|
5
|
+
* handing to an agent, caps how much it may take on, and pins the capabilities
|
|
6
|
+
* it runs with. It deliberately stops there: it produces a plan, never a
|
|
7
|
+
* commit. Staging, branching and opening a pull request belong to the action,
|
|
8
|
+
* which has the token — and keeping git out of the agent's reach is half the
|
|
9
|
+
* reason this is safe to run in CI at all.
|
|
10
|
+
*
|
|
11
|
+
* The boundary is the point. A fix run gets `files` (it must edit) and `tests`
|
|
12
|
+
* (it must check its own work) and nothing else. No shell, no git, no network.
|
|
13
|
+
* Enforced by the same machinery as any other custom bot, so an agent that
|
|
14
|
+
* decides it would like to curl something simply has no tool to do it with.
|
|
15
|
+
*/
|
|
16
|
+
import type { ReviewIssue } from './codeReview.js';
|
|
17
|
+
import type { Personality } from './personalities.js';
|
|
18
|
+
export interface FixPlanOptions {
|
|
19
|
+
/** Lowest severity to attempt. Defaults to `warning`. */
|
|
20
|
+
minSeverity?: 'error' | 'warning';
|
|
21
|
+
/** Most issues to hand over in one run. */
|
|
22
|
+
maxIssues?: number;
|
|
23
|
+
/** Most files to touch. A fix that rewrites half the repo is not a fix. */
|
|
24
|
+
maxFiles?: number;
|
|
25
|
+
}
|
|
26
|
+
export interface FixPlan {
|
|
27
|
+
/** The issues the agent is being asked to address, in file order. */
|
|
28
|
+
issues: ReviewIssue[];
|
|
29
|
+
/** Files it is allowed to be working in. */
|
|
30
|
+
files: string[];
|
|
31
|
+
/** Why nothing is being attempted, when that is the case. */
|
|
32
|
+
skipped?: 'no-issues' | 'nothing-fixable';
|
|
33
|
+
/** The instruction handed to the agent. */
|
|
34
|
+
prompt: string;
|
|
35
|
+
/** The capability boundary the run executes under. */
|
|
36
|
+
personality: Personality;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* `suggestion` and `info` are opinion — style preferences, "consider extracting
|
|
40
|
+
* this". Acting on them unasked produces churn in someone else's pull request
|
|
41
|
+
* and buries the findings that matter. Only what the reviewer states as a
|
|
42
|
+
* defect is eligible.
|
|
43
|
+
*/
|
|
44
|
+
export declare function isFixable(issue: ReviewIssue, minSeverity: 'error' | 'warning'): boolean;
|
|
45
|
+
/**
|
|
46
|
+
* The capability set a CI fix runs under.
|
|
47
|
+
*
|
|
48
|
+
* Not a suggestion in the prompt — a real `custom-bot/v1` personality, enforced
|
|
49
|
+
* by `isPersonalityToolCallAllowed` and by the tool registry filter, exactly as
|
|
50
|
+
* a bot built in Agent Studio would be. An agent running unattended against
|
|
51
|
+
* someone else's repository is precisely where a boundary has to be real.
|
|
52
|
+
*/
|
|
53
|
+
export declare function ciFixPersonality(): Personality;
|
|
54
|
+
/**
|
|
55
|
+
* Turn a review into a bounded instruction, or decline.
|
|
56
|
+
*
|
|
57
|
+
* Caps matter more than they look. An agent handed sixty findings across forty
|
|
58
|
+
* files will produce a pull request nobody reviews, which is the same as no
|
|
59
|
+
* pull request — except it also burned tokens and someone's afternoon.
|
|
60
|
+
*/
|
|
61
|
+
export declare function buildFixPlan(issues: ReviewIssue[], options?: FixPlanOptions): FixPlan;
|
|
62
|
+
/** The instruction the agent receives: the findings, grouped by file. */
|
|
63
|
+
export declare function formatFixPrompt(issues: ReviewIssue[]): string;
|
|
64
|
+
/** A one-line summary for the pull request body the action opens. */
|
|
65
|
+
export declare function summariseFixPlan(plan: FixPlan): string;
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deciding what a CI agent may attempt to fix, and under what boundary.
|
|
3
|
+
*
|
|
4
|
+
* The reviewer already finds problems. This decides which of them are worth
|
|
5
|
+
* handing to an agent, caps how much it may take on, and pins the capabilities
|
|
6
|
+
* it runs with. It deliberately stops there: it produces a plan, never a
|
|
7
|
+
* commit. Staging, branching and opening a pull request belong to the action,
|
|
8
|
+
* which has the token — and keeping git out of the agent's reach is half the
|
|
9
|
+
* reason this is safe to run in CI at all.
|
|
10
|
+
*
|
|
11
|
+
* The boundary is the point. A fix run gets `files` (it must edit) and `tests`
|
|
12
|
+
* (it must check its own work) and nothing else. No shell, no git, no network.
|
|
13
|
+
* Enforced by the same machinery as any other custom bot, so an agent that
|
|
14
|
+
* decides it would like to curl something simply has no tool to do it with.
|
|
15
|
+
*/
|
|
16
|
+
/** Severities an agent may act on, in descending confidence. */
|
|
17
|
+
const FIXABLE_SEVERITIES = ['error', 'warning'];
|
|
18
|
+
const DEFAULTS = { minSeverity: 'warning', maxIssues: 20, maxFiles: 10 };
|
|
19
|
+
/**
|
|
20
|
+
* `suggestion` and `info` are opinion — style preferences, "consider extracting
|
|
21
|
+
* this". Acting on them unasked produces churn in someone else's pull request
|
|
22
|
+
* and buries the findings that matter. Only what the reviewer states as a
|
|
23
|
+
* defect is eligible.
|
|
24
|
+
*/
|
|
25
|
+
export function isFixable(issue, minSeverity) {
|
|
26
|
+
if (!FIXABLE_SEVERITIES.includes(issue.severity))
|
|
27
|
+
return false;
|
|
28
|
+
return minSeverity === 'warning' ? true : issue.severity === 'error';
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* The capability set a CI fix runs under.
|
|
32
|
+
*
|
|
33
|
+
* Not a suggestion in the prompt — a real `custom-bot/v1` personality, enforced
|
|
34
|
+
* by `isPersonalityToolCallAllowed` and by the tool registry filter, exactly as
|
|
35
|
+
* a bot built in Agent Studio would be. An agent running unattended against
|
|
36
|
+
* someone else's repository is precisely where a boundary has to be real.
|
|
37
|
+
*/
|
|
38
|
+
export function ciFixPersonality() {
|
|
39
|
+
return {
|
|
40
|
+
name: 'ci-fix',
|
|
41
|
+
displayName: 'CI Fix',
|
|
42
|
+
description: 'Applies review findings in CI. Files and tests only.',
|
|
43
|
+
prompt: [
|
|
44
|
+
'You are fixing problems a reviewer already found in a pull request.',
|
|
45
|
+
'',
|
|
46
|
+
'Rules:',
|
|
47
|
+
'- Fix only the issues listed. Do not refactor around them.',
|
|
48
|
+
'- Do not reformat untouched lines; the diff should read as a fix, not a rewrite.',
|
|
49
|
+
'- Run the project tests when you are done and fix what you broke.',
|
|
50
|
+
'- If an issue needs a judgement call you cannot make from the code, leave it and say so.',
|
|
51
|
+
].join('\n'),
|
|
52
|
+
scope: 'project',
|
|
53
|
+
structured: true,
|
|
54
|
+
schemaValid: true,
|
|
55
|
+
modelPreference: 'automatic',
|
|
56
|
+
restrictTools: true,
|
|
57
|
+
tools: ['files', 'tests'],
|
|
58
|
+
declaredTools: ['files', 'tests'],
|
|
59
|
+
projectScope: 'all',
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Turn a review into a bounded instruction, or decline.
|
|
64
|
+
*
|
|
65
|
+
* Caps matter more than they look. An agent handed sixty findings across forty
|
|
66
|
+
* files will produce a pull request nobody reviews, which is the same as no
|
|
67
|
+
* pull request — except it also burned tokens and someone's afternoon.
|
|
68
|
+
*/
|
|
69
|
+
export function buildFixPlan(issues, options = {}) {
|
|
70
|
+
const { minSeverity, maxIssues, maxFiles } = { ...DEFAULTS, ...options };
|
|
71
|
+
const personality = ciFixPersonality();
|
|
72
|
+
if (issues.length === 0) {
|
|
73
|
+
return { issues: [], files: [], skipped: 'no-issues', prompt: '', personality };
|
|
74
|
+
}
|
|
75
|
+
const eligible = issues
|
|
76
|
+
.filter(issue => isFixable(issue, minSeverity))
|
|
77
|
+
// Errors before warnings, then by file so one file's issues arrive together.
|
|
78
|
+
.sort((a, b) => {
|
|
79
|
+
if (a.severity !== b.severity)
|
|
80
|
+
return a.severity === 'error' ? -1 : 1;
|
|
81
|
+
return a.file.localeCompare(b.file) || (a.line ?? 0) - (b.line ?? 0);
|
|
82
|
+
});
|
|
83
|
+
if (eligible.length === 0) {
|
|
84
|
+
return { issues: [], files: [], skipped: 'nothing-fixable', prompt: '', personality };
|
|
85
|
+
}
|
|
86
|
+
// Take whole files rather than cutting a file's issues in half — a partial
|
|
87
|
+
// fix to one file is the worst outcome available, since it looks addressed.
|
|
88
|
+
const files = [];
|
|
89
|
+
const taken = [];
|
|
90
|
+
for (const issue of eligible) {
|
|
91
|
+
const knownFile = files.includes(issue.file);
|
|
92
|
+
if (!knownFile && files.length >= maxFiles)
|
|
93
|
+
continue;
|
|
94
|
+
if (taken.length >= maxIssues && !knownFile)
|
|
95
|
+
continue;
|
|
96
|
+
if (!knownFile)
|
|
97
|
+
files.push(issue.file);
|
|
98
|
+
taken.push(issue);
|
|
99
|
+
}
|
|
100
|
+
return { issues: taken, files, prompt: formatFixPrompt(taken), personality };
|
|
101
|
+
}
|
|
102
|
+
/** The instruction the agent receives: the findings, grouped by file. */
|
|
103
|
+
export function formatFixPrompt(issues) {
|
|
104
|
+
const byFile = new Map();
|
|
105
|
+
for (const issue of issues) {
|
|
106
|
+
const list = byFile.get(issue.file) ?? [];
|
|
107
|
+
list.push(issue);
|
|
108
|
+
byFile.set(issue.file, list);
|
|
109
|
+
}
|
|
110
|
+
const lines = [
|
|
111
|
+
`Fix the following ${issues.length} review finding${issues.length === 1 ? '' : 's'}.`,
|
|
112
|
+
'',
|
|
113
|
+
];
|
|
114
|
+
for (const [file, found] of byFile) {
|
|
115
|
+
lines.push(`## ${file}`);
|
|
116
|
+
for (const issue of found) {
|
|
117
|
+
const where = issue.line ? `line ${issue.line}` : 'file';
|
|
118
|
+
lines.push(`- [${issue.severity}] ${where}: ${issue.message}`);
|
|
119
|
+
if (issue.suggestion)
|
|
120
|
+
lines.push(` suggested: ${issue.suggestion}`);
|
|
121
|
+
}
|
|
122
|
+
lines.push('');
|
|
123
|
+
}
|
|
124
|
+
lines.push('Change nothing outside these files.');
|
|
125
|
+
return lines.join('\n');
|
|
126
|
+
}
|
|
127
|
+
/** A one-line summary for the pull request body the action opens. */
|
|
128
|
+
export function summariseFixPlan(plan) {
|
|
129
|
+
if (plan.skipped === 'no-issues')
|
|
130
|
+
return 'The review found nothing.';
|
|
131
|
+
if (plan.skipped === 'nothing-fixable') {
|
|
132
|
+
return 'The review found only suggestions, which are left for a human to weigh.';
|
|
133
|
+
}
|
|
134
|
+
const errors = plan.issues.filter(i => i.severity === 'error').length;
|
|
135
|
+
const warnings = plan.issues.length - errors;
|
|
136
|
+
const parts = [
|
|
137
|
+
errors ? `${errors} error${errors === 1 ? '' : 's'}` : '',
|
|
138
|
+
warnings ? `${warnings} warning${warnings === 1 ? '' : 's'}` : '',
|
|
139
|
+
].filter(Boolean);
|
|
140
|
+
return `Attempting ${parts.join(' and ')} across ${plan.files.length} file${plan.files.length === 1 ? '' : 's'}.`;
|
|
141
|
+
}
|
package/dist/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const VERSION = "2.
|
|
1
|
+
export declare const VERSION = "2.22.0";
|
package/dist/version.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
// AUTO-GENERATED by scripts/gen-version.js — do not edit by hand.
|
|
2
2
|
// Baked from package.json at build time so the bun-compiled binary reports
|
|
3
3
|
// the right version (it has no package.json on disk to read at runtime).
|
|
4
|
-
export const VERSION = '2.
|
|
4
|
+
export const VERSION = '2.22.0';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "codeep",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.22.0",
|
|
4
4
|
"description": "AI-powered coding assistant built for the terminal. Multiple LLM providers, project-aware context, and a seamless development workflow.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|