@oh-my-pi/pi-coding-agent 8.11.14 → 8.12.2
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/cursor.ts +1 -6
- package/src/extensibility/extensions/types.ts +2 -0
- package/src/modes/components/countdown-timer.ts +9 -0
- package/src/modes/components/hook-selector.ts +58 -7
- package/src/modes/controllers/extension-ui-controller.ts +32 -16
- package/src/prompts/system/custom-system-prompt.md +35 -29
- package/src/prompts/system/system-prompt.md +18 -15
- package/src/system-prompt.ts +214 -0
- package/src/tools/ask.ts +21 -6
- package/src/tools/fetch.ts +1 -4
- package/src/tools/find.ts +24 -50
- package/src/tools/gemini-image.ts +1 -3
- package/src/tools/grep.ts +14 -2
- package/src/tools/read.ts +22 -89
- package/src/tools/ssh.ts +1 -3
package/CHANGELOG.md
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oh-my-pi/pi-coding-agent",
|
|
3
|
-
"version": "8.
|
|
3
|
+
"version": "8.12.2",
|
|
4
4
|
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"ompConfig": {
|
|
@@ -83,11 +83,11 @@
|
|
|
83
83
|
"test": "bun test"
|
|
84
84
|
},
|
|
85
85
|
"dependencies": {
|
|
86
|
-
"@oh-my-pi/omp-stats": "8.
|
|
87
|
-
"@oh-my-pi/pi-agent-core": "8.
|
|
88
|
-
"@oh-my-pi/pi-ai": "8.
|
|
89
|
-
"@oh-my-pi/pi-tui": "8.
|
|
90
|
-
"@oh-my-pi/pi-utils": "8.
|
|
86
|
+
"@oh-my-pi/omp-stats": "8.12.2",
|
|
87
|
+
"@oh-my-pi/pi-agent-core": "8.12.2",
|
|
88
|
+
"@oh-my-pi/pi-ai": "8.12.2",
|
|
89
|
+
"@oh-my-pi/pi-tui": "8.12.2",
|
|
90
|
+
"@oh-my-pi/pi-utils": "8.12.2",
|
|
91
91
|
"@openai/agents": "^0.4.4",
|
|
92
92
|
"@sinclair/typebox": "^0.34.48",
|
|
93
93
|
"ajv": "^8.17.1",
|
package/src/cursor.ts
CHANGED
|
@@ -194,12 +194,7 @@ export class CursorExecHandlers implements ICursorExecHandlers {
|
|
|
194
194
|
|
|
195
195
|
async shell(args: Parameters<NonNullable<ICursorExecHandlers["shell"]>>[0]) {
|
|
196
196
|
const toolCallId = decodeToolCallId(args.toolCallId);
|
|
197
|
-
const timeoutSeconds =
|
|
198
|
-
args.timeout && args.timeout > 0
|
|
199
|
-
? args.timeout > 1000
|
|
200
|
-
? Math.ceil(args.timeout / 1000)
|
|
201
|
-
: args.timeout
|
|
202
|
-
: undefined;
|
|
197
|
+
const timeoutSeconds = args.timeout && args.timeout > 0 ? args.timeout : undefined;
|
|
203
198
|
const toolResultMessage = await executeTool(this.options, "bash", toolCallId, {
|
|
204
199
|
command: args.command,
|
|
205
200
|
workdir: args.workingDirectory || undefined,
|
|
@@ -6,6 +6,7 @@ import type { TUI } from "@oh-my-pi/pi-tui";
|
|
|
6
6
|
export class CountdownTimer {
|
|
7
7
|
private intervalId: ReturnType<typeof setInterval> | undefined;
|
|
8
8
|
private remainingSeconds: number;
|
|
9
|
+
private readonly initialMs: number;
|
|
9
10
|
|
|
10
11
|
constructor(
|
|
11
12
|
timeoutMs: number,
|
|
@@ -13,6 +14,7 @@ export class CountdownTimer {
|
|
|
13
14
|
private onTick: (seconds: number) => void,
|
|
14
15
|
private onExpire: () => void,
|
|
15
16
|
) {
|
|
17
|
+
this.initialMs = timeoutMs;
|
|
16
18
|
this.remainingSeconds = Math.ceil(timeoutMs / 1000);
|
|
17
19
|
this.onTick(this.remainingSeconds);
|
|
18
20
|
|
|
@@ -28,6 +30,13 @@ export class CountdownTimer {
|
|
|
28
30
|
}, 1000);
|
|
29
31
|
}
|
|
30
32
|
|
|
33
|
+
/** Reset the countdown to its initial value */
|
|
34
|
+
reset(): void {
|
|
35
|
+
this.remainingSeconds = Math.ceil(this.initialMs / 1000);
|
|
36
|
+
this.onTick(this.remainingSeconds);
|
|
37
|
+
this.tui?.requestRender();
|
|
38
|
+
}
|
|
39
|
+
|
|
31
40
|
dispose(): void {
|
|
32
41
|
if (this.intervalId) {
|
|
33
42
|
clearInterval(this.intervalId);
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Generic selector component for hooks.
|
|
3
3
|
* Displays a list of string options with keyboard navigation.
|
|
4
4
|
*/
|
|
5
|
-
import { Container, matchesKey, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui";
|
|
5
|
+
import { Container, matchesKey, Spacer, Text, type TUI, visibleWidth } from "@oh-my-pi/pi-tui";
|
|
6
6
|
import { theme } from "../../modes/theme/theme";
|
|
7
7
|
import { CountdownTimer } from "./countdown-timer";
|
|
8
8
|
import { DynamicBorder } from "./dynamic-border";
|
|
@@ -11,12 +11,36 @@ export interface HookSelectorOptions {
|
|
|
11
11
|
tui?: TUI;
|
|
12
12
|
timeout?: number;
|
|
13
13
|
initialIndex?: number;
|
|
14
|
+
outline?: boolean;
|
|
15
|
+
maxVisible?: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
class OutlinedList extends Container {
|
|
19
|
+
private lines: string[] = [];
|
|
20
|
+
|
|
21
|
+
setLines(lines: string[]): void {
|
|
22
|
+
this.lines = lines;
|
|
23
|
+
this.invalidate();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
render(width: number): string[] {
|
|
27
|
+
const borderColor = (text: string) => theme.fg("border", text);
|
|
28
|
+
const horizontal = borderColor(theme.boxSharp.horizontal.repeat(Math.max(1, width)));
|
|
29
|
+
const innerWidth = Math.max(1, width - 2);
|
|
30
|
+
const content = this.lines.map(line => {
|
|
31
|
+
const pad = Math.max(0, innerWidth - visibleWidth(line));
|
|
32
|
+
return `${borderColor(theme.boxSharp.vertical)}${line}${" ".repeat(pad)}${borderColor(theme.boxSharp.vertical)}`;
|
|
33
|
+
});
|
|
34
|
+
return [horizontal, ...content, horizontal];
|
|
35
|
+
}
|
|
14
36
|
}
|
|
15
37
|
|
|
16
38
|
export class HookSelectorComponent extends Container {
|
|
17
39
|
private options: string[];
|
|
18
40
|
private selectedIndex: number;
|
|
19
|
-
private
|
|
41
|
+
private maxVisible: number;
|
|
42
|
+
private listContainer: Container | undefined;
|
|
43
|
+
private outlinedList: OutlinedList | undefined;
|
|
20
44
|
private onSelectCallback: (option: string) => void;
|
|
21
45
|
private onCancelCallback: () => void;
|
|
22
46
|
private titleText: Text;
|
|
@@ -34,6 +58,7 @@ export class HookSelectorComponent extends Container {
|
|
|
34
58
|
|
|
35
59
|
this.options = options;
|
|
36
60
|
this.selectedIndex = Math.min(opts?.initialIndex ?? 0, options.length - 1);
|
|
61
|
+
this.maxVisible = Math.max(3, opts?.maxVisible ?? 12);
|
|
37
62
|
this.onSelectCallback = onSelect;
|
|
38
63
|
this.onCancelCallback = onCancel;
|
|
39
64
|
this.baseTitle = title;
|
|
@@ -62,8 +87,13 @@ export class HookSelectorComponent extends Container {
|
|
|
62
87
|
);
|
|
63
88
|
}
|
|
64
89
|
|
|
65
|
-
|
|
66
|
-
|
|
90
|
+
if (opts?.outline) {
|
|
91
|
+
this.outlinedList = new OutlinedList();
|
|
92
|
+
this.addChild(this.outlinedList);
|
|
93
|
+
} else {
|
|
94
|
+
this.listContainer = new Container();
|
|
95
|
+
this.addChild(this.listContainer);
|
|
96
|
+
}
|
|
67
97
|
this.addChild(new Spacer(1));
|
|
68
98
|
this.addChild(new Text(theme.fg("dim", "up/down navigate enter select esc cancel"), 1, 0));
|
|
69
99
|
this.addChild(new Spacer(1));
|
|
@@ -73,17 +103,38 @@ export class HookSelectorComponent extends Container {
|
|
|
73
103
|
}
|
|
74
104
|
|
|
75
105
|
private updateList(): void {
|
|
76
|
-
|
|
77
|
-
|
|
106
|
+
const lines: string[] = [];
|
|
107
|
+
const startIndex = Math.max(
|
|
108
|
+
0,
|
|
109
|
+
Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), this.options.length - this.maxVisible),
|
|
110
|
+
);
|
|
111
|
+
const endIndex = Math.min(startIndex + this.maxVisible, this.options.length);
|
|
112
|
+
|
|
113
|
+
for (let i = startIndex; i < endIndex; i++) {
|
|
78
114
|
const isSelected = i === this.selectedIndex;
|
|
79
115
|
const text = isSelected
|
|
80
116
|
? theme.fg("accent", `${theme.nav.cursor} `) + theme.fg("accent", this.options[i])
|
|
81
117
|
: ` ${theme.fg("text", this.options[i])}`;
|
|
82
|
-
|
|
118
|
+
lines.push(text);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (startIndex > 0 || endIndex < this.options.length) {
|
|
122
|
+
lines.push(theme.fg("dim", ` (${this.selectedIndex + 1}/${this.options.length})`));
|
|
123
|
+
}
|
|
124
|
+
if (this.outlinedList) {
|
|
125
|
+
this.outlinedList.setLines(lines);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
this.listContainer?.clear();
|
|
129
|
+
for (const line of lines) {
|
|
130
|
+
this.listContainer?.addChild(new Text(line, 1, 0));
|
|
83
131
|
}
|
|
84
132
|
}
|
|
85
133
|
|
|
86
134
|
handleInput(keyData: string): void {
|
|
135
|
+
// Reset countdown on any interaction
|
|
136
|
+
this.countdown?.reset();
|
|
137
|
+
|
|
87
138
|
if (matchesKey(keyData, "up") || keyData === "k") {
|
|
88
139
|
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
|
|
89
140
|
this.updateList();
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Component, TUI } from "@oh-my-pi/pi-tui";
|
|
1
|
+
import type { Component, OverlayHandle, TUI } from "@oh-my-pi/pi-tui";
|
|
2
2
|
import { Spacer, Text } from "@oh-my-pi/pi-tui";
|
|
3
3
|
import { logger } from "@oh-my-pi/pi-utils";
|
|
4
4
|
import { KeybindingsManager } from "../../config/keybindings";
|
|
@@ -18,6 +18,17 @@ import type { InteractiveModeContext } from "../../modes/types";
|
|
|
18
18
|
import { setTerminalTitle } from "../../utils/title-generator";
|
|
19
19
|
|
|
20
20
|
export class ExtensionUiController {
|
|
21
|
+
private hookSelectorOverlay: OverlayHandle | undefined;
|
|
22
|
+
private hookInputOverlay: OverlayHandle | undefined;
|
|
23
|
+
|
|
24
|
+
private readonly dialogOverlayOptions = {
|
|
25
|
+
anchor: "bottom-center",
|
|
26
|
+
width: "80%",
|
|
27
|
+
minWidth: 40,
|
|
28
|
+
maxHeight: "70%",
|
|
29
|
+
margin: 1,
|
|
30
|
+
} as const;
|
|
31
|
+
|
|
21
32
|
constructor(private ctx: InteractiveModeContext) {}
|
|
22
33
|
|
|
23
34
|
/**
|
|
@@ -491,6 +502,9 @@ export class ExtensionUiController {
|
|
|
491
502
|
dialogOptions?: ExtensionUIDialogOptions,
|
|
492
503
|
): Promise<string | undefined> {
|
|
493
504
|
const { promise, resolve } = Promise.withResolvers<string | undefined>();
|
|
505
|
+
this.hookSelectorOverlay?.hide();
|
|
506
|
+
this.hookSelectorOverlay = undefined;
|
|
507
|
+
const maxVisible = Math.max(4, Math.min(15, this.ctx.ui.terminal.rows - 12));
|
|
494
508
|
this.ctx.hookSelector = new HookSelectorComponent(
|
|
495
509
|
title,
|
|
496
510
|
options,
|
|
@@ -502,13 +516,15 @@ export class ExtensionUiController {
|
|
|
502
516
|
this.hideHookSelector();
|
|
503
517
|
resolve(undefined);
|
|
504
518
|
},
|
|
505
|
-
{
|
|
519
|
+
{
|
|
520
|
+
initialIndex: dialogOptions?.initialIndex,
|
|
521
|
+
timeout: dialogOptions?.timeout,
|
|
522
|
+
tui: this.ctx.ui,
|
|
523
|
+
outline: dialogOptions?.outline,
|
|
524
|
+
maxVisible,
|
|
525
|
+
},
|
|
506
526
|
);
|
|
507
|
-
|
|
508
|
-
this.ctx.editorContainer.clear();
|
|
509
|
-
this.ctx.editorContainer.addChild(this.ctx.hookSelector);
|
|
510
|
-
this.ctx.ui.setFocus(this.ctx.hookSelector);
|
|
511
|
-
this.ctx.ui.requestRender();
|
|
527
|
+
this.hookSelectorOverlay = this.ctx.ui.showOverlay(this.ctx.hookSelector, this.dialogOverlayOptions);
|
|
512
528
|
return promise;
|
|
513
529
|
}
|
|
514
530
|
|
|
@@ -516,8 +532,9 @@ export class ExtensionUiController {
|
|
|
516
532
|
* Hide the hook selector.
|
|
517
533
|
*/
|
|
518
534
|
hideHookSelector(): void {
|
|
519
|
-
this.ctx.
|
|
520
|
-
this.
|
|
535
|
+
this.ctx.hookSelector?.dispose();
|
|
536
|
+
this.hookSelectorOverlay?.hide();
|
|
537
|
+
this.hookSelectorOverlay = undefined;
|
|
521
538
|
this.ctx.hookSelector = undefined;
|
|
522
539
|
this.ctx.ui.setFocus(this.ctx.editor);
|
|
523
540
|
this.ctx.ui.requestRender();
|
|
@@ -536,6 +553,8 @@ export class ExtensionUiController {
|
|
|
536
553
|
*/
|
|
537
554
|
showHookInput(title: string, placeholder?: string): Promise<string | undefined> {
|
|
538
555
|
const { promise, resolve } = Promise.withResolvers<string | undefined>();
|
|
556
|
+
this.hookInputOverlay?.hide();
|
|
557
|
+
this.hookInputOverlay = undefined;
|
|
539
558
|
this.ctx.hookInput = new HookInputComponent(
|
|
540
559
|
title,
|
|
541
560
|
placeholder,
|
|
@@ -548,11 +567,7 @@ export class ExtensionUiController {
|
|
|
548
567
|
resolve(undefined);
|
|
549
568
|
},
|
|
550
569
|
);
|
|
551
|
-
|
|
552
|
-
this.ctx.editorContainer.clear();
|
|
553
|
-
this.ctx.editorContainer.addChild(this.ctx.hookInput);
|
|
554
|
-
this.ctx.ui.setFocus(this.ctx.hookInput);
|
|
555
|
-
this.ctx.ui.requestRender();
|
|
570
|
+
this.hookInputOverlay = this.ctx.ui.showOverlay(this.ctx.hookInput, this.dialogOverlayOptions);
|
|
556
571
|
return promise;
|
|
557
572
|
}
|
|
558
573
|
|
|
@@ -560,8 +575,9 @@ export class ExtensionUiController {
|
|
|
560
575
|
* Hide the hook input.
|
|
561
576
|
*/
|
|
562
577
|
hideHookInput(): void {
|
|
563
|
-
this.ctx.
|
|
564
|
-
this.
|
|
578
|
+
this.ctx.hookInput?.dispose();
|
|
579
|
+
this.hookInputOverlay?.hide();
|
|
580
|
+
this.hookInputOverlay = undefined;
|
|
565
581
|
this.ctx.hookInput = undefined;
|
|
566
582
|
this.ctx.ui.setFocus(this.ctx.editor);
|
|
567
583
|
this.ctx.ui.requestRender();
|
|
@@ -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,30 +61,24 @@ 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>
|
|
@@ -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>
|
package/src/system-prompt.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* System prompt construction and project context loading
|
|
3
3
|
*/
|
|
4
|
+
import * as fs from "node:fs/promises";
|
|
4
5
|
import * as os from "node:os";
|
|
5
6
|
import * as path from "node:path";
|
|
7
|
+
import { globPaths } from "@oh-my-pi/pi-utils";
|
|
6
8
|
import { $ } from "bun";
|
|
7
9
|
import chalk from "chalk";
|
|
8
10
|
import { contextFileCapability } from "./capability/context-file";
|
|
@@ -129,6 +131,24 @@ function stripQuotes(value: string): string {
|
|
|
129
131
|
|
|
130
132
|
const AGENTS_MD_PATTERN = "**/AGENTS.md";
|
|
131
133
|
const AGENTS_MD_LIMIT = 200;
|
|
134
|
+
const PROJECT_TREE_LIMIT = 2000;
|
|
135
|
+
const PROJECT_TREE_PER_DIR_LIMIT = 10;
|
|
136
|
+
const PROJECT_TREE_PER_DIR_DEPTH = 2;
|
|
137
|
+
const PROJECT_TREE_IGNORED = new Set([
|
|
138
|
+
".git",
|
|
139
|
+
".hg",
|
|
140
|
+
".svn",
|
|
141
|
+
".next",
|
|
142
|
+
".turbo",
|
|
143
|
+
".cache",
|
|
144
|
+
".venv",
|
|
145
|
+
".idea",
|
|
146
|
+
".vscode",
|
|
147
|
+
"build",
|
|
148
|
+
"dist",
|
|
149
|
+
"node_modules",
|
|
150
|
+
"target",
|
|
151
|
+
]);
|
|
132
152
|
|
|
133
153
|
interface AgentsMdSearch {
|
|
134
154
|
scopePath: string;
|
|
@@ -166,6 +186,197 @@ function buildAgentsMdSearch(cwd: string): AgentsMdSearch {
|
|
|
166
186
|
};
|
|
167
187
|
}
|
|
168
188
|
|
|
189
|
+
type ProjectTreeEntry = {
|
|
190
|
+
name: string;
|
|
191
|
+
isDirectory: boolean;
|
|
192
|
+
path: string;
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
type ProjectTreeScan = {
|
|
196
|
+
children: Map<string, ProjectTreeEntry[]>;
|
|
197
|
+
truncated: boolean;
|
|
198
|
+
truncatedDirs: Set<string>;
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const GLOB_TIMEOUT_MS = 5000;
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Scan project tree using fs.promises.glob with exclusion filters.
|
|
205
|
+
* Returns null if glob fails.
|
|
206
|
+
*/
|
|
207
|
+
async function scanProjectTreeWithGlob(root: string): Promise<ProjectTreeScan | null> {
|
|
208
|
+
let entries: string[];
|
|
209
|
+
try {
|
|
210
|
+
entries = await globPaths("**/*", {
|
|
211
|
+
cwd: root,
|
|
212
|
+
gitignore: true,
|
|
213
|
+
timeoutMs: GLOB_TIMEOUT_MS,
|
|
214
|
+
});
|
|
215
|
+
} catch {
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Build directory contents map from file list
|
|
220
|
+
// Map<dirPath, Map<entryPath, isDirectory>>
|
|
221
|
+
const dirContents = new Map<string, Map<string, boolean>>();
|
|
222
|
+
dirContents.set(root, new Map());
|
|
223
|
+
|
|
224
|
+
for (const entry of entries) {
|
|
225
|
+
const filePath = entry.trim();
|
|
226
|
+
if (!filePath) continue;
|
|
227
|
+
const absolutePath = path.join(root, filePath);
|
|
228
|
+
// Check static ignores on path components
|
|
229
|
+
const relative = path.relative(root, absolutePath);
|
|
230
|
+
const parts = relative.split(path.sep);
|
|
231
|
+
if (parts.some(p => PROJECT_TREE_IGNORED.has(p))) continue;
|
|
232
|
+
|
|
233
|
+
// Add file to its parent directory
|
|
234
|
+
const parent = path.dirname(absolutePath);
|
|
235
|
+
if (!dirContents.has(parent)) dirContents.set(parent, new Map());
|
|
236
|
+
dirContents.get(parent)!.set(absolutePath, false);
|
|
237
|
+
|
|
238
|
+
// Add all intermediate directories
|
|
239
|
+
let dir = parent;
|
|
240
|
+
while (dir.length >= root.length && dir !== path.dirname(dir)) {
|
|
241
|
+
const parentDir = path.dirname(dir);
|
|
242
|
+
if (!dirContents.has(parentDir)) dirContents.set(parentDir, new Map());
|
|
243
|
+
dirContents.get(parentDir)!.set(dir, true);
|
|
244
|
+
dir = parentDir;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// BFS to build the tree with limits
|
|
249
|
+
const children = new Map<string, ProjectTreeEntry[]>();
|
|
250
|
+
let entryCount = 0;
|
|
251
|
+
let truncated = false;
|
|
252
|
+
const truncatedDirs = new Set<string>();
|
|
253
|
+
|
|
254
|
+
const queue: Array<{ dirPath: string; depth: number }> = [{ dirPath: root, depth: 0 }];
|
|
255
|
+
let cursor = 0;
|
|
256
|
+
|
|
257
|
+
while (cursor < queue.length && !truncated) {
|
|
258
|
+
const { dirPath, depth } = queue[cursor];
|
|
259
|
+
cursor += 1;
|
|
260
|
+
|
|
261
|
+
const contents = dirContents.get(dirPath);
|
|
262
|
+
if (!contents || contents.size === 0) continue;
|
|
263
|
+
|
|
264
|
+
// Get stats for sorting
|
|
265
|
+
const entries = Array.from(contents.entries());
|
|
266
|
+
const withStats = await Promise.all(
|
|
267
|
+
entries.map(async ([entryPath, isDirectory]) => {
|
|
268
|
+
try {
|
|
269
|
+
const stats = await fs.stat(entryPath);
|
|
270
|
+
return { entryPath, isDirectory, mtimeMs: stats.mtimeMs };
|
|
271
|
+
} catch {
|
|
272
|
+
return { entryPath, isDirectory, mtimeMs: 0 };
|
|
273
|
+
}
|
|
274
|
+
}),
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
withStats.sort((a, b) => {
|
|
278
|
+
if (a.mtimeMs !== b.mtimeMs) return b.mtimeMs - a.mtimeMs;
|
|
279
|
+
return path.basename(a.entryPath).localeCompare(path.basename(b.entryPath));
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
const perDirLimit = depth >= PROJECT_TREE_PER_DIR_DEPTH ? PROJECT_TREE_PER_DIR_LIMIT : null;
|
|
283
|
+
const limited = perDirLimit === null ? withStats : withStats.slice(0, perDirLimit);
|
|
284
|
+
const hasMoreEntries = perDirLimit !== null && withStats.length > perDirLimit;
|
|
285
|
+
|
|
286
|
+
const mapped: ProjectTreeEntry[] = [];
|
|
287
|
+
for (const { entryPath, isDirectory } of limited) {
|
|
288
|
+
if (entryCount >= PROJECT_TREE_LIMIT) {
|
|
289
|
+
truncated = true;
|
|
290
|
+
break;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
mapped.push({
|
|
294
|
+
name: path.basename(entryPath),
|
|
295
|
+
isDirectory,
|
|
296
|
+
path: entryPath,
|
|
297
|
+
});
|
|
298
|
+
entryCount += 1;
|
|
299
|
+
|
|
300
|
+
if (isDirectory) {
|
|
301
|
+
queue.push({ dirPath: entryPath, depth: depth + 1 });
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (!truncated && hasMoreEntries) {
|
|
306
|
+
truncatedDirs.add(dirPath);
|
|
307
|
+
}
|
|
308
|
+
children.set(dirPath, mapped);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return { children, truncated, truncatedDirs };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
async function scanProjectTree(root: string): Promise<ProjectTreeScan> {
|
|
315
|
+
const globResult = await scanProjectTreeWithGlob(root);
|
|
316
|
+
if (globResult) return globResult;
|
|
317
|
+
return { children: new Map(), truncated: false, truncatedDirs: new Set() };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function renderProjectTree(scan: ProjectTreeScan, root: string): string {
|
|
321
|
+
const lines: string[] = [];
|
|
322
|
+
|
|
323
|
+
const collapseDir = (dirPath: string): { path: string; entries: ProjectTreeEntry[] } | null => {
|
|
324
|
+
let currentPath = dirPath;
|
|
325
|
+
while (true) {
|
|
326
|
+
const entries = scan.children.get(currentPath);
|
|
327
|
+
if (!entries || entries.length === 0) return null;
|
|
328
|
+
const files = entries.filter(entry => !entry.isDirectory);
|
|
329
|
+
const dirs = entries.filter(entry => entry.isDirectory);
|
|
330
|
+
if (files.length === 0 && dirs.length === 1 && !scan.truncatedDirs.has(currentPath)) {
|
|
331
|
+
currentPath = dirs[0].path;
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
return { path: currentPath, entries };
|
|
335
|
+
}
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
const renderDir = (dirPath: string, indent: string, isRoot: boolean): void => {
|
|
339
|
+
const collapsed = collapseDir(dirPath);
|
|
340
|
+
if (!collapsed) return;
|
|
341
|
+
const { path: collapsedPath, entries } = collapsed;
|
|
342
|
+
|
|
343
|
+
// For non-root directories, print the header and indent contents
|
|
344
|
+
const contentIndent = isRoot ? indent : `${indent} `;
|
|
345
|
+
if (!isRoot) {
|
|
346
|
+
const relative = path.relative(root, collapsedPath) || ".";
|
|
347
|
+
lines.push(`${indent}@ ${relative}`);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const files = entries.filter(entry => !entry.isDirectory);
|
|
351
|
+
const dirs = entries.filter(entry => entry.isDirectory);
|
|
352
|
+
|
|
353
|
+
for (const entry of files) {
|
|
354
|
+
lines.push(`${contentIndent}- ${entry.name}`);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if (scan.truncatedDirs.has(collapsedPath)) {
|
|
358
|
+
lines.push(`${contentIndent}- …`);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
for (const entry of dirs) {
|
|
362
|
+
renderDir(entry.path, contentIndent, false);
|
|
363
|
+
}
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
renderDir(root, "", true);
|
|
367
|
+
|
|
368
|
+
if (scan.truncated) {
|
|
369
|
+
lines.push("…");
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
return lines.join("\n");
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
async function buildProjectTreeSnapshot(root: string): Promise<string> {
|
|
376
|
+
const scan = await scanProjectTree(root);
|
|
377
|
+
return renderProjectTree(scan, root);
|
|
378
|
+
}
|
|
379
|
+
|
|
169
380
|
function getOsName(): string {
|
|
170
381
|
switch (process.platform) {
|
|
171
382
|
case "win32":
|
|
@@ -707,6 +918,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
|
|
|
707
918
|
// Resolve context files: use provided or discover
|
|
708
919
|
const contextFiles = providedContextFiles ?? (await loadProjectContextFiles({ cwd: resolvedCwd }));
|
|
709
920
|
const agentsMdSearch = buildAgentsMdSearch(resolvedCwd);
|
|
921
|
+
const projectTree = await buildProjectTreeSnapshot(resolvedCwd);
|
|
710
922
|
|
|
711
923
|
// Build tool descriptions array
|
|
712
924
|
// Priority: toolNames (explicit list) > tools (Map) > defaults
|
|
@@ -744,6 +956,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
|
|
|
744
956
|
customPrompt: resolvedCustomPrompt,
|
|
745
957
|
appendPrompt: resolvedAppendPrompt ?? "",
|
|
746
958
|
contextFiles,
|
|
959
|
+
projectTree,
|
|
747
960
|
agentsMdSearch,
|
|
748
961
|
git,
|
|
749
962
|
skills: filteredSkills,
|
|
@@ -759,6 +972,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
|
|
|
759
972
|
environment: await getEnvironmentInfo(),
|
|
760
973
|
systemPromptCustomization: systemPromptCustomization ?? "",
|
|
761
974
|
contextFiles,
|
|
975
|
+
projectTree,
|
|
762
976
|
agentsMdSearch,
|
|
763
977
|
git,
|
|
764
978
|
skills: filteredSkills,
|
package/src/tools/ask.ts
CHANGED
|
@@ -77,6 +77,7 @@ export interface AskToolDetails {
|
|
|
77
77
|
|
|
78
78
|
const OTHER_OPTION = "Other (type your own)";
|
|
79
79
|
const RECOMMENDED_SUFFIX = " (Recommended)";
|
|
80
|
+
const ASK_TIMEOUT_MS = 30000;
|
|
80
81
|
|
|
81
82
|
function getDoneOptionLabel(): string {
|
|
82
83
|
return `${theme.status.success} Done selecting`;
|
|
@@ -113,7 +114,7 @@ interface UIContext {
|
|
|
113
114
|
select(
|
|
114
115
|
prompt: string,
|
|
115
116
|
options: string[],
|
|
116
|
-
options_?: { initialIndex?: number; timeout?: number },
|
|
117
|
+
options_?: { initialIndex?: number; timeout?: number; outline?: boolean },
|
|
117
118
|
): Promise<string | undefined>;
|
|
118
119
|
input(prompt: string): Promise<string | undefined>;
|
|
119
120
|
}
|
|
@@ -131,7 +132,7 @@ async function askSingleQuestion(
|
|
|
131
132
|
|
|
132
133
|
if (multi) {
|
|
133
134
|
const selected = new Set<string>();
|
|
134
|
-
let cursorIndex = 0;
|
|
135
|
+
let cursorIndex = Math.min(Math.max(recommended ?? 0, 0), optionLabels.length - 1);
|
|
135
136
|
|
|
136
137
|
while (true) {
|
|
137
138
|
const opts: string[] = [];
|
|
@@ -148,13 +149,22 @@ async function askSingleQuestion(
|
|
|
148
149
|
opts.push(OTHER_OPTION);
|
|
149
150
|
|
|
150
151
|
const prefix = selected.size > 0 ? `(${selected.size} selected) ` : "";
|
|
151
|
-
const
|
|
152
|
+
const selectionStart = Date.now();
|
|
153
|
+
const choice = await ui.select(`${prefix}${question}`, opts, {
|
|
154
|
+
initialIndex: cursorIndex,
|
|
155
|
+
timeout: ASK_TIMEOUT_MS,
|
|
156
|
+
outline: true,
|
|
157
|
+
});
|
|
158
|
+
const elapsed = Date.now() - selectionStart;
|
|
159
|
+
const timedOut = elapsed >= ASK_TIMEOUT_MS;
|
|
152
160
|
|
|
153
161
|
if (choice === undefined || choice === doneLabel) break;
|
|
154
162
|
|
|
155
163
|
if (choice === OTHER_OPTION) {
|
|
156
|
-
|
|
157
|
-
|
|
164
|
+
if (!timedOut) {
|
|
165
|
+
const input = await ui.input("Enter your response:");
|
|
166
|
+
if (input) customInput = input;
|
|
167
|
+
}
|
|
158
168
|
break;
|
|
159
169
|
}
|
|
160
170
|
|
|
@@ -179,13 +189,18 @@ async function askSingleQuestion(
|
|
|
179
189
|
selected.add(opt);
|
|
180
190
|
}
|
|
181
191
|
}
|
|
192
|
+
|
|
193
|
+
if (timedOut) {
|
|
194
|
+
break;
|
|
195
|
+
}
|
|
182
196
|
}
|
|
183
197
|
selectedOptions = Array.from(selected);
|
|
184
198
|
} else {
|
|
185
199
|
const displayLabels = addRecommendedSuffix(optionLabels, recommended);
|
|
186
200
|
const choice = await ui.select(question, [...displayLabels, OTHER_OPTION], {
|
|
187
|
-
timeout:
|
|
201
|
+
timeout: ASK_TIMEOUT_MS,
|
|
188
202
|
initialIndex: recommended,
|
|
203
|
+
outline: true,
|
|
189
204
|
});
|
|
190
205
|
if (choice === OTHER_OPTION) {
|
|
191
206
|
const input = await ui.input("Enter your response:");
|
package/src/tools/fetch.ts
CHANGED
|
@@ -911,11 +911,8 @@ export class FetchTool implements AgentTool<typeof fetchSchema, FetchToolDetails
|
|
|
911
911
|
): Promise<AgentToolResult<FetchToolDetails>> {
|
|
912
912
|
const { url, timeout: rawTimeout = 20, raw = false } = params;
|
|
913
913
|
|
|
914
|
-
// Auto-convert milliseconds to seconds if value > 1000 (16+ min is unreasonable)
|
|
915
|
-
const timeoutSec = rawTimeout > 1000 ? rawTimeout / 1000 : rawTimeout;
|
|
916
|
-
|
|
917
914
|
// Clamp to valid range (seconds)
|
|
918
|
-
const effectiveTimeout = Math.min(Math.max(
|
|
915
|
+
const effectiveTimeout = Math.min(Math.max(rawTimeout, 1), 45);
|
|
919
916
|
|
|
920
917
|
if (signal?.aborted) {
|
|
921
918
|
throw new ToolAbortError();
|
package/src/tools/find.ts
CHANGED
|
@@ -3,7 +3,7 @@ import * as path from "node:path";
|
|
|
3
3
|
import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
|
|
4
4
|
import type { Component } from "@oh-my-pi/pi-tui";
|
|
5
5
|
import { Text } from "@oh-my-pi/pi-tui";
|
|
6
|
-
import { isEnoent, untilAborted } from "@oh-my-pi/pi-utils";
|
|
6
|
+
import { globPaths, isEnoent, untilAborted } from "@oh-my-pi/pi-utils";
|
|
7
7
|
import type { Static } from "@sinclair/typebox";
|
|
8
8
|
import { Type } from "@sinclair/typebox";
|
|
9
9
|
import { renderPromptTemplate } from "../config/prompt-templates";
|
|
@@ -11,14 +11,12 @@ import type { RenderResultOptions } from "../extensibility/custom-tools/types";
|
|
|
11
11
|
import type { Theme } from "../modes/theme/theme";
|
|
12
12
|
import findDescription from "../prompts/tools/find.md" with { type: "text" };
|
|
13
13
|
import { renderFileList, renderStatusLine, renderTreeList } from "../tui";
|
|
14
|
-
import { ensureTool } from "../utils/tools-manager";
|
|
15
14
|
import type { ToolSession } from ".";
|
|
16
|
-
import { runRg } from "./grep";
|
|
17
15
|
import { applyListLimit } from "./list-limit";
|
|
18
16
|
import type { OutputMeta } from "./output-meta";
|
|
19
17
|
import { resolveToCwd } from "./path-utils";
|
|
20
18
|
import { formatCount, formatEmptyMessage, formatErrorMessage, PREVIEW_LIMITS } from "./render-utils";
|
|
21
|
-
import { ToolError, throwIfAborted } from "./tool-errors";
|
|
19
|
+
import { ToolAbortError, ToolError, throwIfAborted } from "./tool-errors";
|
|
22
20
|
import { toolResult } from "./tool-result";
|
|
23
21
|
import { type TruncationResult, truncateHead } from "./truncate";
|
|
24
22
|
|
|
@@ -30,7 +28,7 @@ const findSchema = Type.Object({
|
|
|
30
28
|
});
|
|
31
29
|
|
|
32
30
|
const DEFAULT_LIMIT = 1000;
|
|
33
|
-
const
|
|
31
|
+
const GLOB_TIMEOUT_MS = 5000;
|
|
34
32
|
|
|
35
33
|
export interface FindToolDetails {
|
|
36
34
|
truncation?: TruncationResult;
|
|
@@ -80,7 +78,7 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
|
|
|
80
78
|
params: Static<typeof findSchema>,
|
|
81
79
|
signal?: AbortSignal,
|
|
82
80
|
_onUpdate?: AgentToolUpdateCallback<FindToolDetails>,
|
|
83
|
-
|
|
81
|
+
_context?: AgentToolContext,
|
|
84
82
|
): Promise<AgentToolResult<FindToolDetails>> {
|
|
85
83
|
const { pattern, path: searchDir, limit, hidden } = params;
|
|
86
84
|
|
|
@@ -107,7 +105,6 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
|
|
|
107
105
|
}
|
|
108
106
|
const includeHidden = hidden ?? true;
|
|
109
107
|
const globPattern = normalizedPattern.replace(/\\/g, "/");
|
|
110
|
-
const globMatcher = new Bun.Glob(globPattern);
|
|
111
108
|
|
|
112
109
|
// If custom operations provided with glob, use that instead of fd
|
|
113
110
|
if (this.customOps?.glob) {
|
|
@@ -171,44 +168,30 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
|
|
|
171
168
|
throw new ToolError(`Path is not a directory: ${searchPath}`);
|
|
172
169
|
}
|
|
173
170
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
171
|
+
let lines: string[];
|
|
172
|
+
try {
|
|
173
|
+
lines = await globPaths(globPattern, {
|
|
174
|
+
cwd: searchPath,
|
|
175
|
+
gitignore: true,
|
|
176
|
+
dot: includeHidden,
|
|
177
|
+
signal,
|
|
178
|
+
timeoutMs: GLOB_TIMEOUT_MS,
|
|
179
|
+
});
|
|
180
|
+
} catch (error) {
|
|
181
|
+
if (error instanceof Error && error.name === "AbortError") {
|
|
182
|
+
throw new ToolAbortError();
|
|
183
|
+
}
|
|
184
|
+
if (error instanceof Error && error.name === "TimeoutError") {
|
|
185
|
+
const timeoutSeconds = Math.max(1, Math.round(GLOB_TIMEOUT_MS / 1000));
|
|
186
|
+
throw new ToolError(`glob timed out after ${timeoutSeconds}s`);
|
|
187
|
+
}
|
|
188
|
+
throw error;
|
|
181
189
|
}
|
|
182
190
|
|
|
183
|
-
|
|
184
|
-
"--files",
|
|
185
|
-
...(includeHidden ? ["--hidden"] : []),
|
|
186
|
-
"--no-require-git",
|
|
187
|
-
"--color=never",
|
|
188
|
-
"--glob",
|
|
189
|
-
"!**/.git/**",
|
|
190
|
-
"--glob",
|
|
191
|
-
"!**/node_modules/**",
|
|
192
|
-
searchPath,
|
|
193
|
-
];
|
|
194
|
-
|
|
195
|
-
// Run rg with timeout
|
|
196
|
-
const mainTimeoutSignal = AbortSignal.timeout(RG_TIMEOUT_MS);
|
|
197
|
-
const mainCombinedSignal = signal ? AbortSignal.any([signal, mainTimeoutSignal]) : mainTimeoutSignal;
|
|
198
|
-
const { stdout, stderr, exitCode } = await runRg(rgPath, args, mainCombinedSignal);
|
|
199
|
-
const output = stdout.trim();
|
|
200
|
-
|
|
201
|
-
// rg exit codes: 0 = found files, 1 = no matches, other = error
|
|
202
|
-
// Treat exit code 1 with no output as "no files found"
|
|
203
|
-
if (!output) {
|
|
204
|
-
if (exitCode !== 0 && exitCode !== 1) {
|
|
205
|
-
throw new ToolError(stderr.trim() || `rg failed (exit ${exitCode})`);
|
|
206
|
-
}
|
|
191
|
+
if (lines.length === 0) {
|
|
207
192
|
const details: FindToolDetails = { scopePath, fileCount: 0, files: [], truncated: false };
|
|
208
193
|
return toolResult(details).text("No files found matching pattern").done();
|
|
209
194
|
}
|
|
210
|
-
|
|
211
|
-
const lines = output.split("\n");
|
|
212
195
|
const relativized: string[] = [];
|
|
213
196
|
const mtimes: number[] = [];
|
|
214
197
|
|
|
@@ -220,16 +203,7 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
|
|
|
220
203
|
}
|
|
221
204
|
|
|
222
205
|
const hadTrailingSlash = line.endsWith("/") || line.endsWith("\\");
|
|
223
|
-
let relativePath = line;
|
|
224
|
-
if (line.startsWith(searchPath)) {
|
|
225
|
-
relativePath = line.slice(searchPath.length + 1); // +1 for the /
|
|
226
|
-
} else {
|
|
227
|
-
relativePath = path.relative(searchPath, line);
|
|
228
|
-
}
|
|
229
|
-
const matchPath = relativePath.replace(/\\/g, "/");
|
|
230
|
-
if (!globMatcher.match(matchPath)) {
|
|
231
|
-
continue;
|
|
232
|
-
}
|
|
206
|
+
let relativePath = line.replace(/\\/g, "/");
|
|
233
207
|
|
|
234
208
|
let mtimeMs = 0;
|
|
235
209
|
let isDirectory = false;
|
|
@@ -691,10 +691,8 @@ export const geminiImageTool: CustomTool<typeof geminiImageSchema, GeminiImageTo
|
|
|
691
691
|
}
|
|
692
692
|
|
|
693
693
|
const { timeout: rawTimeout = DEFAULT_TIMEOUT_SECONDS } = params;
|
|
694
|
-
// Auto-convert milliseconds to seconds if value > 1000 (16+ min is unreasonable)
|
|
695
|
-
let timeoutSeconds = rawTimeout > 1000 ? rawTimeout / 1000 : rawTimeout;
|
|
696
694
|
// Clamp to reasonable range: 1s - 600s (10 min)
|
|
697
|
-
timeoutSeconds = Math.max(1, Math.min(600,
|
|
695
|
+
const timeoutSeconds = Math.max(1, Math.min(600, rawTimeout));
|
|
698
696
|
const requestSignal = createRequestSignal(signal, timeoutSeconds);
|
|
699
697
|
|
|
700
698
|
if (provider === "antigravity") {
|
package/src/tools/grep.ts
CHANGED
|
@@ -73,13 +73,22 @@ export interface RgResult {
|
|
|
73
73
|
*
|
|
74
74
|
* @throws ToolAbortError if signal is aborted
|
|
75
75
|
*/
|
|
76
|
-
export async function runRg(
|
|
77
|
-
|
|
76
|
+
export async function runRg(
|
|
77
|
+
rgPath: string,
|
|
78
|
+
args: string[],
|
|
79
|
+
options?: { signal?: AbortSignal; timeoutMs?: number },
|
|
80
|
+
): Promise<RgResult> {
|
|
81
|
+
const child = ptree.cspawn([rgPath, ...args], { signal: options?.signal, timeout: options?.timeoutMs });
|
|
82
|
+
const timeoutSeconds = options?.timeoutMs ? Math.max(1, Math.round(options.timeoutMs / 1000)) : undefined;
|
|
83
|
+
const timeoutMessage = timeoutSeconds ? `rg timed out after ${timeoutSeconds}s` : "rg timed out";
|
|
78
84
|
|
|
79
85
|
let stdout: string;
|
|
80
86
|
try {
|
|
81
87
|
stdout = await child.nothrow().text();
|
|
82
88
|
} catch (err) {
|
|
89
|
+
if (err instanceof ptree.TimeoutError) {
|
|
90
|
+
throw new ToolError(timeoutMessage);
|
|
91
|
+
}
|
|
83
92
|
if (err instanceof ptree.Exception && err.aborted) {
|
|
84
93
|
throw new ToolAbortError();
|
|
85
94
|
}
|
|
@@ -91,6 +100,9 @@ export async function runRg(rgPath: string, args: string[], signal?: AbortSignal
|
|
|
91
100
|
await child.exited;
|
|
92
101
|
} catch (err) {
|
|
93
102
|
exitError = err;
|
|
103
|
+
if (err instanceof ptree.TimeoutError) {
|
|
104
|
+
throw new ToolError(timeoutMessage);
|
|
105
|
+
}
|
|
94
106
|
if (err instanceof ptree.Exception && err.aborted) {
|
|
95
107
|
throw new ToolAbortError();
|
|
96
108
|
}
|
package/src/tools/read.ts
CHANGED
|
@@ -4,7 +4,7 @@ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallb
|
|
|
4
4
|
import type { ImageContent, TextContent } from "@oh-my-pi/pi-ai";
|
|
5
5
|
import type { Component } from "@oh-my-pi/pi-tui";
|
|
6
6
|
import { Text } from "@oh-my-pi/pi-tui";
|
|
7
|
-
import { ptree } from "@oh-my-pi/pi-utils";
|
|
7
|
+
import { globPaths, ptree } from "@oh-my-pi/pi-utils";
|
|
8
8
|
import { Type } from "@sinclair/typebox";
|
|
9
9
|
import { CONFIG_DIR_NAME } from "../config";
|
|
10
10
|
import { renderPromptTemplate } from "../config/prompt-templates";
|
|
@@ -16,7 +16,6 @@ import { renderCodeCell, renderOutputBlock, renderStatusLine } from "../tui";
|
|
|
16
16
|
import { formatDimensionNote, resizeImage } from "../utils/image-resize";
|
|
17
17
|
import { detectSupportedImageMimeTypeFromFile } from "../utils/mime";
|
|
18
18
|
import { ensureTool } from "../utils/tools-manager";
|
|
19
|
-
import { runRg } from "./grep";
|
|
20
19
|
import { applyListLimit } from "./list-limit";
|
|
21
20
|
import { LsTool } from "./ls";
|
|
22
21
|
import type { OutputMeta } from "./output-meta";
|
|
@@ -49,6 +48,7 @@ const MAX_FUZZY_RESULTS = 5;
|
|
|
49
48
|
const MAX_FUZZY_CANDIDATES = 20000;
|
|
50
49
|
const MIN_BASE_SIMILARITY = 0.5;
|
|
51
50
|
const MIN_FULL_SIMILARITY = 0.6;
|
|
51
|
+
const GLOB_TIMEOUT_MS = 5000;
|
|
52
52
|
|
|
53
53
|
function normalizePathForMatch(value: string): string {
|
|
54
54
|
return value
|
|
@@ -162,95 +162,32 @@ function similarityScore(a: string, b: string): number {
|
|
|
162
162
|
async function listCandidateFiles(
|
|
163
163
|
searchRoot: string,
|
|
164
164
|
signal?: AbortSignal,
|
|
165
|
-
|
|
165
|
+
_notify?: (message: string) => void,
|
|
166
166
|
): Promise<{ files: string[]; truncated: boolean; error?: string }> {
|
|
167
|
-
let
|
|
167
|
+
let files: string[];
|
|
168
168
|
try {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
const args: string[] = [
|
|
179
|
-
"--files",
|
|
180
|
-
"--color=never",
|
|
181
|
-
"--hidden",
|
|
182
|
-
"--glob",
|
|
183
|
-
"!**/.git/**",
|
|
184
|
-
"--glob",
|
|
185
|
-
"!**/node_modules/**",
|
|
186
|
-
];
|
|
187
|
-
|
|
188
|
-
const gitignoreFiles = new Set<string>();
|
|
189
|
-
const rootGitignore = path.join(searchRoot, ".gitignore");
|
|
190
|
-
if (await Bun.file(rootGitignore).exists()) {
|
|
191
|
-
gitignoreFiles.add(rootGitignore);
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
try {
|
|
195
|
-
const gitignoreArgs = [
|
|
196
|
-
"--files",
|
|
197
|
-
"--color=never",
|
|
198
|
-
"--hidden",
|
|
199
|
-
"--no-ignore",
|
|
200
|
-
"--glob",
|
|
201
|
-
"!**/.git/**",
|
|
202
|
-
"--glob",
|
|
203
|
-
"!**/node_modules/**",
|
|
204
|
-
"--glob",
|
|
205
|
-
".gitignore",
|
|
206
|
-
searchRoot,
|
|
207
|
-
];
|
|
208
|
-
const { stdout } = await runRg(rgPath, gitignoreArgs, signal);
|
|
209
|
-
const output = stdout.trim();
|
|
210
|
-
if (output) {
|
|
211
|
-
const nestedGitignores = output
|
|
212
|
-
.split("\n")
|
|
213
|
-
.map(line => line.replace(/\r$/, "").trim())
|
|
214
|
-
.filter(line => line.length > 0);
|
|
215
|
-
for (const file of nestedGitignores) {
|
|
216
|
-
const normalized = file.replace(/\\/g, "/");
|
|
217
|
-
if (normalized.includes("/node_modules/") || normalized.includes("/.git/")) {
|
|
218
|
-
continue;
|
|
219
|
-
}
|
|
220
|
-
gitignoreFiles.add(file);
|
|
221
|
-
}
|
|
222
|
-
}
|
|
169
|
+
files = await globPaths("**/*", {
|
|
170
|
+
cwd: searchRoot,
|
|
171
|
+
gitignore: true,
|
|
172
|
+
dot: true,
|
|
173
|
+
signal,
|
|
174
|
+
timeoutMs: GLOB_TIMEOUT_MS,
|
|
175
|
+
});
|
|
223
176
|
} catch (error) {
|
|
224
|
-
if (error instanceof
|
|
225
|
-
throw
|
|
177
|
+
if (error instanceof Error && error.name === "AbortError") {
|
|
178
|
+
throw new ToolAbortError();
|
|
226
179
|
}
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
for (const gitignorePath of gitignoreFiles) {
|
|
231
|
-
args.push("--ignore-file", gitignorePath);
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
args.push(searchRoot);
|
|
235
|
-
|
|
236
|
-
const { stdout, stderr, exitCode } = await runRg(rgPath, args, signal);
|
|
237
|
-
const output = stdout.trim();
|
|
238
|
-
|
|
239
|
-
if (!output) {
|
|
240
|
-
// rg exit codes: 0 = ok, 1 = no matches, other = error
|
|
241
|
-
if (exitCode !== 0 && exitCode !== 1) {
|
|
242
|
-
return { files: [], truncated: false, error: stderr.trim() || `rg failed (exit ${exitCode})` };
|
|
180
|
+
if (error instanceof Error && error.name === "TimeoutError") {
|
|
181
|
+
const timeoutSeconds = Math.max(1, Math.round(GLOB_TIMEOUT_MS / 1000));
|
|
182
|
+
return { files: [], truncated: false, error: `glob timed out after ${timeoutSeconds}s` };
|
|
243
183
|
}
|
|
244
|
-
|
|
184
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
185
|
+
return { files: [], truncated: false, error: message };
|
|
245
186
|
}
|
|
246
187
|
|
|
247
|
-
const
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
.filter(line => line.length > 0);
|
|
251
|
-
|
|
252
|
-
const truncated = files.length > MAX_FUZZY_CANDIDATES;
|
|
253
|
-
const limited = truncated ? files.slice(0, MAX_FUZZY_CANDIDATES) : files;
|
|
188
|
+
const normalizedFiles = files.map(line => line.replace(/\r$/, "").trim()).filter(line => line.length > 0);
|
|
189
|
+
const truncated = normalizedFiles.length > MAX_FUZZY_CANDIDATES;
|
|
190
|
+
const limited = truncated ? normalizedFiles.slice(0, MAX_FUZZY_CANDIDATES) : normalizedFiles;
|
|
254
191
|
|
|
255
192
|
return { files: limited, truncated };
|
|
256
193
|
}
|
|
@@ -304,11 +241,7 @@ async function findReadPathSuggestions(
|
|
|
304
241
|
const cleaned = file.replace(/\r$/, "").trim();
|
|
305
242
|
if (!cleaned) continue;
|
|
306
243
|
|
|
307
|
-
const relativePath =
|
|
308
|
-
? cleaned.startsWith(searchRoot)
|
|
309
|
-
? cleaned.slice(searchRoot.length + 1)
|
|
310
|
-
: path.relative(searchRoot, cleaned)
|
|
311
|
-
: cleaned;
|
|
244
|
+
const relativePath = cleaned;
|
|
312
245
|
|
|
313
246
|
if (!relativePath || relativePath.startsWith("..")) {
|
|
314
247
|
continue;
|
package/src/tools/ssh.ts
CHANGED
|
@@ -162,10 +162,8 @@ export class SshTool implements AgentTool<typeof sshSchema, SSHToolDetails> {
|
|
|
162
162
|
const hostInfo = await ensureHostInfo(hostConfig);
|
|
163
163
|
const remoteCommand = buildRemoteCommand(command, cwd, hostInfo);
|
|
164
164
|
|
|
165
|
-
// Auto-convert milliseconds to seconds if value > 1000 (16+ min is unreasonable)
|
|
166
|
-
let timeoutSec = rawTimeout > 1000 ? rawTimeout / 1000 : rawTimeout;
|
|
167
165
|
// Clamp to reasonable range: 1s - 3600s (1 hour)
|
|
168
|
-
timeoutSec = Math.max(1, Math.min(3600,
|
|
166
|
+
const timeoutSec = Math.max(1, Math.min(3600, rawTimeout));
|
|
169
167
|
const timeoutMs = timeoutSec * 1000;
|
|
170
168
|
|
|
171
169
|
const tailBuffer = createTailBuffer(DEFAULT_MAX_BYTES);
|