@oh-my-pi/pi-coding-agent 8.10.13 → 8.12.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/CHANGELOG.md +5 -0
- package/package.json +6 -6
- package/src/commit/model-selection.ts +22 -8
- package/src/config/model-resolver.ts +117 -16
- package/src/cursor.ts +1 -6
- package/src/main.ts +8 -2
- package/src/modes/components/countdown-timer.ts +9 -0
- package/src/modes/components/hook-selector.ts +3 -0
- package/src/modes/components/skill-message.ts +92 -0
- package/src/modes/controllers/input-controller.ts +17 -1
- package/src/modes/utils/ui-helpers.ts +11 -2
- package/src/prompts/agents/designer.md +75 -0
- package/src/prompts/system/custom-system-prompt.md +36 -30
- package/src/prompts/system/system-prompt.md +18 -15
- package/src/prompts/tools/patch.md +6 -0
- package/src/session/agent-session.ts +57 -11
- package/src/session/messages.ts +9 -0
- package/src/system-prompt.ts +292 -0
- package/src/task/agents.ts +2 -9
- package/src/task/executor.ts +6 -1
- package/src/tools/fetch.ts +1 -4
- package/src/tools/gemini-image.ts +1 -3
- package/src/tools/ssh.ts +1 -3
|
@@ -5,43 +5,55 @@
|
|
|
5
5
|
{{#if appendPrompt}}
|
|
6
6
|
{{appendPrompt}}
|
|
7
7
|
{{/if}}
|
|
8
|
-
{{#
|
|
9
|
-
|
|
8
|
+
{{#ifAny projectTree contextFiles.length git.isRepo}}
|
|
9
|
+
<project>
|
|
10
|
+
{{#if projectTree}}
|
|
11
|
+
## Files
|
|
12
|
+
<tree>
|
|
13
|
+
{{projectTree}}
|
|
14
|
+
</tree>
|
|
15
|
+
{{/if}}
|
|
10
16
|
|
|
11
|
-
|
|
17
|
+
{{#if contextFiles.length}}
|
|
18
|
+
## Context
|
|
19
|
+
<instructions>
|
|
12
20
|
{{#list contextFiles join="\n"}}
|
|
13
21
|
<file path="{{path}}">
|
|
14
22
|
{{content}}
|
|
15
23
|
</file>
|
|
16
24
|
{{/list}}
|
|
17
|
-
</
|
|
25
|
+
</instructions>
|
|
18
26
|
{{/if}}
|
|
27
|
+
|
|
19
28
|
{{#if git.isRepo}}
|
|
20
|
-
|
|
29
|
+
## Version Control
|
|
30
|
+
This is a snapshot. It does not update during the conversation.
|
|
21
31
|
|
|
22
|
-
This is the git status at the start of the conversation. Note that this status is a snapshot in time, and will not update during the conversation.
|
|
23
32
|
Current branch: {{git.currentBranch}}
|
|
24
33
|
Main branch: {{git.mainBranch}}
|
|
25
34
|
|
|
26
|
-
Status:
|
|
27
35
|
{{git.status}}
|
|
28
36
|
|
|
29
|
-
|
|
37
|
+
### History
|
|
30
38
|
{{git.commits}}
|
|
31
39
|
{{/if}}
|
|
40
|
+
</project>
|
|
41
|
+
{{/ifAny}}
|
|
42
|
+
|
|
32
43
|
{{#if skills.length}}
|
|
33
|
-
|
|
34
|
-
|
|
44
|
+
Skills are specialized knowledge.
|
|
45
|
+
They exist because someone learned the hard way.
|
|
35
46
|
|
|
36
|
-
|
|
47
|
+
Scan descriptions against your task domain.
|
|
48
|
+
If a skill covers what you're producing, read `skill://<name>` before proceeding.
|
|
49
|
+
|
|
50
|
+
<skills>
|
|
37
51
|
{{#list skills join="\n"}}
|
|
38
|
-
<skill>
|
|
39
|
-
|
|
40
|
-
<description>{{escapeXml description}}</description>
|
|
41
|
-
<location>skill://{{escapeXml name}}</location>
|
|
52
|
+
<skill name="{{name}}">
|
|
53
|
+
{{description}}
|
|
42
54
|
</skill>
|
|
43
55
|
{{/list}}
|
|
44
|
-
</
|
|
56
|
+
</skills>
|
|
45
57
|
{{/if}}
|
|
46
58
|
{{#if preloadedSkills.length}}
|
|
47
59
|
The following skills are preloaded in full. Apply their instructions directly.
|
|
@@ -49,34 +61,28 @@ The following skills are preloaded in full. Apply their instructions directly.
|
|
|
49
61
|
<preloaded_skills>
|
|
50
62
|
{{#list preloadedSkills join="\n"}}
|
|
51
63
|
<skill name="{{name}}">
|
|
52
|
-
<location>skill://{{escapeXml name}}</location>
|
|
53
|
-
<content>
|
|
54
64
|
{{content}}
|
|
55
|
-
</content>
|
|
56
65
|
</skill>
|
|
57
66
|
{{/list}}
|
|
58
67
|
</preloaded_skills>
|
|
59
68
|
{{/if}}
|
|
60
69
|
{{#if rules.length}}
|
|
61
|
-
|
|
70
|
+
Rules are local constraints.
|
|
71
|
+
They exist because someone made a mistake here before.
|
|
72
|
+
|
|
73
|
+
Read `rule://<name>` when working in their domain.
|
|
62
74
|
|
|
63
75
|
<rules>
|
|
64
76
|
{{#list rules join="\n"}}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
<description>{{escapeXml description}}</description>
|
|
77
|
+
<rule name="{{name}}">
|
|
78
|
+
{{description}}
|
|
68
79
|
{{#if globs.length}}
|
|
69
|
-
|
|
70
|
-
{{#list globs join="\n"}}
|
|
71
|
-
<glob>{{escapeXml this}}</glob>
|
|
72
|
-
{{/list}}
|
|
73
|
-
</globs>
|
|
80
|
+
{{#list globs join="\n"}}<glob>{{this}}</glob>{{/list}}
|
|
74
81
|
{{/if}}
|
|
75
|
-
<location>rule://{{escapeXml name}}</location>
|
|
76
82
|
</rule>
|
|
77
83
|
{{/list}}
|
|
78
84
|
</rules>
|
|
79
85
|
{{/if}}
|
|
80
86
|
|
|
81
87
|
Current date and time: {{dateTime}}
|
|
82
|
-
Current working directory: {{cwd}}
|
|
88
|
+
Current working directory: {{cwd}}
|
|
@@ -221,22 +221,27 @@ It lies. The code that runs is not the code that works.
|
|
|
221
221
|
- Resolve blockers before yielding.
|
|
222
222
|
</procedure>
|
|
223
223
|
|
|
224
|
-
<
|
|
224
|
+
<project>
|
|
225
|
+
{{#if projectTree}}
|
|
226
|
+
## Files
|
|
227
|
+
<tree>
|
|
228
|
+
{{projectTree}}
|
|
229
|
+
</tree>
|
|
230
|
+
{{/if}}
|
|
231
|
+
|
|
225
232
|
{{#if contextFiles.length}}
|
|
226
|
-
|
|
233
|
+
## Context
|
|
234
|
+
<instructions>
|
|
227
235
|
{{#list contextFiles join="\n"}}
|
|
228
236
|
<file path="{{path}}">
|
|
229
237
|
{{content}}
|
|
230
238
|
</file>
|
|
231
239
|
{{/list}}
|
|
232
|
-
</
|
|
240
|
+
</instructions>
|
|
233
241
|
{{/if}}
|
|
234
|
-
</context>
|
|
235
242
|
|
|
236
243
|
{{#if git.isRepo}}
|
|
237
|
-
|
|
238
|
-
# Git Status
|
|
239
|
-
|
|
244
|
+
## Version Control
|
|
240
245
|
This is a snapshot. It does not update during the conversation.
|
|
241
246
|
|
|
242
247
|
Current branch: {{git.currentBranch}}
|
|
@@ -244,23 +249,22 @@ Main branch: {{git.mainBranch}}
|
|
|
244
249
|
|
|
245
250
|
{{git.status}}
|
|
246
251
|
|
|
247
|
-
|
|
248
|
-
|
|
252
|
+
### History
|
|
249
253
|
{{git.commits}}
|
|
250
|
-
</vcs>
|
|
251
254
|
{{/if}}
|
|
255
|
+
</project>
|
|
256
|
+
|
|
252
257
|
{{#if skills.length}}
|
|
253
258
|
<skills>
|
|
254
259
|
Skills are specialized knowledge.
|
|
255
260
|
They exist because someone learned the hard way.
|
|
256
261
|
|
|
257
262
|
Scan descriptions against your task domain.
|
|
258
|
-
If a skill covers what you're producing, read
|
|
263
|
+
If a skill covers what you're producing, read `skill://<name>` before proceeding.
|
|
259
264
|
|
|
260
265
|
{{#list skills join="\n"}}
|
|
261
266
|
<skill name="{{name}}">
|
|
262
267
|
{{description}}
|
|
263
|
-
<path>skill://{{name}}</path>
|
|
264
268
|
</skill>
|
|
265
269
|
{{/list}}
|
|
266
270
|
</skills>
|
|
@@ -271,7 +275,6 @@ The following skills are preloaded in full. Apply their instructions directly.
|
|
|
271
275
|
|
|
272
276
|
{{#list preloadedSkills join="\n"}}
|
|
273
277
|
<skill name="{{name}}">
|
|
274
|
-
<location>skill://{{escapeXml name}}</location>
|
|
275
278
|
{{content}}
|
|
276
279
|
</skill>
|
|
277
280
|
{{/list}}
|
|
@@ -282,12 +285,12 @@ The following skills are preloaded in full. Apply their instructions directly.
|
|
|
282
285
|
Rules are local constraints.
|
|
283
286
|
They exist because someone made a mistake here before.
|
|
284
287
|
|
|
285
|
-
|
|
288
|
+
Read `rule://<name>` when working in their domain.
|
|
289
|
+
|
|
286
290
|
{{#list rules join="\n"}}
|
|
287
291
|
<rule name="{{name}}">
|
|
288
292
|
{{description}}
|
|
289
293
|
{{#list globs join="\n"}}<glob>{{this}}</glob>{{/list}}
|
|
290
|
-
<path>rule://{{name}}</path>
|
|
291
294
|
</rule>
|
|
292
295
|
{{/list}}
|
|
293
296
|
</rules>
|
|
@@ -17,6 +17,7 @@ Performs patch operations on a file given a diff. Primary tool for modifying exi
|
|
|
17
17
|
**Context Lines:**
|
|
18
18
|
- Include enough ` `-prefixed lines to make match unique (usually 2–8 total)
|
|
19
19
|
- Must exist in the file exactly as written (preserve indentation/trailing spaces)
|
|
20
|
+
- When editing structured blocks (nested braces, tags, indented regions), include opening and closing lines in context so the edit stays inside the block
|
|
20
21
|
</instruction>
|
|
21
22
|
|
|
22
23
|
<parameters>
|
|
@@ -47,6 +48,9 @@ Returns success/failure status. On failure, returns error message indicating:
|
|
|
47
48
|
- Always read the target file before editing
|
|
48
49
|
- Copy anchors and context lines verbatim (including whitespace)
|
|
49
50
|
- Never use anchors as comments (no line numbers, location labels, or placeholders like `@@ @@`)
|
|
51
|
+
- Do not place new lines outside the intended block unless that is the explicit goal
|
|
52
|
+
- If an edit fails or produces broken structure, re-read the file and produce a new patch from current content—do not retry the same diff
|
|
53
|
+
- If indentation is wrong after editing, run the project's formatter (if available) rather than making repeated edit attempts
|
|
50
54
|
</critical>
|
|
51
55
|
|
|
52
56
|
<example name="create">
|
|
@@ -69,4 +73,6 @@ edit {"path":"obsolete.txt","op":"delete"}
|
|
|
69
73
|
- Generic anchors: `import`, `export`, `describe`, `function`, `const`
|
|
70
74
|
- Anchor comments: `line 207`, `top of file`, `near imports`, `...`
|
|
71
75
|
- Editing without reading the file first (causes stale context errors)
|
|
76
|
+
- Repeating the same addition in multiple hunks (creates duplicate blocks)
|
|
77
|
+
- Falling back to full-file overwrites for minor changes (acceptable for major restructures or short files)
|
|
72
78
|
</avoid>
|
|
@@ -1165,6 +1165,62 @@ export class AgentSession {
|
|
|
1165
1165
|
return;
|
|
1166
1166
|
}
|
|
1167
1167
|
|
|
1168
|
+
const userContent: (TextContent | ImageContent)[] = [{ type: "text", text: expandedText }];
|
|
1169
|
+
if (options?.images) {
|
|
1170
|
+
userContent.push(...options.images);
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
await this._promptWithMessage(
|
|
1174
|
+
{
|
|
1175
|
+
role: "user",
|
|
1176
|
+
content: userContent,
|
|
1177
|
+
synthetic: options?.synthetic,
|
|
1178
|
+
timestamp: Date.now(),
|
|
1179
|
+
},
|
|
1180
|
+
expandedText,
|
|
1181
|
+
options,
|
|
1182
|
+
);
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
async promptCustomMessage<T = unknown>(
|
|
1186
|
+
message: Pick<CustomMessage<T>, "customType" | "content" | "display" | "details">,
|
|
1187
|
+
options?: Pick<PromptOptions, "streamingBehavior" | "toolChoice">,
|
|
1188
|
+
): Promise<void> {
|
|
1189
|
+
const textContent =
|
|
1190
|
+
typeof message.content === "string"
|
|
1191
|
+
? message.content
|
|
1192
|
+
: message.content
|
|
1193
|
+
.filter((content): content is TextContent => content.type === "text")
|
|
1194
|
+
.map(content => content.text)
|
|
1195
|
+
.join("");
|
|
1196
|
+
|
|
1197
|
+
if (this.isStreaming) {
|
|
1198
|
+
if (!options?.streamingBehavior) {
|
|
1199
|
+
throw new Error(
|
|
1200
|
+
"Agent is already processing. Specify streamingBehavior ('steer' or 'followUp') to queue the message.",
|
|
1201
|
+
);
|
|
1202
|
+
}
|
|
1203
|
+
await this.sendCustomMessage(message, { deliverAs: options.streamingBehavior });
|
|
1204
|
+
return;
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
const customMessage: CustomMessage<T> = {
|
|
1208
|
+
role: "custom",
|
|
1209
|
+
customType: message.customType,
|
|
1210
|
+
content: message.content,
|
|
1211
|
+
display: message.display,
|
|
1212
|
+
details: message.details,
|
|
1213
|
+
timestamp: Date.now(),
|
|
1214
|
+
};
|
|
1215
|
+
|
|
1216
|
+
await this._promptWithMessage(customMessage, textContent, options);
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
private async _promptWithMessage(
|
|
1220
|
+
message: AgentMessage,
|
|
1221
|
+
expandedText: string,
|
|
1222
|
+
options?: Pick<PromptOptions, "toolChoice" | "images">,
|
|
1223
|
+
): Promise<void> {
|
|
1168
1224
|
// Flush any pending bash messages before the new prompt
|
|
1169
1225
|
this._flushPendingBashMessages();
|
|
1170
1226
|
this._flushPendingPythonMessages();
|
|
@@ -1207,17 +1263,7 @@ export class AgentSession {
|
|
|
1207
1263
|
messages.push(planModeMessage);
|
|
1208
1264
|
}
|
|
1209
1265
|
|
|
1210
|
-
|
|
1211
|
-
const userContent: (TextContent | ImageContent)[] = [{ type: "text", text: expandedText }];
|
|
1212
|
-
if (options?.images) {
|
|
1213
|
-
userContent.push(...options.images);
|
|
1214
|
-
}
|
|
1215
|
-
messages.push({
|
|
1216
|
-
role: "user",
|
|
1217
|
-
content: userContent,
|
|
1218
|
-
synthetic: options?.synthetic,
|
|
1219
|
-
timestamp: Date.now(),
|
|
1220
|
-
});
|
|
1266
|
+
messages.push(message);
|
|
1221
1267
|
|
|
1222
1268
|
// Inject any pending "nextTurn" messages as context alongside the user message
|
|
1223
1269
|
for (const msg of this._pendingNextTurnMessages) {
|
package/src/session/messages.ts
CHANGED
|
@@ -15,6 +15,15 @@ import { formatOutputNotice } from "../tools/output-meta";
|
|
|
15
15
|
const COMPACTION_SUMMARY_TEMPLATE = compactionSummaryContextPrompt;
|
|
16
16
|
const BRANCH_SUMMARY_TEMPLATE = branchSummaryContextPrompt;
|
|
17
17
|
|
|
18
|
+
export const SKILL_PROMPT_MESSAGE_TYPE = "skill-prompt";
|
|
19
|
+
|
|
20
|
+
export interface SkillPromptDetails {
|
|
21
|
+
name: string;
|
|
22
|
+
path: string;
|
|
23
|
+
args?: string;
|
|
24
|
+
lineCount: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
18
27
|
function getPrunedToolResultContent(message: ToolResultMessage): (TextContent | ImageContent)[] {
|
|
19
28
|
if (message.prunedAt === undefined) {
|
|
20
29
|
return message.content;
|
package/src/system-prompt.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* System prompt construction and project context loading
|
|
3
3
|
*/
|
|
4
|
+
import type * as fsTypes from "node:fs";
|
|
5
|
+
import * as fs from "node:fs/promises";
|
|
4
6
|
import * as os from "node:os";
|
|
5
7
|
import * as path from "node:path";
|
|
6
8
|
import { $ } from "bun";
|
|
@@ -14,6 +16,8 @@ import { loadSkills, type Skill } from "./extensibility/skills";
|
|
|
14
16
|
import customSystemPromptTemplate from "./prompts/system/custom-system-prompt.md" with { type: "text" };
|
|
15
17
|
import systemPromptTemplate from "./prompts/system/system-prompt.md" with { type: "text" };
|
|
16
18
|
import type { ToolName } from "./tools";
|
|
19
|
+
import { runRg } from "./tools/grep";
|
|
20
|
+
import { ensureTool } from "./utils/tools-manager";
|
|
17
21
|
|
|
18
22
|
interface GitContext {
|
|
19
23
|
isRepo: boolean;
|
|
@@ -129,6 +133,24 @@ function stripQuotes(value: string): string {
|
|
|
129
133
|
|
|
130
134
|
const AGENTS_MD_PATTERN = "**/AGENTS.md";
|
|
131
135
|
const AGENTS_MD_LIMIT = 200;
|
|
136
|
+
const PROJECT_TREE_LIMIT = 2000;
|
|
137
|
+
const PROJECT_TREE_PER_DIR_LIMIT = 10;
|
|
138
|
+
const PROJECT_TREE_PER_DIR_DEPTH = 2;
|
|
139
|
+
const PROJECT_TREE_IGNORED = new Set([
|
|
140
|
+
".git",
|
|
141
|
+
".hg",
|
|
142
|
+
".svn",
|
|
143
|
+
".next",
|
|
144
|
+
".turbo",
|
|
145
|
+
".cache",
|
|
146
|
+
".venv",
|
|
147
|
+
".idea",
|
|
148
|
+
".vscode",
|
|
149
|
+
"build",
|
|
150
|
+
"dist",
|
|
151
|
+
"node_modules",
|
|
152
|
+
"target",
|
|
153
|
+
]);
|
|
132
154
|
|
|
133
155
|
interface AgentsMdSearch {
|
|
134
156
|
scopePath: string;
|
|
@@ -166,6 +188,273 @@ function buildAgentsMdSearch(cwd: string): AgentsMdSearch {
|
|
|
166
188
|
};
|
|
167
189
|
}
|
|
168
190
|
|
|
191
|
+
type ProjectTreeEntry = {
|
|
192
|
+
name: string;
|
|
193
|
+
isDirectory: boolean;
|
|
194
|
+
path: string;
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
type ProjectTreeScan = {
|
|
198
|
+
children: Map<string, ProjectTreeEntry[]>;
|
|
199
|
+
truncated: boolean;
|
|
200
|
+
truncatedDirs: Set<string>;
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const RG_TIMEOUT_MS = 5000;
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Scan project tree using ripgrep to respect gitignore.
|
|
207
|
+
* Returns null if ripgrep is unavailable.
|
|
208
|
+
*/
|
|
209
|
+
async function scanProjectTreeWithRg(root: string): Promise<ProjectTreeScan | null> {
|
|
210
|
+
const rgPath = await ensureTool("rg", { silent: true });
|
|
211
|
+
if (!rgPath) return null;
|
|
212
|
+
|
|
213
|
+
const args = ["--files", "--no-require-git", "--color=never", root];
|
|
214
|
+
|
|
215
|
+
let stdout: string;
|
|
216
|
+
try {
|
|
217
|
+
const signal = AbortSignal.timeout(RG_TIMEOUT_MS);
|
|
218
|
+
const result = await runRg(rgPath, args, signal);
|
|
219
|
+
if (result.exitCode !== 0 && result.exitCode !== 1) return null;
|
|
220
|
+
stdout = result.stdout;
|
|
221
|
+
} catch {
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Build directory contents map from file list
|
|
226
|
+
// Map<dirPath, Map<entryPath, isDirectory>>
|
|
227
|
+
const dirContents = new Map<string, Map<string, boolean>>();
|
|
228
|
+
dirContents.set(root, new Map());
|
|
229
|
+
|
|
230
|
+
for (const line of stdout.split("\n")) {
|
|
231
|
+
const filePath = line.trim();
|
|
232
|
+
if (!filePath) continue;
|
|
233
|
+
|
|
234
|
+
// Check static ignores on path components
|
|
235
|
+
const relative = path.relative(root, filePath);
|
|
236
|
+
const parts = relative.split(path.sep);
|
|
237
|
+
if (parts.some(p => PROJECT_TREE_IGNORED.has(p))) continue;
|
|
238
|
+
|
|
239
|
+
// Add file to its parent directory
|
|
240
|
+
const parent = path.dirname(filePath);
|
|
241
|
+
if (!dirContents.has(parent)) dirContents.set(parent, new Map());
|
|
242
|
+
dirContents.get(parent)!.set(filePath, false);
|
|
243
|
+
|
|
244
|
+
// Add all intermediate directories
|
|
245
|
+
let dir = parent;
|
|
246
|
+
while (dir.length >= root.length && dir !== path.dirname(dir)) {
|
|
247
|
+
const parentDir = path.dirname(dir);
|
|
248
|
+
if (!dirContents.has(parentDir)) dirContents.set(parentDir, new Map());
|
|
249
|
+
dirContents.get(parentDir)!.set(dir, true);
|
|
250
|
+
dir = parentDir;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// BFS to build the tree with limits
|
|
255
|
+
const children = new Map<string, ProjectTreeEntry[]>();
|
|
256
|
+
let entryCount = 0;
|
|
257
|
+
let truncated = false;
|
|
258
|
+
const truncatedDirs = new Set<string>();
|
|
259
|
+
|
|
260
|
+
const queue: Array<{ dirPath: string; depth: number }> = [{ dirPath: root, depth: 0 }];
|
|
261
|
+
let cursor = 0;
|
|
262
|
+
|
|
263
|
+
while (cursor < queue.length && !truncated) {
|
|
264
|
+
const { dirPath, depth } = queue[cursor];
|
|
265
|
+
cursor += 1;
|
|
266
|
+
|
|
267
|
+
const contents = dirContents.get(dirPath);
|
|
268
|
+
if (!contents || contents.size === 0) continue;
|
|
269
|
+
|
|
270
|
+
// Get stats for sorting
|
|
271
|
+
const entries = Array.from(contents.entries());
|
|
272
|
+
const withStats = await Promise.all(
|
|
273
|
+
entries.map(async ([entryPath, isDirectory]) => {
|
|
274
|
+
try {
|
|
275
|
+
const stats = await fs.stat(entryPath);
|
|
276
|
+
return { entryPath, isDirectory, mtimeMs: stats.mtimeMs };
|
|
277
|
+
} catch {
|
|
278
|
+
return { entryPath, isDirectory, mtimeMs: 0 };
|
|
279
|
+
}
|
|
280
|
+
}),
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
withStats.sort((a, b) => {
|
|
284
|
+
if (a.mtimeMs !== b.mtimeMs) return b.mtimeMs - a.mtimeMs;
|
|
285
|
+
return path.basename(a.entryPath).localeCompare(path.basename(b.entryPath));
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
const perDirLimit = depth >= PROJECT_TREE_PER_DIR_DEPTH ? PROJECT_TREE_PER_DIR_LIMIT : null;
|
|
289
|
+
const limited = perDirLimit === null ? withStats : withStats.slice(0, perDirLimit);
|
|
290
|
+
const hasMoreEntries = perDirLimit !== null && withStats.length > perDirLimit;
|
|
291
|
+
|
|
292
|
+
const mapped: ProjectTreeEntry[] = [];
|
|
293
|
+
for (const { entryPath, isDirectory } of limited) {
|
|
294
|
+
if (entryCount >= PROJECT_TREE_LIMIT) {
|
|
295
|
+
truncated = true;
|
|
296
|
+
break;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
mapped.push({
|
|
300
|
+
name: path.basename(entryPath),
|
|
301
|
+
isDirectory,
|
|
302
|
+
path: entryPath,
|
|
303
|
+
});
|
|
304
|
+
entryCount += 1;
|
|
305
|
+
|
|
306
|
+
if (isDirectory) {
|
|
307
|
+
queue.push({ dirPath: entryPath, depth: depth + 1 });
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (!truncated && hasMoreEntries) {
|
|
312
|
+
truncatedDirs.add(dirPath);
|
|
313
|
+
}
|
|
314
|
+
children.set(dirPath, mapped);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return { children, truncated, truncatedDirs };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Fallback scan using readdir when ripgrep is unavailable.
|
|
322
|
+
*/
|
|
323
|
+
async function scanProjectTreeFallback(root: string): Promise<ProjectTreeScan> {
|
|
324
|
+
const children = new Map<string, ProjectTreeEntry[]>();
|
|
325
|
+
let entryCount = 0;
|
|
326
|
+
let truncated = false;
|
|
327
|
+
const truncatedDirs = new Set<string>();
|
|
328
|
+
|
|
329
|
+
const queue: Array<{ dirPath: string; depth: number }> = [{ dirPath: root, depth: 0 }];
|
|
330
|
+
let cursor = 0;
|
|
331
|
+
|
|
332
|
+
while (cursor < queue.length && !truncated) {
|
|
333
|
+
const { dirPath, depth } = queue[cursor];
|
|
334
|
+
cursor += 1;
|
|
335
|
+
let entries: fsTypes.Dirent[];
|
|
336
|
+
try {
|
|
337
|
+
entries = await fs.readdir(dirPath, { withFileTypes: true });
|
|
338
|
+
} catch {
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const filtered = entries.filter(entry => !PROJECT_TREE_IGNORED.has(entry.name));
|
|
343
|
+
const withStats = await Promise.all(
|
|
344
|
+
filtered.map(async entry => {
|
|
345
|
+
const entryPath = path.join(dirPath, entry.name);
|
|
346
|
+
try {
|
|
347
|
+
const stats = await fs.stat(entryPath);
|
|
348
|
+
return { entry, entryPath, mtimeMs: stats.mtimeMs };
|
|
349
|
+
} catch {
|
|
350
|
+
return { entry, entryPath, mtimeMs: 0 };
|
|
351
|
+
}
|
|
352
|
+
}),
|
|
353
|
+
);
|
|
354
|
+
|
|
355
|
+
withStats.sort((a, b) => {
|
|
356
|
+
if (a.mtimeMs !== b.mtimeMs) return b.mtimeMs - a.mtimeMs;
|
|
357
|
+
return a.entry.name.localeCompare(b.entry.name);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
const perDirLimit = depth >= PROJECT_TREE_PER_DIR_DEPTH ? PROJECT_TREE_PER_DIR_LIMIT : null;
|
|
361
|
+
const limited = perDirLimit === null ? withStats : withStats.slice(0, perDirLimit);
|
|
362
|
+
const hasMoreEntries = perDirLimit !== null && withStats.length > perDirLimit;
|
|
363
|
+
|
|
364
|
+
const mapped: ProjectTreeEntry[] = [];
|
|
365
|
+
for (const entryWithStat of limited) {
|
|
366
|
+
if (entryCount >= PROJECT_TREE_LIMIT) {
|
|
367
|
+
truncated = true;
|
|
368
|
+
break;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
mapped.push({
|
|
372
|
+
name: entryWithStat.entry.name,
|
|
373
|
+
isDirectory: entryWithStat.entry.isDirectory(),
|
|
374
|
+
path: entryWithStat.entryPath,
|
|
375
|
+
});
|
|
376
|
+
entryCount += 1;
|
|
377
|
+
|
|
378
|
+
if (entryWithStat.entry.isDirectory()) {
|
|
379
|
+
queue.push({ dirPath: entryWithStat.entryPath, depth: depth + 1 });
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (!truncated && hasMoreEntries) {
|
|
384
|
+
truncatedDirs.add(dirPath);
|
|
385
|
+
}
|
|
386
|
+
children.set(dirPath, mapped);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
return { children, truncated, truncatedDirs };
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
async function scanProjectTree(root: string): Promise<ProjectTreeScan> {
|
|
393
|
+
const rgResult = await scanProjectTreeWithRg(root);
|
|
394
|
+
if (rgResult) return rgResult;
|
|
395
|
+
return scanProjectTreeFallback(root);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function renderProjectTree(scan: ProjectTreeScan, root: string): string {
|
|
399
|
+
const lines: string[] = [];
|
|
400
|
+
|
|
401
|
+
const collapseDir = (dirPath: string): { path: string; entries: ProjectTreeEntry[] } | null => {
|
|
402
|
+
let currentPath = dirPath;
|
|
403
|
+
while (true) {
|
|
404
|
+
const entries = scan.children.get(currentPath);
|
|
405
|
+
if (!entries || entries.length === 0) return null;
|
|
406
|
+
const files = entries.filter(entry => !entry.isDirectory);
|
|
407
|
+
const dirs = entries.filter(entry => entry.isDirectory);
|
|
408
|
+
if (files.length === 0 && dirs.length === 1 && !scan.truncatedDirs.has(currentPath)) {
|
|
409
|
+
currentPath = dirs[0].path;
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
return { path: currentPath, entries };
|
|
413
|
+
}
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
const renderDir = (dirPath: string, indent: string, isRoot: boolean): void => {
|
|
417
|
+
const collapsed = collapseDir(dirPath);
|
|
418
|
+
if (!collapsed) return;
|
|
419
|
+
const { path: collapsedPath, entries } = collapsed;
|
|
420
|
+
|
|
421
|
+
// For non-root directories, print the header and indent contents
|
|
422
|
+
const contentIndent = isRoot ? indent : `${indent} `;
|
|
423
|
+
if (!isRoot) {
|
|
424
|
+
const relative = path.relative(root, collapsedPath) || ".";
|
|
425
|
+
lines.push(`${indent}@ ${relative}`);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const files = entries.filter(entry => !entry.isDirectory);
|
|
429
|
+
const dirs = entries.filter(entry => entry.isDirectory);
|
|
430
|
+
|
|
431
|
+
for (const entry of files) {
|
|
432
|
+
lines.push(`${contentIndent}- ${entry.name}`);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
if (scan.truncatedDirs.has(collapsedPath)) {
|
|
436
|
+
lines.push(`${contentIndent}- …`);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
for (const entry of dirs) {
|
|
440
|
+
renderDir(entry.path, contentIndent, false);
|
|
441
|
+
}
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
renderDir(root, "", true);
|
|
445
|
+
|
|
446
|
+
if (scan.truncated) {
|
|
447
|
+
lines.push("…");
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
return lines.join("\n");
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
async function buildProjectTreeSnapshot(root: string): Promise<string> {
|
|
454
|
+
const scan = await scanProjectTree(root);
|
|
455
|
+
return renderProjectTree(scan, root);
|
|
456
|
+
}
|
|
457
|
+
|
|
169
458
|
function getOsName(): string {
|
|
170
459
|
switch (process.platform) {
|
|
171
460
|
case "win32":
|
|
@@ -707,6 +996,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
|
|
|
707
996
|
// Resolve context files: use provided or discover
|
|
708
997
|
const contextFiles = providedContextFiles ?? (await loadProjectContextFiles({ cwd: resolvedCwd }));
|
|
709
998
|
const agentsMdSearch = buildAgentsMdSearch(resolvedCwd);
|
|
999
|
+
const projectTree = await buildProjectTreeSnapshot(resolvedCwd);
|
|
710
1000
|
|
|
711
1001
|
// Build tool descriptions array
|
|
712
1002
|
// Priority: toolNames (explicit list) > tools (Map) > defaults
|
|
@@ -744,6 +1034,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
|
|
|
744
1034
|
customPrompt: resolvedCustomPrompt,
|
|
745
1035
|
appendPrompt: resolvedAppendPrompt ?? "",
|
|
746
1036
|
contextFiles,
|
|
1037
|
+
projectTree,
|
|
747
1038
|
agentsMdSearch,
|
|
748
1039
|
git,
|
|
749
1040
|
skills: filteredSkills,
|
|
@@ -759,6 +1050,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
|
|
|
759
1050
|
environment: await getEnvironmentInfo(),
|
|
760
1051
|
systemPromptCustomization: systemPromptCustomization ?? "",
|
|
761
1052
|
contextFiles,
|
|
1053
|
+
projectTree,
|
|
762
1054
|
agentsMdSearch,
|
|
763
1055
|
git,
|
|
764
1056
|
skills: filteredSkills,
|
package/src/task/agents.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { renderPromptTemplate } from "../config/prompt-templates";
|
|
7
7
|
import { parseAgentFields } from "../discovery/helpers";
|
|
8
|
+
import designerMd from "../prompts/agents/designer.md" with { type: "text" };
|
|
8
9
|
import exploreMd from "../prompts/agents/explore.md" with { type: "text" };
|
|
9
10
|
// Embed agent markdown files at build time
|
|
10
11
|
import agentFrontmatterTemplate from "../prompts/agents/frontmatter.md" with { type: "text" };
|
|
@@ -37,6 +38,7 @@ function buildAgentContent(def: EmbeddedAgentDef): string {
|
|
|
37
38
|
const EMBEDDED_AGENT_DEFS: EmbeddedAgentDef[] = [
|
|
38
39
|
{ fileName: "explore.md", template: exploreMd },
|
|
39
40
|
{ fileName: "plan.md", template: planMd },
|
|
41
|
+
{ fileName: "designer.md", template: designerMd },
|
|
40
42
|
{ fileName: "reviewer.md", template: reviewerMd },
|
|
41
43
|
{
|
|
42
44
|
fileName: "task.md",
|
|
@@ -57,15 +59,6 @@ const EMBEDDED_AGENT_DEFS: EmbeddedAgentDef[] = [
|
|
|
57
59
|
},
|
|
58
60
|
template: taskMd,
|
|
59
61
|
},
|
|
60
|
-
{
|
|
61
|
-
fileName: "deep_task.md",
|
|
62
|
-
frontmatter: {
|
|
63
|
-
name: "deep_task",
|
|
64
|
-
description: "Deep task for comprehensive reasoning",
|
|
65
|
-
model: "pi/slow",
|
|
66
|
-
},
|
|
67
|
-
template: taskMd,
|
|
68
|
-
},
|
|
69
62
|
];
|
|
70
63
|
|
|
71
64
|
const EMBEDDED_AGENTS: { name: string; content: string }[] = EMBEDDED_AGENT_DEFS.map(def => ({
|