@nmzpy/pi-ember-stack 0.1.5 → 0.2.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/README.md +4 -4
- package/package.json +12 -2
- package/plugins/devin-auth/extensions/index.ts +45 -7
- package/plugins/devin-auth/src/models.ts +42 -196
- package/plugins/index.ts +30 -7
- package/plugins/pi-compact-tools/index.ts +69 -48
- package/plugins/pi-compact-tools/renderer.ts +419 -0
- package/plugins/pi-custom-agents/index.ts +489 -343
- package/plugins/pi-custom-agents/questionnaire-tool.ts +1 -0
- package/plugins/pi-custom-agents/subagent/agents/coder.md +1 -0
- package/plugins/pi-custom-agents/subagent/agents/scout.md +16 -37
- package/plugins/pi-custom-agents/subagent/extensions/index.ts +6 -3
- package/plugins/pi-custom-agents/subagent/extensions/runner.ts +31 -5
- package/plugins/pi-ember-fff/index.ts +717 -0
- package/plugins/pi-ember-fff/query.ts +87 -0
- package/plugins/pi-ember-fff/test/query.test.ts +66 -0
- package/plugins/pi-ember-fff/test/renderer.test.ts +376 -0
- package/plugins/pi-ember-tps/index.ts +122 -0
- package/plugins/pi-ember-ui/ember.json +96 -0
- package/plugins/pi-ember-ui/index.ts +618 -0
- package/plugins/pi-ember-ui/mode-colors.ts +168 -0
- package/tsconfig.json +2 -1
- package/plugins/pi-custom-agents/subagent/agents/architect.md +0 -56
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
import { Text, type Component } from "@earendil-works/pi-tui";
|
|
2
|
+
|
|
3
|
+
const BULLET = "• ";
|
|
4
|
+
const DISCOVERY_TOOLS = new Set(["read", "grep", "find", "ls"]);
|
|
5
|
+
const GROUPABLE_TOOLS = new Set([...DISCOVERY_TOOLS, "bash"]);
|
|
6
|
+
|
|
7
|
+
export type ToolRenderContext = {
|
|
8
|
+
args: any;
|
|
9
|
+
toolCallId: string;
|
|
10
|
+
invalidate: () => void;
|
|
11
|
+
state: Record<string, any>;
|
|
12
|
+
expanded?: boolean;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type ToolRenderResultOptions = {
|
|
16
|
+
isPartial: boolean;
|
|
17
|
+
expanded?: boolean;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type CompactCall = {
|
|
21
|
+
id: string;
|
|
22
|
+
name: string;
|
|
23
|
+
args: any;
|
|
24
|
+
group?: DiscoveryGroup;
|
|
25
|
+
invalidate?: () => void;
|
|
26
|
+
isError: boolean;
|
|
27
|
+
_completed?: boolean;
|
|
28
|
+
result?: any;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type DiscoveryGroup = {
|
|
32
|
+
records: CompactCall[];
|
|
33
|
+
/**
|
|
34
|
+
* The record whose component currently renders the group header.
|
|
35
|
+
* The latest call to join the group becomes the owner so the
|
|
36
|
+
* header always renders in the current turn's visible component.
|
|
37
|
+
*/
|
|
38
|
+
renderOwner?: CompactCall;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
function textValue(value: unknown, fallback = ""): string {
|
|
42
|
+
if (value === undefined || value === null) return fallback;
|
|
43
|
+
return String(value).replace(/[\r\n]+/g, " ");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function toolPath(args: any): string {
|
|
47
|
+
return textValue(args?.file_path ?? args?.path, ".");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function bashCdDir(command: string): string | undefined {
|
|
51
|
+
const match = /^\s*cd\s+([^\s&]+)\s*&&\s*/.exec(command);
|
|
52
|
+
return match?.[1];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function groupKey(name: string, args: any): string | undefined {
|
|
56
|
+
if (DISCOVERY_TOOLS.has(name)) return "__discovery__";
|
|
57
|
+
if (name === "bash") {
|
|
58
|
+
const dir = bashCdDir(textValue(args?.command));
|
|
59
|
+
return dir ? `bash:${dir}` : undefined;
|
|
60
|
+
}
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function errorText(result: any, isError: boolean): string | undefined {
|
|
65
|
+
const content = result?.content?.find((item: any) => item.type === "text");
|
|
66
|
+
if (!isError && !content?.text?.startsWith("Error")) return undefined;
|
|
67
|
+
return textValue(content?.text, "Tool failed").split("\n")[0];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function fullOutputText(result: any): string {
|
|
71
|
+
const content = result?.content?.find((item: any) => item.type === "text");
|
|
72
|
+
const text = content?.text;
|
|
73
|
+
if (typeof text !== "string") return "";
|
|
74
|
+
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function formatExpandedOutput(result: any, theme: any): string {
|
|
78
|
+
const text = fullOutputText(result).trimEnd();
|
|
79
|
+
if (!text) return "";
|
|
80
|
+
return "\n" + text.split("\n").map((line) => theme.fg("text", line)).join("\n");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function bashLastLine(result: any): string | undefined {
|
|
84
|
+
const content = result?.content?.find((item: any) => item.type === "text");
|
|
85
|
+
const text = content?.text;
|
|
86
|
+
if (typeof text !== "string") return undefined;
|
|
87
|
+
const lines = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
|
|
88
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
89
|
+
const line = lines[i].trim();
|
|
90
|
+
if (line.length > 0) return line;
|
|
91
|
+
}
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function formatBashResultLine(result: any, theme: any): string {
|
|
96
|
+
const lastLine = bashLastLine(result);
|
|
97
|
+
if (lastLine === undefined) return "";
|
|
98
|
+
return "\n" + theme.fg("dim", " ") + theme.fg("text", lastLine);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function diffStats(result: any): { additions: number; removals: number } {
|
|
102
|
+
const diff = typeof result?.details?.diff === "string" ? result.details.diff : "";
|
|
103
|
+
let additions = 0;
|
|
104
|
+
let removals = 0;
|
|
105
|
+
for (const line of diff.split("\n")) {
|
|
106
|
+
if (line.startsWith("+") && !line.startsWith("+++")) additions++;
|
|
107
|
+
if (line.startsWith("-") && !line.startsWith("---")) removals++;
|
|
108
|
+
}
|
|
109
|
+
return { additions, removals };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function matchCount(result: any): number | undefined {
|
|
113
|
+
const total = result?.details?.totalMatched;
|
|
114
|
+
if (typeof total === "number") return total;
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function matchLabel(result: any, theme: any): string {
|
|
119
|
+
const total = matchCount(result);
|
|
120
|
+
if (total === undefined) return "";
|
|
121
|
+
const label = total === 1 ? "1 match" : `${total} matches`;
|
|
122
|
+
const color = total > 0 ? "success" : "muted";
|
|
123
|
+
return theme.fg("dim", " ") + theme.fg(color, label);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const PULSE_INTERVAL_MS = 600;
|
|
127
|
+
|
|
128
|
+
function bulletColor(record: CompactCall, theme: any): string {
|
|
129
|
+
if (record.isError) return theme.fg("error", BULLET);
|
|
130
|
+
if (record._completed) return theme.fg("success", BULLET);
|
|
131
|
+
const pulse = Math.floor(Date.now() / PULSE_INTERVAL_MS) % 2 === 0;
|
|
132
|
+
return pulse ? theme.fg("muted", BULLET) : theme.fg("dim", BULLET);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function formatEditStats(result: any, theme: any): string {
|
|
136
|
+
const { additions, removals } = diffStats(result);
|
|
137
|
+
return theme.fg("success", `+${additions}`) +
|
|
138
|
+
theme.fg("dim", " / ") +
|
|
139
|
+
theme.fg("error", `-${removals}`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function formatCallBody(name: string, args: any, theme: any, inGroup = false): string {
|
|
143
|
+
const pathName = toolPath(args);
|
|
144
|
+
switch (name) {
|
|
145
|
+
case "read":
|
|
146
|
+
return theme.fg("dim", theme.bold("Read")) +
|
|
147
|
+
theme.fg("dim", ` ${pathName}`);
|
|
148
|
+
case "grep":
|
|
149
|
+
return theme.fg("dim", theme.bold("Search")) +
|
|
150
|
+
theme.fg("dim", ` ${textValue(args?.pattern)}`) +
|
|
151
|
+
theme.fg("dim", ` in ${pathName}`);
|
|
152
|
+
case "find":
|
|
153
|
+
return theme.fg("dim", theme.bold("Find")) +
|
|
154
|
+
theme.fg("dim", ` ${textValue(args?.pattern)}`) +
|
|
155
|
+
theme.fg("dim", ` in ${pathName}`);
|
|
156
|
+
case "ls":
|
|
157
|
+
return theme.fg("dim", theme.bold("List")) +
|
|
158
|
+
theme.fg("dim", ` ${pathName}`);
|
|
159
|
+
case "bash": {
|
|
160
|
+
const cmd = textValue(args?.command);
|
|
161
|
+
const stripped = inGroup ? (cmd.replace(/^\s*cd\s+[^\s&]+\s*&&\s*/, "") || cmd) : cmd;
|
|
162
|
+
return theme.fg("dim", theme.bold("Run")) +
|
|
163
|
+
theme.fg("dim", ` $ ${stripped}`);
|
|
164
|
+
}
|
|
165
|
+
case "edit":
|
|
166
|
+
return theme.fg("dim", theme.bold("edit")) +
|
|
167
|
+
theme.fg("dim", ` ${pathName}`);
|
|
168
|
+
case "write":
|
|
169
|
+
return theme.fg("dim", theme.bold("write")) +
|
|
170
|
+
theme.fg("dim", ` ${pathName}`);
|
|
171
|
+
default:
|
|
172
|
+
return theme.fg("dim", theme.bold(name));
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function groupBulletColor(group: DiscoveryGroup, theme: any): string {
|
|
177
|
+
if (group.records.some((r) => r.isError)) return theme.fg("error", BULLET);
|
|
178
|
+
if (group.records.every((r) => r._completed)) return theme.fg("success", BULLET);
|
|
179
|
+
const pulse = Math.floor(Date.now() / PULSE_INTERVAL_MS) % 2 === 0;
|
|
180
|
+
return pulse ? theme.fg("muted", BULLET) : theme.fg("dim", BULLET);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function groupHeaderLabel(group: DiscoveryGroup): string {
|
|
184
|
+
const allDone = group.records.every((r) => r._completed);
|
|
185
|
+
const verb = allDone ? "Explored" : "Exploring";
|
|
186
|
+
const first = group.records[0];
|
|
187
|
+
if (!first) return verb;
|
|
188
|
+
const key = groupKey(first.name, first.args);
|
|
189
|
+
if (key?.startsWith("bash:")) {
|
|
190
|
+
const dir = key.slice(5);
|
|
191
|
+
return `${verb} ${dir}`;
|
|
192
|
+
}
|
|
193
|
+
return verb;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function formatGroup(group: DiscoveryGroup, theme: any): string {
|
|
197
|
+
const lines = [
|
|
198
|
+
groupBulletColor(group, theme) + theme.fg("text", theme.bold(groupHeaderLabel(group))),
|
|
199
|
+
];
|
|
200
|
+
for (const [index, record] of group.records.entries()) {
|
|
201
|
+
const prefix = index === 0 ? " └ " : " ";
|
|
202
|
+
const suffix = record._completed && (record.name === "grep" || record.name === "find")
|
|
203
|
+
? matchLabel(record.result, theme)
|
|
204
|
+
: "";
|
|
205
|
+
lines.push(
|
|
206
|
+
theme.fg("dim", prefix) +
|
|
207
|
+
bulletColor(record, theme) +
|
|
208
|
+
formatCallBody(record.name, record.args, theme, true) +
|
|
209
|
+
suffix,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
return lines.join("\n");
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export class CompactRenderer {
|
|
216
|
+
private readonly calls = new Map<string, CompactCall>();
|
|
217
|
+
private lastCall: CompactCall | undefined;
|
|
218
|
+
private lastGroupKey: string | undefined;
|
|
219
|
+
private pulseTimer: ReturnType<typeof setInterval> | null = null;
|
|
220
|
+
private readonly pendingPulses = new Set<CompactCall>();
|
|
221
|
+
|
|
222
|
+
private startPulse(record: CompactCall): void {
|
|
223
|
+
this.pendingPulses.add(record);
|
|
224
|
+
if (this.pulseTimer) return;
|
|
225
|
+
this.pulseTimer = setInterval(() => {
|
|
226
|
+
if (this.pendingPulses.size === 0) {
|
|
227
|
+
this.stopPulse();
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
for (const r of this.pendingPulses) r.invalidate?.();
|
|
231
|
+
}, PULSE_INTERVAL_MS);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
private stopPulse(): void {
|
|
235
|
+
if (this.pulseTimer) {
|
|
236
|
+
clearInterval(this.pulseTimer);
|
|
237
|
+
this.pulseTimer = null;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
beginTurn(): void {
|
|
242
|
+
// Do NOT reset lastCall / lastGroupKey here. Resetting breaks
|
|
243
|
+
// grouping when turn_start fires between renderCall registrations
|
|
244
|
+
// (e.g. parallel bash calls with the same cd dir). Consecutive
|
|
245
|
+
// calls with the same group key should always group, even across
|
|
246
|
+
// turn boundaries. Different keys naturally don't group.
|
|
247
|
+
this.pendingPulses.clear();
|
|
248
|
+
this.stopPulse();
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
observeCall(name: string, id: string, args: any): CompactCall {
|
|
252
|
+
return this.registerCall(name, id, args);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
registerCall(
|
|
256
|
+
name: string,
|
|
257
|
+
id: string,
|
|
258
|
+
args: any,
|
|
259
|
+
invalidate?: () => void,
|
|
260
|
+
): CompactCall {
|
|
261
|
+
const existing = this.calls.get(id);
|
|
262
|
+
if (existing) {
|
|
263
|
+
existing.args = args;
|
|
264
|
+
existing.invalidate = invalidate ?? existing.invalidate;
|
|
265
|
+
return existing;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const record: CompactCall = { id, name, args, isError: false };
|
|
269
|
+
this.calls.set(id, record);
|
|
270
|
+
const key = groupKey(name, args);
|
|
271
|
+
if (key && this.lastCall && key === this.lastGroupKey) {
|
|
272
|
+
const group = this.lastCall.group ?? { records: [this.lastCall], renderOwner: this.lastCall };
|
|
273
|
+
this.lastCall.group = group;
|
|
274
|
+
// New record joins the group — become renderOwner so the group
|
|
275
|
+
// header renders in this call's (current-turn) component.
|
|
276
|
+
const prevOwner = group.renderOwner;
|
|
277
|
+
group.renderOwner = record;
|
|
278
|
+
if (prevOwner && prevOwner !== record) prevOwner.invalidate?.();
|
|
279
|
+
group.records.push(record);
|
|
280
|
+
record.group = group;
|
|
281
|
+
for (const groupedCall of group.records) groupedCall.invalidate?.();
|
|
282
|
+
}
|
|
283
|
+
this.lastCall = record;
|
|
284
|
+
this.lastGroupKey = key;
|
|
285
|
+
record.invalidate = invalidate;
|
|
286
|
+
this.startPulse(record);
|
|
287
|
+
return record;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
setResult(record: CompactCall, result: any, isError: boolean): void {
|
|
291
|
+
if (record._completed && record.result === result && record.isError === isError) return;
|
|
292
|
+
record.isError = isError;
|
|
293
|
+
record._completed = true;
|
|
294
|
+
record.result = result;
|
|
295
|
+
this.pendingPulses.delete(record);
|
|
296
|
+
if (this.pendingPulses.size === 0) this.stopPulse();
|
|
297
|
+
if (record.group) {
|
|
298
|
+
record.group.renderOwner?.invalidate?.();
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
renderCall(
|
|
303
|
+
name: string,
|
|
304
|
+
args: any,
|
|
305
|
+
theme: any,
|
|
306
|
+
context: ToolRenderContext,
|
|
307
|
+
): Component {
|
|
308
|
+
try {
|
|
309
|
+
return this.renderCallInner(name, args, theme, context);
|
|
310
|
+
} catch {
|
|
311
|
+
// Never throw: Pi's fallback would dump raw content. Return a
|
|
312
|
+
// compact call row instead.
|
|
313
|
+
return new Text(
|
|
314
|
+
theme.fg("muted", BULLET) + formatCallBody(name, args, theme),
|
|
315
|
+
0, 0,
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
private renderCallInner(
|
|
321
|
+
name: string,
|
|
322
|
+
args: any,
|
|
323
|
+
theme: any,
|
|
324
|
+
context: ToolRenderContext,
|
|
325
|
+
): Component {
|
|
326
|
+
const record = this.registerCall(name, context.toolCallId, args, context.invalidate);
|
|
327
|
+
if (record.group && record.group.records.length > 1) {
|
|
328
|
+
if (record.group.renderOwner !== record) return new Text("", 0, 0);
|
|
329
|
+
return new Text(formatGroup(record.group, theme), 0, 0);
|
|
330
|
+
}
|
|
331
|
+
const callText = context.state.callText instanceof Text
|
|
332
|
+
? context.state.callText
|
|
333
|
+
: new Text("", 0, 0);
|
|
334
|
+
context.state.callText = callText;
|
|
335
|
+
callText.setText(
|
|
336
|
+
bulletColor(record, theme) + formatCallBody(name, args, theme),
|
|
337
|
+
);
|
|
338
|
+
return callText;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
renderResult(
|
|
342
|
+
name: string,
|
|
343
|
+
args: any,
|
|
344
|
+
result: any,
|
|
345
|
+
options: ToolRenderResultOptions,
|
|
346
|
+
theme: any,
|
|
347
|
+
context: ToolRenderContext & { isError: boolean },
|
|
348
|
+
): Component {
|
|
349
|
+
try {
|
|
350
|
+
return this.renderResultInner(name, args, result, options, theme, context);
|
|
351
|
+
} catch {
|
|
352
|
+
// Never throw: Pi's fallback would dump the full tool output
|
|
353
|
+
// (e.g. entire file contents for read). Return an empty result
|
|
354
|
+
// row — the call row already shows the compact summary.
|
|
355
|
+
return new Text("", 0, 0);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
private renderResultInner(
|
|
360
|
+
name: string,
|
|
361
|
+
args: any,
|
|
362
|
+
result: any,
|
|
363
|
+
options: ToolRenderResultOptions,
|
|
364
|
+
theme: any,
|
|
365
|
+
context: ToolRenderContext & { isError: boolean },
|
|
366
|
+
): Component {
|
|
367
|
+
const record = this.registerCall(name, context.toolCallId, args, context.invalidate);
|
|
368
|
+
this.setResult(record, result, context.isError);
|
|
369
|
+
const expanded = options.expanded === true;
|
|
370
|
+
|
|
371
|
+
if (record.group && record.group.records.length > 1) {
|
|
372
|
+
if (record.group.renderOwner !== record) return new Text("", 0, 0);
|
|
373
|
+
if (expanded && !options.isPartial) {
|
|
374
|
+
const output = formatExpandedOutput(result, theme);
|
|
375
|
+
if (output) return new Text(output, 0, 0);
|
|
376
|
+
}
|
|
377
|
+
const error = errorText(result, context.isError);
|
|
378
|
+
return error ? new Text(theme.fg("error", error), 0, 0) : new Text("", 0, 0);
|
|
379
|
+
}
|
|
380
|
+
if (options.isPartial) return new Text("", 0, 0);
|
|
381
|
+
|
|
382
|
+
const error = errorText(result, context.isError);
|
|
383
|
+
const callText = context.state.callText;
|
|
384
|
+
if (callText instanceof Text) {
|
|
385
|
+
if (name === "edit") {
|
|
386
|
+
callText.setText(
|
|
387
|
+
bulletColor(record, theme) +
|
|
388
|
+
formatCallBody(name, args, theme) +
|
|
389
|
+
theme.fg("dim", " ") +
|
|
390
|
+
formatEditStats(result, theme),
|
|
391
|
+
);
|
|
392
|
+
} else if (name === "grep" || name === "find") {
|
|
393
|
+
callText.setText(
|
|
394
|
+
bulletColor(record, theme) +
|
|
395
|
+
formatCallBody(name, args, theme) +
|
|
396
|
+
matchLabel(result, theme),
|
|
397
|
+
);
|
|
398
|
+
} else if (name === "bash") {
|
|
399
|
+
callText.setText(
|
|
400
|
+
bulletColor(record, theme) +
|
|
401
|
+
formatCallBody(name, args, theme) +
|
|
402
|
+
formatBashResultLine(result, theme),
|
|
403
|
+
);
|
|
404
|
+
} else {
|
|
405
|
+
callText.setText(
|
|
406
|
+
bulletColor(record, theme) + formatCallBody(name, args, theme),
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
if (error) return new Text(theme.fg("error", error), 0, 0);
|
|
411
|
+
if (expanded) {
|
|
412
|
+
const output = formatExpandedOutput(result, theme);
|
|
413
|
+
if (output) return new Text(output, 0, 0);
|
|
414
|
+
}
|
|
415
|
+
return new Text("", 0, 0);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
export { BULLET, DISCOVERY_TOOLS, GROUPABLE_TOOLS };
|