codeep 3.3.3 → 3.4.1
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/commands.d.ts +50 -1
- package/dist/acp/commands.js +545 -109
- package/dist/acp/protocol.d.ts +14 -5
- package/dist/acp/server.d.ts +36 -1
- package/dist/acp/server.js +581 -155
- package/dist/acp/serverHandlers.d.ts +2 -1
- package/dist/acp/serverHandlers.js +3 -0
- package/dist/acp/session.d.ts +28 -2
- package/dist/acp/session.js +25 -6
- package/dist/acp/transport.d.ts +40 -4
- package/dist/acp/transport.js +218 -25
- package/dist/acp/turns.d.ts +20 -0
- package/dist/acp/turns.js +30 -0
- package/dist/api/index.js +2 -0
- package/dist/api/ollamaNative.d.ts +3 -0
- package/dist/api/ollamaNative.js +35 -3
- package/dist/config/index.d.ts +21 -4
- package/dist/config/index.js +178 -123
- package/dist/renderer/agentExecution.d.ts +30 -2
- package/dist/renderer/agentExecution.js +248 -92
- package/dist/renderer/commands/helpers.d.ts +18 -2
- package/dist/renderer/commands/helpers.js +28 -5
- package/dist/renderer/commands.d.ts +2 -0
- package/dist/renderer/commands.js +180 -64
- package/dist/renderer/main.d.ts +41 -0
- package/dist/renderer/main.js +181 -80
- package/dist/utils/agent.d.ts +69 -4
- package/dist/utils/agent.js +416 -248
- package/dist/utils/agentChat.js +82 -10
- package/dist/utils/agents.d.ts +2 -1
- package/dist/utils/agents.js +100 -29
- package/dist/utils/auditLog.d.ts +4 -3
- package/dist/utils/auditLog.js +92 -9
- package/dist/utils/checkpoints.js +11 -6
- package/dist/utils/codeReview.js +28 -23
- package/dist/utils/codeepCloud.d.ts +14 -2
- package/dist/utils/codeepCloud.js +56 -20
- package/dist/utils/customCommands.js +7 -2
- package/dist/utils/git.d.ts +262 -4
- package/dist/utils/git.js +1928 -61
- package/dist/utils/gitHookInstaller.d.ts +32 -1
- package/dist/utils/gitHookInstaller.js +76 -8
- package/dist/utils/gitignore.d.ts +8 -0
- package/dist/utils/gitignore.js +41 -10
- package/dist/utils/headlessReview.d.ts +11 -0
- package/dist/utils/headlessReview.js +33 -5
- package/dist/utils/history.d.ts +22 -6
- package/dist/utils/history.js +140 -26
- package/dist/utils/logger.js +6 -7
- package/dist/utils/mcpConfig.d.ts +24 -0
- package/dist/utils/mcpConfig.js +36 -5
- package/dist/utils/mentions.d.ts +28 -5
- package/dist/utils/mentions.js +253 -45
- package/dist/utils/personalities.js +16 -6
- package/dist/utils/planMode.d.ts +13 -7
- package/dist/utils/planMode.js +32 -12
- package/dist/utils/projectIntelligence.d.ts +2 -0
- package/dist/utils/projectIntelligence.js +27 -8
- package/dist/utils/projectPaths.d.ts +53 -0
- package/dist/utils/projectPaths.js +146 -0
- package/dist/utils/shell.d.ts +119 -0
- package/dist/utils/shell.js +417 -45
- package/dist/utils/skillBundles.js +17 -7
- package/dist/utils/skillBundlesCloud.js +20 -3
- package/dist/utils/skills.d.ts +24 -2
- package/dist/utils/skills.js +235 -43
- package/dist/utils/smartContext.js +97 -23
- package/dist/utils/telegramApproval.d.ts +10 -2
- package/dist/utils/telegramApproval.js +22 -4
- package/dist/utils/toolExecution.d.ts +50 -2
- package/dist/utils/toolExecution.js +418 -16
- package/dist/utils/toolParsing.d.ts +7 -1
- package/dist/utils/toolParsing.js +12 -3
- package/dist/utils/userProfile.js +58 -16
- package/dist/utils/verify.d.ts +25 -4
- package/dist/utils/verify.js +259 -74
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/utils/agentChat.js
CHANGED
|
@@ -11,9 +11,11 @@
|
|
|
11
11
|
* AgentChatResponse — response type (re-export from agentStream)
|
|
12
12
|
* TimeoutError — distinguishes timeout from user abort
|
|
13
13
|
*/
|
|
14
|
-
import { existsSync, readFileSync,
|
|
14
|
+
import { existsSync, readFileSync, statSync, openSync, readSync, closeSync } from 'fs';
|
|
15
15
|
import { join } from 'path';
|
|
16
|
+
import { StringDecoder } from 'string_decoder';
|
|
16
17
|
import { createHash } from 'crypto';
|
|
18
|
+
import { UnsafeProjectPathError, leadsOutsideProject, writeProjectFile } from './projectPaths.js';
|
|
17
19
|
import { config, getApiKey, resolveBaseUrl } from '../config/index.js';
|
|
18
20
|
import { loadProjectIntelligence, generateContextFromIntelligence } from './projectIntelligence.js';
|
|
19
21
|
import { formatCommandIndex } from './commandIndex.js';
|
|
@@ -41,6 +43,41 @@ export class TimeoutError extends Error {
|
|
|
41
43
|
this.name = 'TimeoutError';
|
|
42
44
|
}
|
|
43
45
|
}
|
|
46
|
+
/**
|
|
47
|
+
* Read at most `maxBytes` of a project file, or null when it isn't a regular
|
|
48
|
+
* file inside the project. Rules and progress files arrive with a cloned
|
|
49
|
+
* repo, and statSync follows symlinks: a committed `AGENTS.md -> /dev/zero`
|
|
50
|
+
* (or a FIFO) never comes back from readFileSync, and every agent run would
|
|
51
|
+
* hang on it; `CODEEP.md -> ~/.aws/credentials` would put the user's
|
|
52
|
+
* credentials into every system prompt.
|
|
53
|
+
*/
|
|
54
|
+
function readProjectFile(filePath, maxBytes, projectRoot) {
|
|
55
|
+
if (leadsOutsideProject(filePath, projectRoot))
|
|
56
|
+
return null;
|
|
57
|
+
const stat = statSync(filePath, { throwIfNoEntry: false });
|
|
58
|
+
if (stat && !stat.isFile())
|
|
59
|
+
return null;
|
|
60
|
+
// A path that vanished since the caller's existsSync is left to
|
|
61
|
+
// readFileSync to report.
|
|
62
|
+
if (!stat || stat.size <= maxBytes)
|
|
63
|
+
return { text: readFileSync(filePath, 'utf-8'), truncated: false };
|
|
64
|
+
const fd = openSync(filePath, 'r');
|
|
65
|
+
try {
|
|
66
|
+
const buf = Buffer.alloc(maxBytes);
|
|
67
|
+
let read = 0;
|
|
68
|
+
while (read < maxBytes) {
|
|
69
|
+
const n = readSync(fd, buf, read, maxBytes - read, read);
|
|
70
|
+
if (n === 0)
|
|
71
|
+
break;
|
|
72
|
+
read += n;
|
|
73
|
+
}
|
|
74
|
+
// StringDecoder holds back a multi-byte character split by the cut.
|
|
75
|
+
return { text: new StringDecoder('utf8').write(buf.subarray(0, read)), truncated: true };
|
|
76
|
+
}
|
|
77
|
+
finally {
|
|
78
|
+
closeSync(fd);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
44
81
|
/**
|
|
45
82
|
* Load project rules from .codeep/rules.md or CODEEP.md
|
|
46
83
|
*/
|
|
@@ -69,12 +106,14 @@ export function loadProjectRules(projectRoot) {
|
|
|
69
106
|
for (const filePath of candidates) {
|
|
70
107
|
if (existsSync(filePath)) {
|
|
71
108
|
try {
|
|
72
|
-
|
|
109
|
+
const file = readProjectFile(filePath, MAX_RULES_BYTES, projectRoot);
|
|
110
|
+
if (!file)
|
|
111
|
+
continue;
|
|
112
|
+
let content = file.text.trim();
|
|
73
113
|
if (content) {
|
|
74
|
-
if (
|
|
75
|
-
debug('Project rules truncated', filePath,
|
|
76
|
-
content
|
|
77
|
-
+ '\n\n[Rules truncated by Codeep — file exceeds the 64KB inline limit.]';
|
|
114
|
+
if (file.truncated) {
|
|
115
|
+
debug('Project rules truncated', filePath, `> ${MAX_RULES_BYTES} bytes`);
|
|
116
|
+
content += '\n\n[Rules truncated by Codeep — file exceeds the 64KB inline limit.]';
|
|
78
117
|
}
|
|
79
118
|
debug('Loaded project rules from', filePath);
|
|
80
119
|
return `\n\n## Project Rules\nThe following rules are defined by the project owner. You MUST follow these rules:\n\n${content}`;
|
|
@@ -87,6 +126,8 @@ export function loadProjectRules(projectRoot) {
|
|
|
87
126
|
}
|
|
88
127
|
return '';
|
|
89
128
|
}
|
|
129
|
+
/** Largest progress log we'll inject (64 KB). */
|
|
130
|
+
const MAX_PROGRESS_BYTES = 64 * 1024;
|
|
90
131
|
/**
|
|
91
132
|
* Load agent progress log from .codeep/progress.md
|
|
92
133
|
* Injected into system prompt so agent knows what was previously done.
|
|
@@ -98,7 +139,12 @@ export function loadProgressLog(projectRoot) {
|
|
|
98
139
|
if (!existsSync(progressFile))
|
|
99
140
|
return '';
|
|
100
141
|
try {
|
|
101
|
-
|
|
142
|
+
// Capped like the rules: this file can arrive with the repo too.
|
|
143
|
+
const file = readProjectFile(progressFile, MAX_PROGRESS_BYTES, projectRoot);
|
|
144
|
+
let content = file ? file.text.trim() : '';
|
|
145
|
+
if (content && file?.truncated) {
|
|
146
|
+
content += '\n\n[Progress log truncated by Codeep — file exceeds the 64KB inline limit.]';
|
|
147
|
+
}
|
|
102
148
|
if (content) {
|
|
103
149
|
return `\n\n## Previous Session Progress\nThe agent has previously worked on this project. Read this to understand what was already done and what still needs to be done:\n\n${content}`;
|
|
104
150
|
}
|
|
@@ -108,6 +154,28 @@ export function loadProgressLog(projectRoot) {
|
|
|
108
154
|
}
|
|
109
155
|
return '';
|
|
110
156
|
}
|
|
157
|
+
/** How much of the task the progress log keeps, split between its start and end. */
|
|
158
|
+
const MAX_PROGRESS_TASK_CHARS = 4000;
|
|
159
|
+
/**
|
|
160
|
+
* The task as the progress log records it. Callers pass the enriched prompt,
|
|
161
|
+
* with attached files in front of the user's words, so a long one keeps its
|
|
162
|
+
* start and its end: written whole, it filled the 64KB read budget and the
|
|
163
|
+
* Status and Summary sections below it never reached the next session.
|
|
164
|
+
*/
|
|
165
|
+
function progressTask(prompt) {
|
|
166
|
+
if (prompt.length <= MAX_PROGRESS_TASK_CHARS)
|
|
167
|
+
return prompt;
|
|
168
|
+
const half = MAX_PROGRESS_TASK_CHARS / 2;
|
|
169
|
+
// Don't cut a surrogate pair in two.
|
|
170
|
+
let head = half;
|
|
171
|
+
if (/[\uD800-\uDBFF]/.test(prompt[head - 1]))
|
|
172
|
+
head--;
|
|
173
|
+
let tail = prompt.length - half;
|
|
174
|
+
if (/[\uDC00-\uDFFF]/.test(prompt[tail]))
|
|
175
|
+
tail++;
|
|
176
|
+
const omitted = tail - head;
|
|
177
|
+
return `${prompt.slice(0, head)}\n[… ${omitted} characters of the task omitted …]\n${prompt.slice(tail)}`;
|
|
178
|
+
}
|
|
111
179
|
/**
|
|
112
180
|
* Write agent progress log to .codeep/progress.md
|
|
113
181
|
* Called after each agent run so the next session has context.
|
|
@@ -129,7 +197,7 @@ export function writeProgressLog(projectRoot, prompt, result, projectName) {
|
|
|
129
197
|
`## Last Session: ${now}`,
|
|
130
198
|
``,
|
|
131
199
|
`### Task`,
|
|
132
|
-
|
|
200
|
+
progressTask(prompt),
|
|
133
201
|
``,
|
|
134
202
|
`### Status`,
|
|
135
203
|
result.success ? `✓ Completed (${result.iterations} iterations)` : `⚠ Incomplete — task may need to be continued`,
|
|
@@ -161,13 +229,15 @@ export function writeProgressLog(projectRoot, prompt, result, projectName) {
|
|
|
161
229
|
lines.push('');
|
|
162
230
|
}
|
|
163
231
|
const content = lines.join('\n');
|
|
164
|
-
|
|
232
|
+
// .codeep/ comes with the repo: a symlinked progress.md (or .codeep) would
|
|
233
|
+
// have this overwrite whatever file the repo pointed it at.
|
|
234
|
+
writeProjectFile(projectRoot, join(codeepDir, 'progress.md'), content);
|
|
165
235
|
if (projectName && projectRoot) {
|
|
166
236
|
syncProgress({ projectName, projectId: generateProjectId(projectRoot), content });
|
|
167
237
|
}
|
|
168
238
|
}
|
|
169
239
|
catch (err) {
|
|
170
|
-
debug('Failed to write progress log:', err);
|
|
240
|
+
debug(err instanceof UnsafeProjectPathError ? 'Not writing progress log:' : 'Failed to write progress log:', err);
|
|
171
241
|
}
|
|
172
242
|
}
|
|
173
243
|
/**
|
|
@@ -451,6 +521,8 @@ additionalTools, runtime) {
|
|
|
451
521
|
temperature: requiresDefaultTemperature(providerId) ? undefined : Number(config.get('temperature')),
|
|
452
522
|
timeoutMs,
|
|
453
523
|
onChunk: useStreaming ? onChunk : undefined,
|
|
524
|
+
// Carries the user's Stop as well as this request's own timeout.
|
|
525
|
+
signal: controller.signal,
|
|
454
526
|
});
|
|
455
527
|
if (res.promptTokens != null && res.completionTokens != null) {
|
|
456
528
|
recordTokenUsage({ promptTokens: res.promptTokens, completionTokens: res.completionTokens, totalTokens: res.promptTokens + res.completionTokens }, model, providerId);
|
package/dist/utils/agents.d.ts
CHANGED
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
* - **Built-in**: hardcoded below (researcher, reviewer, tester).
|
|
11
11
|
* - **Project**: `<workspace>/.codeep/agents/<name>.md`
|
|
12
12
|
* - **Global**: `~/.codeep/agents/<name>.md`
|
|
13
|
-
* Project shadows global shadows built-in, by name.
|
|
13
|
+
* Project shadows global shadows built-in, by name. A project file that
|
|
14
|
+
* shadows a built-in keeps at most the built-in's tools.
|
|
14
15
|
*
|
|
15
16
|
* File format — YAML-ish frontmatter + Markdown body (the role prompt):
|
|
16
17
|
* ```
|
package/dist/utils/agents.js
CHANGED
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
* - **Built-in**: hardcoded below (researcher, reviewer, tester).
|
|
11
11
|
* - **Project**: `<workspace>/.codeep/agents/<name>.md`
|
|
12
12
|
* - **Global**: `~/.codeep/agents/<name>.md`
|
|
13
|
-
* Project shadows global shadows built-in, by name.
|
|
13
|
+
* Project shadows global shadows built-in, by name. A project file that
|
|
14
|
+
* shadows a built-in keeps at most the built-in's tools.
|
|
14
15
|
*
|
|
15
16
|
* File format — YAML-ish frontmatter + Markdown body (the role prompt):
|
|
16
17
|
* ```
|
|
@@ -25,9 +26,10 @@
|
|
|
25
26
|
* You are a senior reviewer. Find correctness & security issues…
|
|
26
27
|
* ```
|
|
27
28
|
*/
|
|
28
|
-
import { readFileSync, readdirSync, existsSync } from 'fs';
|
|
29
|
+
import { readFileSync, readdirSync, existsSync, statSync } from 'fs';
|
|
29
30
|
import { join } from 'path';
|
|
30
31
|
import { homedir } from 'os';
|
|
32
|
+
import { leadsOutsideProject } from './projectPaths.js';
|
|
31
33
|
const BUILTIN = [
|
|
32
34
|
{
|
|
33
35
|
name: 'planner',
|
|
@@ -79,14 +81,73 @@ const BUILTIN = [
|
|
|
79
81
|
- Final message: what you added, the command to run them, and the pass/fail result.`,
|
|
80
82
|
},
|
|
81
83
|
];
|
|
82
|
-
/**
|
|
84
|
+
/** Largest agent file we'll read (64 KB). */
|
|
85
|
+
const MAX_AGENT_FILE_BYTES = 64 * 1024;
|
|
86
|
+
const unquote = (s) => s.trim().replace(/^["']|["']$/g, '');
|
|
87
|
+
/** Drop a YAML trailing comment (`read_file # safe`) from an unquoted value. */
|
|
88
|
+
const uncomment = (s) => s.replace(/(^|\s)#.*$/, '');
|
|
89
|
+
/**
|
|
90
|
+
* Parse a `tools:` value: `[a, b]`, `a, b`, or a YAML block list. A key that
|
|
91
|
+
* is present always yields a list, possibly empty. Only an absent key means
|
|
92
|
+
* "all tools", so a value we can't read denies tools rather than granting
|
|
93
|
+
* every one of them.
|
|
94
|
+
*/
|
|
83
95
|
function parseToolsValue(raw) {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
96
|
+
if (Array.isArray(raw))
|
|
97
|
+
return raw.map(unquote).filter(Boolean);
|
|
98
|
+
const inline = raw.trim().match(/^\[([^\]]*)\]/);
|
|
99
|
+
const inner = inline ? inline[1] : uncomment(raw);
|
|
100
|
+
return inner.split(',').map(unquote).filter(Boolean);
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Split an agent file into lowercase frontmatter keys and the body. A key
|
|
104
|
+
* with an empty value collects the `- item` lines under it. Returns null when
|
|
105
|
+
* the file opens a frontmatter fence that never closes: dropping the file is
|
|
106
|
+
* safer than reading its `tools:` line as part of the prompt.
|
|
107
|
+
*/
|
|
108
|
+
function parseAgentFile(raw) {
|
|
109
|
+
// Windows editors save CRLF and some prepend a BOM; neither may cost the
|
|
110
|
+
// file its frontmatter. Nor may a blank line above the opening fence: read
|
|
111
|
+
// as body, the `tools:` line would be dropped and every tool allowed.
|
|
112
|
+
const text = raw.replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n').replace(/^(?:[ \t]*\n)+(?=---)/, '');
|
|
113
|
+
const meta = {};
|
|
114
|
+
if (!/^---[ \t]*\n/.test(text))
|
|
115
|
+
return { meta, body: text };
|
|
116
|
+
// An empty block (`---` twice) is checked first: the general pattern would
|
|
117
|
+
// take a later `---` rule in the body as the closing fence.
|
|
118
|
+
const fm = text.match(/^---[ \t]*\n()---[ \t]*(?:\n([\s\S]*))?$/)
|
|
119
|
+
?? text.match(/^---[ \t]*\n([\s\S]*?)\n---[ \t]*(?:\n([\s\S]*))?$/);
|
|
120
|
+
if (!fm)
|
|
121
|
+
return null;
|
|
122
|
+
let list = null;
|
|
123
|
+
for (const line of fm[1].split('\n')) {
|
|
124
|
+
// YAML lets a blank line or a comment line sit inside a block list
|
|
125
|
+
if (!line.trim() || /^\s*#/.test(line))
|
|
126
|
+
continue;
|
|
127
|
+
const item = line.match(/^\s*-\s+(.*)$/);
|
|
128
|
+
if (item && list) {
|
|
129
|
+
list.push(uncomment(item[1]));
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
list = null;
|
|
133
|
+
const m = line.match(/^([a-zA-Z]+)\s*:\s*(.*)$/);
|
|
134
|
+
if (!m)
|
|
135
|
+
continue;
|
|
136
|
+
// `tools: # read-only` is an empty value with a comment; the list follows.
|
|
137
|
+
const value = m[2].trim().startsWith('#') ? '' : m[2].trim();
|
|
138
|
+
if (value) {
|
|
139
|
+
meta[m[1].toLowerCase()] = value;
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
list = [];
|
|
143
|
+
meta[m[1].toLowerCase()] = list;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return { meta, body: fm[2] ?? '' };
|
|
87
147
|
}
|
|
148
|
+
const str = (v) => (typeof v === 'string' && v ? v : undefined);
|
|
88
149
|
/** Load custom agents from a `.codeep/agents/` directory. */
|
|
89
|
-
function loadFromDir(dir, scope) {
|
|
150
|
+
function loadFromDir(dir, scope, projectRoot) {
|
|
90
151
|
if (!existsSync(dir))
|
|
91
152
|
return [];
|
|
92
153
|
const out = [];
|
|
@@ -104,32 +165,33 @@ function loadFromDir(dir, scope) {
|
|
|
104
165
|
if (!/^[a-z0-9][a-z0-9-]*$/.test(slug))
|
|
105
166
|
continue;
|
|
106
167
|
try {
|
|
107
|
-
const
|
|
108
|
-
|
|
168
|
+
const file = join(dir, entry);
|
|
169
|
+
// A project's files come with the repo: a link out of it would put an
|
|
170
|
+
// arbitrary file of the user's (credentials, history) into the prompt.
|
|
171
|
+
if (projectRoot && leadsOutsideProject(file, projectRoot))
|
|
109
172
|
continue;
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
if (
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
}
|
|
121
|
-
const
|
|
122
|
-
const
|
|
123
|
-
const
|
|
124
|
-
const maxIterations = meta.maxiterations ? parseInt(meta.maxiterations, 10) : undefined;
|
|
173
|
+
// statSync follows symlinks: a committed link to /dev/zero or a FIFO
|
|
174
|
+
// never comes back from readFileSync, so check kind and size first.
|
|
175
|
+
const stat = statSync(file);
|
|
176
|
+
if (!stat.isFile() || stat.size > MAX_AGENT_FILE_BYTES)
|
|
177
|
+
continue;
|
|
178
|
+
const parsed = parseAgentFile(readFileSync(file, 'utf8'));
|
|
179
|
+
if (!parsed)
|
|
180
|
+
continue;
|
|
181
|
+
const { meta, body } = parsed;
|
|
182
|
+
const displayName = str(meta.name) || slug;
|
|
183
|
+
const description = str(meta.description) || `Custom agent from ${entry}`;
|
|
184
|
+
const tools = meta.tools !== undefined ? parseToolsValue(meta.tools) : undefined;
|
|
185
|
+
const iterations = str(meta.maxiterations);
|
|
186
|
+
const maxIterations = iterations ? parseInt(iterations, 10) : undefined;
|
|
125
187
|
out.push({
|
|
126
188
|
name: slug,
|
|
127
189
|
displayName,
|
|
128
190
|
description: description.length > 200 ? description.slice(0, 197) + '…' : description,
|
|
129
191
|
prompt: body.trim(),
|
|
130
192
|
tools,
|
|
131
|
-
model: meta.model
|
|
132
|
-
personality: meta.personality
|
|
193
|
+
model: str(meta.model),
|
|
194
|
+
personality: str(meta.personality),
|
|
133
195
|
maxIterations: Number.isFinite(maxIterations) ? maxIterations : undefined,
|
|
134
196
|
scope,
|
|
135
197
|
});
|
|
@@ -141,15 +203,24 @@ function loadFromDir(dir, scope) {
|
|
|
141
203
|
return out;
|
|
142
204
|
}
|
|
143
205
|
export function loadAgents(workspaceRoot) {
|
|
144
|
-
const project = workspaceRoot ? loadFromDir(join(workspaceRoot, '.codeep', 'agents'), 'project') : [];
|
|
206
|
+
const project = workspaceRoot ? loadFromDir(join(workspaceRoot, '.codeep', 'agents'), 'project', workspaceRoot) : [];
|
|
145
207
|
const global = loadFromDir(join(homedir(), '.codeep', 'agents'), 'global');
|
|
146
208
|
const byName = new Map();
|
|
147
209
|
for (const a of BUILTIN)
|
|
148
210
|
byName.set(a.name, a);
|
|
149
211
|
for (const a of global)
|
|
150
212
|
byName.set(a.name, a);
|
|
151
|
-
for (const a of project)
|
|
213
|
+
for (const a of project) {
|
|
214
|
+
// A cloned repo can replace a built-in's prompt, but not hand it more
|
|
215
|
+
// tools than it ships with: auto-review delegates to `reviewer` after
|
|
216
|
+
// every write, so that allowlist must hold whatever `.codeep/agents/` says.
|
|
217
|
+
const builtin = BUILTIN.find((b) => b.name === a.name);
|
|
218
|
+
if (builtin?.tools) {
|
|
219
|
+
const allowed = builtin.tools;
|
|
220
|
+
a.tools = a.tools ? a.tools.filter((t) => allowed.includes(t)) : [...allowed];
|
|
221
|
+
}
|
|
152
222
|
byName.set(a.name, a);
|
|
223
|
+
}
|
|
153
224
|
return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
154
225
|
}
|
|
155
226
|
export function findAgent(name, workspaceRoot) {
|
|
@@ -180,7 +251,7 @@ export function formatAgentList(workspaceRoot) {
|
|
|
180
251
|
const lines = ['## Sub-agents', '', 'The agent can `delegate` self-contained sub-tasks to these. Each runs in its own context and returns a summary.', '', '| Name | Scope | Tools | Description |', '|---|---|---|---|'];
|
|
181
252
|
for (const a of list) {
|
|
182
253
|
const tag = a.scope === 'builtin' ? 'built-in' : a.scope;
|
|
183
|
-
const tools = a.tools ? `${a.tools.length} scoped` : '
|
|
254
|
+
const tools = !a.tools ? 'all' : a.tools.length ? `${a.tools.length} scoped` : 'none';
|
|
184
255
|
lines.push(`| \`${a.name}\` | ${tag} | ${tools} | ${a.description} |`);
|
|
185
256
|
}
|
|
186
257
|
lines.push('', 'Add your own: drop a `<name>.md` with frontmatter (name, description, tools, model, personality) in `.codeep/agents/` (project) or `~/.codeep/agents/` (global).');
|
package/dist/utils/auditLog.d.ts
CHANGED
|
@@ -14,9 +14,10 @@
|
|
|
14
14
|
* Format is JSON Lines. Appending one line per event survives a crash mid-run,
|
|
15
15
|
* needs no read-modify-write, and stays greppable without a parser.
|
|
16
16
|
*
|
|
17
|
-
* PRIVACY: entries carry command lines
|
|
18
|
-
* command can still contain a secret someone
|
|
19
|
-
* history can — treat the directory like shell
|
|
17
|
+
* PRIVACY: entries carry command lines, file paths and MCP tool arguments, not
|
|
18
|
+
* file contents. A command or an argument can still contain a secret someone
|
|
19
|
+
* typed into it, exactly as shell history can — treat the directory like shell
|
|
20
|
+
* history, not like source.
|
|
20
21
|
*
|
|
21
22
|
* It sits under `.codeep/`, which most projects already ignore, but Codeep does
|
|
22
23
|
* not edit anyone's `.gitignore` and this module must not claim otherwise. If a
|
package/dist/utils/auditLog.js
CHANGED
|
@@ -14,24 +14,109 @@
|
|
|
14
14
|
* Format is JSON Lines. Appending one line per event survives a crash mid-run,
|
|
15
15
|
* needs no read-modify-write, and stays greppable without a parser.
|
|
16
16
|
*
|
|
17
|
-
* PRIVACY: entries carry command lines
|
|
18
|
-
* command can still contain a secret someone
|
|
19
|
-
* history can — treat the directory like shell
|
|
17
|
+
* PRIVACY: entries carry command lines, file paths and MCP tool arguments, not
|
|
18
|
+
* file contents. A command or an argument can still contain a secret someone
|
|
19
|
+
* typed into it, exactly as shell history can — treat the directory like shell
|
|
20
|
+
* history, not like source.
|
|
20
21
|
*
|
|
21
22
|
* It sits under `.codeep/`, which most projects already ignore, but Codeep does
|
|
22
23
|
* not edit anyone's `.gitignore` and this module must not claim otherwise. If a
|
|
23
24
|
* project tracks `.codeep/`, the audit log will be committed with it.
|
|
24
25
|
*/
|
|
25
|
-
import { existsSync,
|
|
26
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
|
|
27
|
+
import { appendProjectFile } from './projectPaths.js';
|
|
26
28
|
import { join } from 'path';
|
|
27
29
|
import { randomBytes } from 'crypto';
|
|
28
30
|
import { config } from '../config/index.js';
|
|
31
|
+
/** Arguments that carry a file body. They are never part of a target. Names
|
|
32
|
+
* are compared without `_`/`-` and case, so `newText` is `new_text`. */
|
|
33
|
+
const CONTENT_ARGUMENTS = new Set([
|
|
34
|
+
'content', 'contents', 'filecontent', 'filecontents', 'filetext', 'body',
|
|
35
|
+
'oldstring', 'newstring', 'oldstr', 'newstr', 'oldtext', 'newtext',
|
|
36
|
+
'oldcontent', 'newcontent', 'replacement', 'patch', 'diff',
|
|
37
|
+
]);
|
|
38
|
+
/** Argument names whose value is a credential, compared without `_`/`-` and
|
|
39
|
+
* case. `token` is judged separately: most `*_token` arguments are secrets,
|
|
40
|
+
* but a page or continuation token is a cursor the user should see. */
|
|
41
|
+
const SECRET_ARGUMENT = /passw|passphrase|secret|apikey|accesskey|privatekey|authorization|credential|cookie/;
|
|
42
|
+
const CURSOR_TOKEN_QUALIFIERS = new Set(['page', 'next', 'prev', 'previous', 'continuation', 'cursor', 'sync', 'max', 'min', 'num', 'total', 'count']);
|
|
43
|
+
/**
|
|
44
|
+
* Values shaped like a well-known credential, whatever the argument is called.
|
|
45
|
+
* Each alternative consumes the whole credential, because only the matched
|
|
46
|
+
* text is replaced: a pattern that stopped after the prefix would leave the
|
|
47
|
+
* rest of the key on screen.
|
|
48
|
+
*/
|
|
49
|
+
const SECRET_VALUE = /\b[sr]k[-_](?:live|test|proj|ant)[-_][\w-]+|\bsk-[\w-]{20,}|\bgh[pousr]_\w{20,}|\bgithub_pat_\w{20,}|\bAKIA[0-9A-Z]{16}\b|\bxox[abprs]-[\w-]{10,}|-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)|\bBearer\s+[\w.~+/=-]{16,}/gi;
|
|
50
|
+
/** Longest single argument shown. The permission prompt lays out what is
|
|
51
|
+
* left and says what it drops, so this only bounds a pathological value. */
|
|
52
|
+
const MAX_ARGUMENT = 2000;
|
|
53
|
+
/** The words of an argument name: `page_token`, `pageToken`, `PAGE-TOKEN`. */
|
|
54
|
+
function nameWords(key) {
|
|
55
|
+
return key.replace(/([a-z0-9])([A-Z])/g, '$1 $2').toLowerCase().split(/[\s_-]+/).filter(Boolean);
|
|
56
|
+
}
|
|
57
|
+
/** True when an argument's name says its value is a credential. */
|
|
58
|
+
function isCredentialName(key) {
|
|
59
|
+
const words = nameWords(key);
|
|
60
|
+
if (SECRET_ARGUMENT.test(words.join('')))
|
|
61
|
+
return true;
|
|
62
|
+
return words.some((w, i) => (w === 'token' || w === 'tokens') && !(i > 0 && CURSOR_TOKEN_QUALIFIERS.has(words[i - 1])));
|
|
63
|
+
}
|
|
64
|
+
/** A plain value as it should appear: flattened, known credential shapes
|
|
65
|
+
* replaced where they occur, the rest left readable. */
|
|
66
|
+
function showValue(value) {
|
|
67
|
+
let text = String(value).replace(SECRET_VALUE, '[redacted]').replace(/\s+/g, ' ').trim();
|
|
68
|
+
if (text.length > MAX_ARGUMENT)
|
|
69
|
+
text = `${text.slice(0, MAX_ARGUMENT)}…[+${text.length - MAX_ARGUMENT} chars]`;
|
|
70
|
+
return text;
|
|
71
|
+
}
|
|
72
|
+
function isPlain(value) {
|
|
73
|
+
return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean';
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* An MCP tool's arguments, as `name=value` pairs in the order given. The
|
|
77
|
+
* argument names are the server's, not the built-in `command`/`path`/`url`
|
|
78
|
+
* ones: `postgres__query` carries its SQL in `sql`, and a call described by
|
|
79
|
+
* its name alone would be approved in the permission prompt unseen.
|
|
80
|
+
*
|
|
81
|
+
* Plain values and lists of plain values are shown — an exec tool's `args`
|
|
82
|
+
* is what it will run. A list holding objects, or an object, is where servers
|
|
83
|
+
* put file bodies under names of their own (`files[].content`,
|
|
84
|
+
* `edits[].newText`), so it is counted, never printed.
|
|
85
|
+
*/
|
|
86
|
+
function describeMcpArguments(p) {
|
|
87
|
+
const parts = [];
|
|
88
|
+
for (const [key, value] of Object.entries(p)) {
|
|
89
|
+
const name = key.toLowerCase().replace(/[-_]/g, '');
|
|
90
|
+
if (CONTENT_ARGUMENTS.has(name) || value === undefined || value === null)
|
|
91
|
+
continue;
|
|
92
|
+
if (isCredentialName(key) && (isPlain(value) || Array.isArray(value))) {
|
|
93
|
+
parts.push(`${key}=[redacted]`);
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (Array.isArray(value)) {
|
|
97
|
+
parts.push(value.every(isPlain)
|
|
98
|
+
? `${key}=${value.map(showValue).join(' ')}`
|
|
99
|
+
: `${key}=[${value.length} ${value.length === 1 ? 'item' : 'items'}]`);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (typeof value === 'object') {
|
|
103
|
+
const size = Object.keys(value).length;
|
|
104
|
+
parts.push(`${key}={${size} ${size === 1 ? 'key' : 'keys'}}`);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
parts.push(`${key}=${showValue(value)}`);
|
|
108
|
+
}
|
|
109
|
+
return parts.join(', ');
|
|
110
|
+
}
|
|
29
111
|
/** A one-line, content-free description of what a tool call was aimed at.
|
|
30
112
|
* Paths, commands and URLs are the point of the record; file bodies are not,
|
|
31
113
|
* and `content`/`old_string` style arguments are never read here. */
|
|
32
114
|
export function describeAuditTarget(call) {
|
|
33
115
|
const p = call.parameters ?? {};
|
|
34
116
|
const str = (k) => (typeof p[k] === 'string' ? p[k] : undefined);
|
|
117
|
+
// MCP tools (`<server>__<tool>`; no built-in name contains `__`).
|
|
118
|
+
if (call.tool.includes('__'))
|
|
119
|
+
return describeMcpArguments(p) || call.tool;
|
|
35
120
|
const command = str('command');
|
|
36
121
|
if (command) {
|
|
37
122
|
const args = Array.isArray(p.args) ? p.args.map(String) : [];
|
|
@@ -71,9 +156,6 @@ export function recordAuditEvent(projectRoot, event) {
|
|
|
71
156
|
if (!isAuditEnabled())
|
|
72
157
|
return;
|
|
73
158
|
try {
|
|
74
|
-
const dir = auditDir(projectRoot);
|
|
75
|
-
if (!existsSync(dir))
|
|
76
|
-
mkdirSync(dir, { recursive: true });
|
|
77
159
|
const line = {
|
|
78
160
|
...event,
|
|
79
161
|
target: clip(event.target, MAX_TARGET),
|
|
@@ -82,10 +164,11 @@ export function recordAuditEvent(projectRoot, event) {
|
|
|
82
164
|
};
|
|
83
165
|
// Drop undefined keys so a line stays small and diffs stay readable.
|
|
84
166
|
const compact = Object.fromEntries(Object.entries(line).filter(([, v]) => v !== undefined));
|
|
85
|
-
|
|
167
|
+
// .codeep/ can come with a cloned repo: never append through a symlink.
|
|
168
|
+
appendProjectFile(projectRoot, auditFile(projectRoot), JSON.stringify(compact) + '\n');
|
|
86
169
|
}
|
|
87
170
|
catch {
|
|
88
|
-
/* an unwritable audit log must never fail the run */
|
|
171
|
+
/* an unwritable (or symlinked) audit log must never fail the run */
|
|
89
172
|
}
|
|
90
173
|
}
|
|
91
174
|
/** Open a run and return its id. Records the agent and the capabilities it was
|
|
@@ -33,15 +33,14 @@
|
|
|
33
33
|
* "gitHead": "abcdef0" // optional, recorded only if cwd is a git repo
|
|
34
34
|
* }
|
|
35
35
|
*/
|
|
36
|
-
import { existsSync,
|
|
36
|
+
import { existsSync, readdirSync, readFileSync, unlinkSync, statSync } from 'fs';
|
|
37
|
+
import { isSafeProjectWriteTarget, writeProjectFile } from './projectPaths.js';
|
|
37
38
|
import { join } from 'path';
|
|
38
39
|
import { randomUUID } from 'crypto';
|
|
39
40
|
import { execSync } from 'child_process';
|
|
41
|
+
import { hardenedGitEnv } from './git.js';
|
|
40
42
|
function getCheckpointsDir(workspaceRoot) {
|
|
41
|
-
|
|
42
|
-
if (!existsSync(dir))
|
|
43
|
-
mkdirSync(dir, { recursive: true });
|
|
44
|
-
return dir;
|
|
43
|
+
return join(workspaceRoot, '.codeep', 'checkpoints');
|
|
45
44
|
}
|
|
46
45
|
function readGitHead(workspaceRoot) {
|
|
47
46
|
try {
|
|
@@ -51,6 +50,8 @@ function readGitHead(workspaceRoot) {
|
|
|
51
50
|
encoding: 'utf-8',
|
|
52
51
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
53
52
|
timeout: 2000,
|
|
53
|
+
// Read-only and Codeep's own, so the repository's hooks stay out of it.
|
|
54
|
+
env: hardenedGitEnv({ cwd: workspaceRoot, noHooks: true }),
|
|
54
55
|
}).trim();
|
|
55
56
|
return out || undefined;
|
|
56
57
|
}
|
|
@@ -83,7 +84,8 @@ export function createCheckpoint(opts) {
|
|
|
83
84
|
gitHead: readGitHead(opts.workspaceRoot),
|
|
84
85
|
};
|
|
85
86
|
const dir = getCheckpointsDir(opts.workspaceRoot);
|
|
86
|
-
|
|
87
|
+
// .codeep/ can come with a cloned repo: never write through a symlink.
|
|
88
|
+
writeProjectFile(opts.workspaceRoot, join(dir, `${checkpoint.id}.json`), JSON.stringify(checkpoint, null, 2));
|
|
87
89
|
return checkpoint;
|
|
88
90
|
}
|
|
89
91
|
/**
|
|
@@ -150,6 +152,9 @@ export function deleteCheckpoint(workspaceRoot, id) {
|
|
|
150
152
|
const file = join(getCheckpointsDir(workspaceRoot), `${id}.json`);
|
|
151
153
|
if (!existsSync(file))
|
|
152
154
|
return false;
|
|
155
|
+
// Through a symlinked .codeep/ the path would name a file elsewhere.
|
|
156
|
+
if (!isSafeProjectWriteTarget(workspaceRoot, file))
|
|
157
|
+
return false;
|
|
153
158
|
try {
|
|
154
159
|
unlinkSync(file);
|
|
155
160
|
return true;
|
package/dist/utils/codeReview.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { existsSync, readFileSync, readdirSync } from 'fs';
|
|
5
5
|
import { join, extname, relative } from 'path';
|
|
6
|
-
import {
|
|
6
|
+
import { getChangedFilesResult } from './git.js';
|
|
7
7
|
import { loadReviewConfig, globToRegExp } from './reviewConfig.js';
|
|
8
8
|
// Built-in code patterns that indicate issues. Each has a stable `id` so it can
|
|
9
9
|
// be turned off per-project via `.codeep/review.json` { "disable": ["..."] }.
|
|
@@ -274,26 +274,24 @@ function analyzeFile(filePath, content, projectRoot, rules, disabled) {
|
|
|
274
274
|
}
|
|
275
275
|
return issues;
|
|
276
276
|
}
|
|
277
|
-
/**
|
|
278
|
-
* Get files to review
|
|
279
|
-
*/
|
|
280
277
|
function getFilesToReview(projectRoot, specificFiles) {
|
|
281
278
|
if (specificFiles && specificFiles.length > 0) {
|
|
282
|
-
return
|
|
283
|
-
.map(f => join(projectRoot, f))
|
|
284
|
-
|
|
279
|
+
return {
|
|
280
|
+
files: specificFiles.map(f => join(projectRoot, f)).filter(f => existsSync(f)),
|
|
281
|
+
source: 'specific',
|
|
282
|
+
};
|
|
285
283
|
}
|
|
286
|
-
// Get changed files from git
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
284
|
+
// Get changed files from git. Called ONCE — the scope line below used to
|
|
285
|
+
// call getChangedFiles() a second time, which is a second `git config
|
|
286
|
+
// --list` plus a second `git status` on every review.
|
|
287
|
+
const changed = getChangedFilesResult(projectRoot);
|
|
288
|
+
if (changed.files.length > 0) {
|
|
289
|
+
return { files: changed.files.map(f => join(projectRoot, f)), source: 'git' };
|
|
290
290
|
}
|
|
291
291
|
// Otherwise, review src directory
|
|
292
292
|
const srcDir = join(projectRoot, 'src');
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
}
|
|
296
|
-
return getAllSourceFiles(projectRoot);
|
|
293
|
+
const files = existsSync(srcDir) ? getAllSourceFiles(srcDir) : getAllSourceFiles(projectRoot);
|
|
294
|
+
return { files, source: 'scan', gitError: changed.error };
|
|
297
295
|
}
|
|
298
296
|
/**
|
|
299
297
|
* Get all source files in directory
|
|
@@ -341,7 +339,8 @@ export function performCodeReview(projectContext, specificFiles) {
|
|
|
341
339
|
...CODE_PATTERNS.filter((p) => !disabled.has(p.id)),
|
|
342
340
|
...(config?.rules ?? []),
|
|
343
341
|
];
|
|
344
|
-
|
|
342
|
+
const selection = getFilesToReview(projectRoot, specificFiles);
|
|
343
|
+
let filesToReview = selection.files;
|
|
345
344
|
// Apply include/exclude globs (posix-relative paths). Empty include = all.
|
|
346
345
|
if (config && (config.include.length > 0 || config.exclude.length > 0)) {
|
|
347
346
|
const inc = config.include.map(globToRegExp);
|
|
@@ -356,17 +355,23 @@ export function performCodeReview(projectContext, specificFiles) {
|
|
|
356
355
|
});
|
|
357
356
|
}
|
|
358
357
|
const allIssues = [];
|
|
359
|
-
// Determine scope —
|
|
360
|
-
//
|
|
358
|
+
// Determine scope — reports the branch getFilesToReview actually took,
|
|
359
|
+
// rather than re-deriving it, so the two can no longer disagree.
|
|
360
|
+
const count = `${filesToReview.length} file${filesToReview.length === 1 ? '' : 's'}`;
|
|
361
361
|
let scope;
|
|
362
|
-
if (
|
|
363
|
-
scope = `specific file${specificFiles
|
|
362
|
+
if (selection.source === 'specific') {
|
|
363
|
+
scope = `specific file${specificFiles?.length === 1 ? '' : 's'} (${filesToReview.length})`;
|
|
364
|
+
}
|
|
365
|
+
else if (selection.source === 'git') {
|
|
366
|
+
scope = `unstaged git changes (${count})`;
|
|
364
367
|
}
|
|
365
|
-
else if (
|
|
366
|
-
|
|
368
|
+
else if (selection.gitError) {
|
|
369
|
+
// Not "no git changes": git would not run here, so nobody knows whether
|
|
370
|
+
// there are any. Say which, and say why — the message carries the fix.
|
|
371
|
+
scope = `full src/ scan — git could not list the changes: ${selection.gitError} (${count})`;
|
|
367
372
|
}
|
|
368
373
|
else {
|
|
369
|
-
scope = `full src/ scan — no git changes (${
|
|
374
|
+
scope = `full src/ scan — no git changes (${count})`;
|
|
370
375
|
}
|
|
371
376
|
for (const filePath of filesToReview) {
|
|
372
377
|
try {
|