codeep 2.20.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/acp/server.js +8 -4
- 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/App.js +0 -1
- package/dist/renderer/Input.d.ts +0 -1
- package/dist/renderer/Input.js +0 -1
- package/dist/renderer/commands/registry.js +1 -0
- package/dist/renderer/commands.js +19 -3
- package/dist/renderer/components/Export.js +0 -2
- package/dist/renderer/components/Login.d.ts +0 -1
- package/dist/renderer/components/Login.js +0 -2
- package/dist/renderer/components/Logout.js +0 -2
- package/dist/renderer/components/Settings.d.ts +3 -0
- package/dist/renderer/components/Settings.js +0 -12
- package/dist/renderer/main.js +35 -56
- package/dist/utils/agent.d.ts +7 -0
- package/dist/utils/agent.js +102 -6
- package/dist/utils/auditLog.d.ts +93 -0
- package/dist/utils/auditLog.js +217 -0
- package/dist/utils/codeepCloud.d.ts +30 -4
- package/dist/utils/codeepCloud.js +71 -19
- package/dist/utils/diffPreview.js +0 -1
- package/dist/utils/git.js +0 -1
- package/dist/utils/headlessReview.d.ts +9 -1
- package/dist/utils/headlessReview.js +77 -3
- package/dist/utils/mcpStreamableHttp.d.ts +0 -1
- package/dist/utils/mcpStreamableHttp.js +0 -3
- package/dist/utils/personalities.js +0 -1
- package/dist/utils/reviewFix.d.ts +65 -0
- package/dist/utils/reviewFix.js +141 -0
- package/dist/utils/skillBundles.js +0 -4
- package/dist/utils/smartContext.js +0 -18
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -2
- package/dist/renderer/components/Permission.d.ts +0 -24
- package/dist/renderer/components/Permission.js +0 -113
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')),
|
|
@@ -205,7 +230,21 @@ export async function runAgent(prompt, projectContext, options = {}) {
|
|
|
205
230
|
// Start history session for undo support. Skipped for nested (delegated)
|
|
206
231
|
// runs so we don't reset the parent's currentSession singleton — the
|
|
207
232
|
// sub-agent's actions still record into the parent's open session.
|
|
208
|
-
|
|
233
|
+
// The return value is unused — startSession's point here is the side
|
|
234
|
+
// effect of opening the history session. Binding it hid that from
|
|
235
|
+
// noUnusedLocals, so the dead binding is gone and the call stays.
|
|
236
|
+
const auditRoot = projectContext.root || process.cwd();
|
|
237
|
+
if (!opts.nested)
|
|
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
|
+
});
|
|
209
248
|
// Task planning phase (if enabled)
|
|
210
249
|
// Use planning for complex keywords or multi-word prompts
|
|
211
250
|
let taskPlan = null;
|
|
@@ -427,6 +466,9 @@ export async function runAgent(prompt, projectContext, options = {}) {
|
|
|
427
466
|
messages.push({ role: 'user', content: initialPrompt });
|
|
428
467
|
let iteration = 0;
|
|
429
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.
|
|
430
472
|
let result;
|
|
431
473
|
let consecutiveTimeouts = 0;
|
|
432
474
|
let incompleteWorkRetries = 0;
|
|
@@ -571,7 +613,7 @@ export async function runAgent(prompt, projectContext, options = {}) {
|
|
|
571
613
|
const totalTokensEstimate = messages.reduce((sum, m) => sum + Math.ceil(m.content.length / 4), 0);
|
|
572
614
|
const throttleMs = Math.min(Math.floor(totalTokensEstimate / 10000) * 1000, 5000);
|
|
573
615
|
if (throttleMs > 0)
|
|
574
|
-
await
|
|
616
|
+
await abortableSleep(throttleMs, opts.abortSignal);
|
|
575
617
|
}
|
|
576
618
|
// Compress messages if context window is getting full (silent)
|
|
577
619
|
const compressed = compressMessages(messages, actions);
|
|
@@ -664,7 +706,11 @@ export async function runAgent(prompt, projectContext, options = {}) {
|
|
|
664
706
|
break;
|
|
665
707
|
}
|
|
666
708
|
// Wait before retry (exponential backoff)
|
|
667
|
-
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;
|
|
668
714
|
continue;
|
|
669
715
|
}
|
|
670
716
|
// Don't retry on 4xx client errors except 429 (rate limit)
|
|
@@ -728,7 +774,12 @@ export async function runAgent(prompt, projectContext, options = {}) {
|
|
|
728
774
|
});
|
|
729
775
|
break; // Break retry loop, continue main loop
|
|
730
776
|
}
|
|
731
|
-
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;
|
|
732
783
|
continue;
|
|
733
784
|
}
|
|
734
785
|
}
|
|
@@ -833,6 +884,13 @@ export async function runAgent(prompt, projectContext, options = {}) {
|
|
|
833
884
|
};
|
|
834
885
|
opts.onToolResult?.(denied, toolCall);
|
|
835
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
|
+
});
|
|
836
894
|
toolResults.push(`Tool ${toolCall.tool} is blocked by the active custom bot. Allowed capabilities: ${allowed}.`);
|
|
837
895
|
continue;
|
|
838
896
|
}
|
|
@@ -940,6 +998,14 @@ export async function runAgent(prompt, projectContext, options = {}) {
|
|
|
940
998
|
// Log action
|
|
941
999
|
const actionLog = createActionLog(toolCall, toolResult);
|
|
942
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
|
+
});
|
|
943
1009
|
// ── Infinite loop detection for write/edit ──────────────────────────
|
|
944
1010
|
if (toolCall.tool === 'write_file' || toolCall.tool === 'edit_file') {
|
|
945
1011
|
const filePath = toolCall.parameters.path || '';
|
|
@@ -1121,6 +1187,13 @@ export async function runAgent(prompt, projectContext, options = {}) {
|
|
|
1121
1187
|
};
|
|
1122
1188
|
opts.onToolResult?.(denied, toolCall);
|
|
1123
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
|
+
});
|
|
1124
1197
|
fixResults.push(`Tool ${toolCall.tool} blocked by the active custom bot.`);
|
|
1125
1198
|
continue;
|
|
1126
1199
|
}
|
|
@@ -1141,6 +1214,14 @@ export async function runAgent(prompt, projectContext, options = {}) {
|
|
|
1141
1214
|
opts.onToolResult?.(toolResult, toolCall);
|
|
1142
1215
|
const actionLog = createActionLog(toolCall, toolResult);
|
|
1143
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
|
+
});
|
|
1144
1225
|
if (toolResult.success) {
|
|
1145
1226
|
const truncated = truncateToolResult(toolResult.output, toolCall.tool);
|
|
1146
1227
|
fixResults.push(`Tool ${toolCall.tool} succeeded:\n${truncated}`);
|
|
@@ -1199,6 +1280,7 @@ export async function runAgent(prompt, projectContext, options = {}) {
|
|
|
1199
1280
|
}
|
|
1200
1281
|
catch (error) {
|
|
1201
1282
|
const err = error;
|
|
1283
|
+
auditFailure = err.message;
|
|
1202
1284
|
result = {
|
|
1203
1285
|
success: false,
|
|
1204
1286
|
iterations: iteration,
|
|
@@ -1211,8 +1293,22 @@ export async function runAgent(prompt, projectContext, options = {}) {
|
|
|
1211
1293
|
finally {
|
|
1212
1294
|
// End session and save history. Skipped for nested runs so we don't write
|
|
1213
1295
|
// a separate session file or null out the parent's open session.
|
|
1214
|
-
if (!opts.nested)
|
|
1296
|
+
if (!opts.nested) {
|
|
1215
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
|
+
}
|
|
1216
1312
|
}
|
|
1217
1313
|
}
|
|
1218
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
|
+
}
|
|
@@ -84,12 +84,37 @@ declare function readFileBundle(kind: 'personalities' | 'commands'): Record<stri
|
|
|
84
84
|
* files. Only writes files that don't already exist (additive merge —
|
|
85
85
|
* never clobber local edits). Returns the count of newly written files. */
|
|
86
86
|
declare function writeFileBundle(kind: 'personalities' | 'commands', items: Record<string, string>): number;
|
|
87
|
+
/** Apply the server's explicit deletion list.
|
|
88
|
+
*
|
|
89
|
+
* Only names the server named. Absence from `items` is deliberately NOT a
|
|
90
|
+
* deletion signal: an expired session, the wrong account, or a truncated
|
|
91
|
+
* response all yield an empty `items`, and deleting on absence would wipe
|
|
92
|
+
* every local agent. Project-scoped agents in `.codeep/personalities/` are
|
|
93
|
+
* not cloud-owned and are never touched — only the global directory is.
|
|
94
|
+
* Every removal is backed up first, and a failed backup cancels the delete. */
|
|
95
|
+
declare function applyPersonalityTombstones(deleted: readonly string[]): number;
|
|
87
96
|
declare function writePulledPersonalityBundle(items: Record<string, string>): number;
|
|
88
|
-
|
|
97
|
+
/** Why a sync attempt produced nothing. Reported so a silent failure cannot
|
|
98
|
+
* look like a successful no-op — the two were indistinguishable when every
|
|
99
|
+
* path returned `null`, and a user watching `codeep account sync` print
|
|
100
|
+
* nothing had no way to tell which had happened. */
|
|
101
|
+
export type SyncFailure = 'not-linked' | 'unreachable' | 'rejected' | 'malformed';
|
|
102
|
+
/** Success carries a count (which may legitimately be 0 — nothing new), plus
|
|
103
|
+
* how many local agents the server's tombstone list removed. */
|
|
104
|
+
export type SyncResult = {
|
|
105
|
+
ok: true;
|
|
106
|
+
count: number;
|
|
107
|
+
removed: number;
|
|
108
|
+
} | {
|
|
109
|
+
ok: false;
|
|
110
|
+
reason: SyncFailure;
|
|
111
|
+
};
|
|
112
|
+
export declare function describeSyncFailure(reason: SyncFailure): string;
|
|
113
|
+
export declare const pullPersonalities: () => Promise<SyncResult>;
|
|
89
114
|
export declare const getLastPersonalityPullBackupCount: () => number;
|
|
90
|
-
export declare const pushPersonalities: () => Promise<
|
|
91
|
-
export declare const pullCommands: () => Promise<
|
|
92
|
-
export declare const pushCommands: () => Promise<
|
|
115
|
+
export declare const pushPersonalities: () => Promise<SyncResult>;
|
|
116
|
+
export declare const pullCommands: () => Promise<SyncResult>;
|
|
117
|
+
export declare const pushCommands: () => Promise<SyncResult>;
|
|
93
118
|
/**
|
|
94
119
|
* Sync session conversation history to codeep.dev.
|
|
95
120
|
* Only user/assistant messages are sent — system messages are filtered out.
|
|
@@ -184,4 +209,5 @@ export declare const _globalDirForTest: typeof globalDir;
|
|
|
184
209
|
export declare const _readFileBundleForTest: typeof readFileBundle;
|
|
185
210
|
export declare const _writeFileBundleForTest: typeof writeFileBundle;
|
|
186
211
|
export declare const _writePulledPersonalityBundleForTest: typeof writePulledPersonalityBundle;
|
|
212
|
+
export declare const _applyPersonalityTombstonesForTest: typeof applyPersonalityTombstones;
|
|
187
213
|
export {};
|