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.
Files changed (38) hide show
  1. package/dist/acp/server.js +8 -4
  2. package/dist/api/index.js +26 -19
  3. package/dist/config/index.d.ts +20 -0
  4. package/dist/config/index.js +32 -0
  5. package/dist/renderer/App.js +0 -1
  6. package/dist/renderer/Input.d.ts +0 -1
  7. package/dist/renderer/Input.js +0 -1
  8. package/dist/renderer/commands/registry.js +1 -0
  9. package/dist/renderer/commands.js +19 -3
  10. package/dist/renderer/components/Export.js +0 -2
  11. package/dist/renderer/components/Login.d.ts +0 -1
  12. package/dist/renderer/components/Login.js +0 -2
  13. package/dist/renderer/components/Logout.js +0 -2
  14. package/dist/renderer/components/Settings.d.ts +3 -0
  15. package/dist/renderer/components/Settings.js +0 -12
  16. package/dist/renderer/main.js +35 -56
  17. package/dist/utils/agent.d.ts +7 -0
  18. package/dist/utils/agent.js +102 -6
  19. package/dist/utils/auditLog.d.ts +93 -0
  20. package/dist/utils/auditLog.js +217 -0
  21. package/dist/utils/codeepCloud.d.ts +30 -4
  22. package/dist/utils/codeepCloud.js +71 -19
  23. package/dist/utils/diffPreview.js +0 -1
  24. package/dist/utils/git.js +0 -1
  25. package/dist/utils/headlessReview.d.ts +9 -1
  26. package/dist/utils/headlessReview.js +77 -3
  27. package/dist/utils/mcpStreamableHttp.d.ts +0 -1
  28. package/dist/utils/mcpStreamableHttp.js +0 -3
  29. package/dist/utils/personalities.js +0 -1
  30. package/dist/utils/reviewFix.d.ts +65 -0
  31. package/dist/utils/reviewFix.js +141 -0
  32. package/dist/utils/skillBundles.js +0 -4
  33. package/dist/utils/smartContext.js +0 -18
  34. package/dist/version.d.ts +1 -1
  35. package/dist/version.js +1 -1
  36. package/package.json +2 -2
  37. package/dist/renderer/components/Permission.d.ts +0 -24
  38. package/dist/renderer/components/Permission.js +0 -113
@@ -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
- const sessionId = opts.nested ? '' : startSession(prompt, projectContext.root || process.cwd());
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 new Promise(resolve => setTimeout(resolve, throttleMs));
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 new Promise(resolve => setTimeout(resolve, 1000 * retryCount));
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 new Promise(resolve => setTimeout(resolve, waitSec * 1000));
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
- export declare const pullPersonalities: () => Promise<number | null>;
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<number | null>;
91
- export declare const pullCommands: () => Promise<number | null>;
92
- export declare const pushCommands: () => Promise<number | null>;
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 {};