@jaybeeuu/agent-cortex 2.3.0 → 2.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +61 -0
- package/README.md +25 -6
- package/bin/installers/claude.mjs +108 -5
- package/bin/installers/pi.mjs +176 -18
- package/extensions/agent-guardrails/package.json +0 -3
- package/extensions/agent-modes/index.ts +4 -3
- package/extensions/agent-modes/package.json +1 -5
- package/extensions/auto-name.ts +16 -49
- package/extensions/lib/tiny-model.ts +15 -151
- package/extensions/notify/index.ts +9 -59
- package/extensions/output-style/package.json +0 -3
- package/extensions/session-start/package.json +0 -3
- package/extensions/skill-stats/package.json +0 -3
- package/extensions/subagent/README.md +2 -2
- package/extensions/subagent/agents.ts +17 -1
- package/extensions/subagent/components.test.ts +111 -0
- package/extensions/subagent/components.ts +182 -0
- package/extensions/subagent/frontmatter.test.ts +74 -0
- package/extensions/subagent/frontmatter.ts +105 -0
- package/extensions/subagent/index.ts +14 -12
- package/extensions/subagent/package.json +3 -4
- package/package.json +1 -1
- package/skills/planning/create-task/templates/review.md +13 -8
- package/skills/review/review-security/SKILL.md +4 -1
package/extensions/auto-name.ts
CHANGED
|
@@ -1,30 +1,29 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Auto-name sessions
|
|
2
|
+
* Auto-name sessions from the first user prompt.
|
|
3
3
|
*
|
|
4
|
-
* On the first
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* On the first prompt of a new session, names the session with a trimmed
|
|
5
|
+
* version of that prompt, then sets it via pi.setSessionName(). Existing names
|
|
6
|
+
* (set via --name or /name) are never overwritten.
|
|
7
|
+
*
|
|
8
|
+
* The name used to come from a cheap side-model, which needed pi-ai's
|
|
9
|
+
* `complete()` — a value import that stopped this extension loading outside the
|
|
10
|
+
* agent-cortex checkout (see test/pi-extensions-load.test.mjs). The truncation
|
|
11
|
+
* that already served as its fallback is now the only path; `tinyModel.enabled`
|
|
12
|
+
* still turns naming off entirely.
|
|
8
13
|
*
|
|
9
14
|
* Config (in settings.json under `tinyModel`):
|
|
10
15
|
* ```json
|
|
11
16
|
* {
|
|
12
17
|
* "tinyModel": {
|
|
13
|
-
* "
|
|
18
|
+
* "enabled": true,
|
|
14
19
|
* "maxNameLength": 50
|
|
15
20
|
* }
|
|
16
21
|
* }
|
|
17
22
|
* ```
|
|
18
|
-
*
|
|
19
|
-
* Existing names (set via --name or /name) are never overwritten.
|
|
20
23
|
*/
|
|
21
24
|
|
|
22
25
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
23
|
-
import {
|
|
24
|
-
loadTinyModelConfig,
|
|
25
|
-
parseModelString,
|
|
26
|
-
generateNameFromPrompt,
|
|
27
|
-
} from "./lib/tiny-model.js";
|
|
26
|
+
import { loadTinyModelConfig } from "./lib/tiny-model.js";
|
|
28
27
|
import type { TinyModelConfig } from "./lib/tiny-model.js";
|
|
29
28
|
|
|
30
29
|
export default function (pi: ExtensionAPI) {
|
|
@@ -41,7 +40,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
41
40
|
}
|
|
42
41
|
});
|
|
43
42
|
|
|
44
|
-
pi.on("input", async (event,
|
|
43
|
+
pi.on("input", async (event, _ctx) => {
|
|
45
44
|
if (!config.enabled || hasNamed) return;
|
|
46
45
|
if (event.source === "rpc") return;
|
|
47
46
|
if (pi.getSessionName()) {
|
|
@@ -51,46 +50,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
51
50
|
if (!event.text?.trim()) return;
|
|
52
51
|
|
|
53
52
|
hasNamed = true;
|
|
54
|
-
|
|
55
|
-
// Fire the LLM call asynchronously — don't block the agent
|
|
56
|
-
(async () => {
|
|
57
|
-
try {
|
|
58
|
-
const { provider, id } = parseModelString(config.model);
|
|
59
|
-
const model = ctx.modelRegistry.find(provider, id);
|
|
60
|
-
if (!model) {
|
|
61
|
-
// Model not found; fall back to truncation
|
|
62
|
-
fallbackName(event.text, config.maxNameLength, pi);
|
|
63
|
-
return;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
const { apiKey, headers } =
|
|
67
|
-
await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
68
|
-
|
|
69
|
-
const name = await generateNameFromPrompt(
|
|
70
|
-
model,
|
|
71
|
-
apiKey,
|
|
72
|
-
headers,
|
|
73
|
-
event.text,
|
|
74
|
-
config.maxNameLength,
|
|
75
|
-
);
|
|
76
|
-
|
|
77
|
-
if (name) {
|
|
78
|
-
pi.setSessionName(name);
|
|
79
|
-
} else {
|
|
80
|
-
fallbackName(event.text, config.maxNameLength, pi);
|
|
81
|
-
}
|
|
82
|
-
} catch (err) {
|
|
83
|
-
console.warn("[auto-name] LLM call failed, falling back:", err);
|
|
84
|
-
fallbackName(event.text, config.maxNameLength, pi);
|
|
85
|
-
}
|
|
86
|
-
})();
|
|
53
|
+
nameFromPrompt(event.text, config.maxNameLength, pi);
|
|
87
54
|
});
|
|
88
55
|
}
|
|
89
56
|
|
|
90
57
|
/**
|
|
91
|
-
*
|
|
58
|
+
* Name the session with the first line of the prompt, truncated to `maxLength`.
|
|
92
59
|
*/
|
|
93
|
-
function
|
|
60
|
+
function nameFromPrompt(
|
|
94
61
|
text: string,
|
|
95
62
|
maxLength: number,
|
|
96
63
|
pi: ExtensionAPI,
|
|
@@ -1,43 +1,45 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* The `tinyModel` config block shared by the extensions that used to call a
|
|
3
|
+
* cheap side-model: session naming (auto-name) and response summaries (notify).
|
|
3
4
|
*
|
|
4
|
-
* Reads
|
|
5
|
-
* and provides functions for session naming and response summarization using
|
|
6
|
-
* the configured model — typically the tiniest free model available.
|
|
7
|
-
*
|
|
8
|
-
* Config (in ~/.pi/agent/settings.json or .pi/settings.json):
|
|
5
|
+
* Reads the key from settings.json (global + project merged):
|
|
9
6
|
* ```json
|
|
10
7
|
* {
|
|
11
8
|
* "tinyModel": {
|
|
12
|
-
* "
|
|
9
|
+
* "enabled": true,
|
|
13
10
|
* "maxNameLength": 50,
|
|
14
11
|
* "maxSummaryLength": 100
|
|
15
12
|
* }
|
|
16
13
|
* }
|
|
17
14
|
* ```
|
|
15
|
+
*
|
|
16
|
+
* The model call itself is gone. It went through pi-ai's `complete()`, a value
|
|
17
|
+
* import of pi's runtime, and that import stopped the extension loading outside
|
|
18
|
+
* the agent-cortex checkout (see test/pi-extensions-load.test.mjs). The
|
|
19
|
+
* ExtensionAPI promises no completion call. Newer hosts (pi 1.4.0) expose
|
|
20
|
+
* `ModelRegistry.complete`, but the installed host (0.73.1) does not. Calling it
|
|
21
|
+
* would need an untyped cast to a method that exists on one version only.
|
|
22
|
+
* Extensions also make no internal LLM calls (AGENTS.md), so this feature stays
|
|
23
|
+
* off. Both callers fall back to the truncation they already used when the model
|
|
24
|
+
* failed. The `model` key is no longer read and is ignored if present.
|
|
18
25
|
*/
|
|
19
26
|
|
|
20
|
-
import { complete } from "@earendil-works/pi-ai";
|
|
21
27
|
import { homedir } from "node:os";
|
|
22
28
|
import { join } from "node:path";
|
|
23
29
|
import { readFileSync, existsSync } from "node:fs";
|
|
24
|
-
import type { Model, Api } from "@earendil-works/pi-ai";
|
|
25
30
|
|
|
26
31
|
/* ── Types ── */
|
|
27
32
|
|
|
28
33
|
export interface TinyModelConfig {
|
|
29
|
-
/** Provider/id string, e.g. "opencode/mimo-v2.5-free" */
|
|
30
|
-
model: string;
|
|
31
34
|
/** Global on/off toggle */
|
|
32
35
|
enabled: boolean;
|
|
33
|
-
/** Max characters for
|
|
36
|
+
/** Max characters for generated session names */
|
|
34
37
|
maxNameLength: number;
|
|
35
38
|
/** Max characters for response summaries in notifications */
|
|
36
39
|
maxSummaryLength: number;
|
|
37
40
|
}
|
|
38
41
|
|
|
39
42
|
const DEFAULT_CONFIG: TinyModelConfig = {
|
|
40
|
-
model: "opencode/mimo-v2.5-free",
|
|
41
43
|
enabled: true,
|
|
42
44
|
maxNameLength: 40,
|
|
43
45
|
maxSummaryLength: 100,
|
|
@@ -72,141 +74,3 @@ export function loadTinyModelConfig(cwd?: string): TinyModelConfig {
|
|
|
72
74
|
|
|
73
75
|
return { ...DEFAULT_CONFIG, ...merged } as TinyModelConfig;
|
|
74
76
|
}
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* Parse a "provider/id" string into its parts.
|
|
78
|
-
*/
|
|
79
|
-
export function parseModelString(modelStr: string): {
|
|
80
|
-
provider: string;
|
|
81
|
-
id: string;
|
|
82
|
-
} {
|
|
83
|
-
const slash = modelStr.indexOf("/");
|
|
84
|
-
if (slash === -1) return { provider: "opencode", id: modelStr };
|
|
85
|
-
return {
|
|
86
|
-
provider: modelStr.slice(0, slash),
|
|
87
|
-
id: modelStr.slice(slash + 1),
|
|
88
|
-
};
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/* ── LLM helpers ── */
|
|
92
|
-
|
|
93
|
-
type ModelAny = Model<Api>;
|
|
94
|
-
type CompleteOptions = Record<string, any>;
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* Generate a concise session title from the user's first prompt.
|
|
98
|
-
*
|
|
99
|
-
* Calls the configured tiny model with a system prompt tuned for naming.
|
|
100
|
-
* Returns null on failure (caller should fall back to truncation).
|
|
101
|
-
*/
|
|
102
|
-
export async function generateNameFromPrompt(
|
|
103
|
-
model: ModelAny,
|
|
104
|
-
apiKey: string | undefined,
|
|
105
|
-
headers: Record<string, string> | undefined,
|
|
106
|
-
prompt: string,
|
|
107
|
-
maxLength: number,
|
|
108
|
-
): Promise<string | null> {
|
|
109
|
-
const systemPrompt = [
|
|
110
|
-
"You are a session naming assistant. Generate a concise title for a coding session.",
|
|
111
|
-
"",
|
|
112
|
-
"Rules:",
|
|
113
|
-
"- Output ONLY the title, no quotes, no prefix, no explanation",
|
|
114
|
-
`- Maximum ${maxLength} characters`,
|
|
115
|
-
"- Summarize intent, do not copy verbatim",
|
|
116
|
-
"- Keep it short (3\u20135 words), capture the general theme",
|
|
117
|
-
"- If the message mentions files or modules, keep the key names brief",
|
|
118
|
-
].join("\n");
|
|
119
|
-
|
|
120
|
-
const truncated = prompt.length > 2000 ? prompt.slice(0, 2000) + "..." : prompt;
|
|
121
|
-
|
|
122
|
-
try {
|
|
123
|
-
const options: CompleteOptions = { maxTokens: 64 };
|
|
124
|
-
if (apiKey) options.apiKey = apiKey;
|
|
125
|
-
if (headers) options.headers = headers;
|
|
126
|
-
|
|
127
|
-
const result = await complete(model, {
|
|
128
|
-
systemPrompt,
|
|
129
|
-
messages: [{ role: "user", content: truncated, timestamp: Date.now() }],
|
|
130
|
-
}, options);
|
|
131
|
-
|
|
132
|
-
const text = result.content
|
|
133
|
-
?.filter((b: any): b is { type: "text"; text: string } => b.type === "text")
|
|
134
|
-
?.map((b) => b.text)
|
|
135
|
-
?.join("")
|
|
136
|
-
?.trim() ?? "";
|
|
137
|
-
|
|
138
|
-
return cleanTitle(text, maxLength);
|
|
139
|
-
} catch {
|
|
140
|
-
return null;
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/**
|
|
145
|
-
* Summarize an assistant response into a one-line summary.
|
|
146
|
-
*
|
|
147
|
-
* Calls the configured tiny model. Returns null on failure.
|
|
148
|
-
*/
|
|
149
|
-
export async function summarizeResponseText(
|
|
150
|
-
model: ModelAny,
|
|
151
|
-
apiKey: string | undefined,
|
|
152
|
-
headers: Record<string, string> | undefined,
|
|
153
|
-
responseText: string,
|
|
154
|
-
maxLength: number,
|
|
155
|
-
): Promise<string | null> {
|
|
156
|
-
const systemPrompt = [
|
|
157
|
-
"Summarize what the AI assistant did in this coding session response.",
|
|
158
|
-
"Be concise — one short sentence or phrase.",
|
|
159
|
-
`Maximum ${maxLength} characters.`,
|
|
160
|
-
"Focus on what changed or was accomplished.",
|
|
161
|
-
"Output ONLY the summary, no quotes, no prefix, no explanation.",
|
|
162
|
-
].join("\n");
|
|
163
|
-
|
|
164
|
-
const truncated =
|
|
165
|
-
responseText.length > 2000 ? responseText.slice(0, 2000) + "..." : responseText;
|
|
166
|
-
|
|
167
|
-
try {
|
|
168
|
-
const options: CompleteOptions = { maxTokens: 64 };
|
|
169
|
-
if (apiKey) options.apiKey = apiKey;
|
|
170
|
-
if (headers) options.headers = headers;
|
|
171
|
-
|
|
172
|
-
const result = await complete(model, {
|
|
173
|
-
systemPrompt,
|
|
174
|
-
messages: [{ role: "user", content: truncated, timestamp: Date.now() }],
|
|
175
|
-
}, options);
|
|
176
|
-
|
|
177
|
-
const text = result.content
|
|
178
|
-
?.filter((b: any): b is { type: "text"; text: string } => b.type === "text")
|
|
179
|
-
?.map((b) => b.text)
|
|
180
|
-
?.join("")
|
|
181
|
-
?.trim() ?? "";
|
|
182
|
-
|
|
183
|
-
return text || null;
|
|
184
|
-
} catch {
|
|
185
|
-
return null;
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
/* ── Internal helpers ── */
|
|
190
|
-
|
|
191
|
-
function cleanTitle(raw: string, maxLength: number): string | null {
|
|
192
|
-
let name = raw.trim();
|
|
193
|
-
if (!name) return null;
|
|
194
|
-
|
|
195
|
-
// Strip surrounding quotes if present
|
|
196
|
-
if (
|
|
197
|
-
(name.startsWith('"') && name.endsWith('"')) ||
|
|
198
|
-
(name.startsWith("'") && name.endsWith("'"))
|
|
199
|
-
) {
|
|
200
|
-
name = name.slice(1, -1);
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
// Collapse newlines
|
|
204
|
-
name = name.replace(/\n/g, " ").trim();
|
|
205
|
-
|
|
206
|
-
// Truncate
|
|
207
|
-
if (name.length > maxLength) {
|
|
208
|
-
name = name.slice(0, maxLength - 3) + "...";
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
return name || null;
|
|
212
|
-
}
|
|
@@ -5,13 +5,16 @@
|
|
|
5
5
|
* multiline notification showing:
|
|
6
6
|
* Line 1: working directory path
|
|
7
7
|
* Line 2: session name (if set)
|
|
8
|
-
* Line 3:
|
|
8
|
+
* Line 3: truncated summary of what the agent did
|
|
9
9
|
*
|
|
10
10
|
* The notification title is the tmux session:window.pane identifier (when
|
|
11
11
|
* available) or "pi".
|
|
12
12
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
13
|
+
* The summary used to come from a cheap side-model, which needed pi-ai's
|
|
14
|
+
* `complete()` — a value import that stopped this extension loading outside the
|
|
15
|
+
* agent-cortex checkout (see test/pi-extensions-load.test.mjs). The truncation
|
|
16
|
+
* that already served as its fallback is now the only path; `maxSummaryLength`
|
|
17
|
+
* still sets its length.
|
|
15
18
|
*/
|
|
16
19
|
|
|
17
20
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
@@ -19,11 +22,7 @@ import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
|
19
22
|
import { execSync } from "node:child_process";
|
|
20
23
|
import { homedir } from "node:os";
|
|
21
24
|
|
|
22
|
-
import {
|
|
23
|
-
loadTinyModelConfig,
|
|
24
|
-
parseModelString,
|
|
25
|
-
summarizeResponseText,
|
|
26
|
-
} from "../lib/tiny-model.js";
|
|
25
|
+
import { loadTinyModelConfig } from "../lib/tiny-model.js";
|
|
27
26
|
import type { TinyModelConfig } from "../lib/tiny-model.js";
|
|
28
27
|
|
|
29
28
|
/* ── Context helpers ── */
|
|
@@ -215,20 +214,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
215
214
|
const sessionName = pi.getSessionName();
|
|
216
215
|
if (sessionName) sections.push(sessionName);
|
|
217
216
|
|
|
218
|
-
// Section 2:
|
|
217
|
+
// Section 2: truncated summary of the assistant's response
|
|
219
218
|
const responseText = extractAssistantText(event.messages);
|
|
220
219
|
if (responseText) {
|
|
221
|
-
|
|
222
|
-
const summary = await summarizeResponseWithFallback(
|
|
223
|
-
ctx,
|
|
224
|
-
responseText,
|
|
225
|
-
config,
|
|
226
|
-
);
|
|
227
|
-
sections.push(summary);
|
|
228
|
-
} catch {
|
|
229
|
-
// If summary fails (e.g. config not loaded), just show raw text
|
|
230
|
-
sections.push(responseText.slice(0, config?.maxSummaryLength ?? 100).replace(/\s+/g, ' ').trim());
|
|
231
|
-
}
|
|
220
|
+
sections.push(truncateToOneLine(responseText, config?.maxSummaryLength ?? 100));
|
|
232
221
|
}
|
|
233
222
|
|
|
234
223
|
// Desktop notification
|
|
@@ -239,45 +228,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
239
228
|
});
|
|
240
229
|
}
|
|
241
230
|
|
|
242
|
-
/**
|
|
243
|
-
* Summarize response text using the configured tiny model, falling back
|
|
244
|
-
* to simple truncation if the LLM call fails or is disabled.
|
|
245
|
-
*/
|
|
246
|
-
async function summarizeResponseWithFallback(
|
|
247
|
-
ctx: any,
|
|
248
|
-
responseText: string,
|
|
249
|
-
config: TinyModelConfig,
|
|
250
|
-
): Promise<string> {
|
|
251
|
-
if (!config.enabled) {
|
|
252
|
-
return truncateToOneLine(responseText, config.maxSummaryLength);
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
try {
|
|
256
|
-
const { provider, id } = parseModelString(config.model);
|
|
257
|
-
const model = ctx.modelRegistry.find(provider, id);
|
|
258
|
-
if (!model) {
|
|
259
|
-
return truncateToOneLine(responseText, config.maxSummaryLength);
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
const { apiKey, headers } =
|
|
263
|
-
await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
264
|
-
|
|
265
|
-
const summary = await summarizeResponseText(
|
|
266
|
-
model,
|
|
267
|
-
apiKey,
|
|
268
|
-
headers,
|
|
269
|
-
responseText,
|
|
270
|
-
config.maxSummaryLength,
|
|
271
|
-
);
|
|
272
|
-
|
|
273
|
-
if (summary) return summary;
|
|
274
|
-
} catch (err) {
|
|
275
|
-
console.warn("[notify] LLM summary failed, falling back:", err);
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
return truncateToOneLine(responseText, config.maxSummaryLength);
|
|
279
|
-
}
|
|
280
|
-
|
|
281
231
|
/**
|
|
282
232
|
* Truncate text to roughly `maxLen` characters on a single line,
|
|
283
233
|
* breaking at a word boundary.
|
|
@@ -7,7 +7,7 @@ Delegate tasks to specialized subagents with isolated context windows.
|
|
|
7
7
|
- **Isolated context**: Each subagent runs in a separate `pi` process
|
|
8
8
|
- **Streaming output**: See tool calls and progress as they happen
|
|
9
9
|
- **Parallel streaming**: All parallel tasks stream updates simultaneously
|
|
10
|
-
- **
|
|
10
|
+
- **Plain-text output**: Final output wrapped to the terminal width (expanded view)
|
|
11
11
|
- **Usage tracking**: Shows turns, tokens, cost, and context usage per agent
|
|
12
12
|
- **Abort support**: Ctrl+C propagates to kill subagent processes
|
|
13
13
|
|
|
@@ -125,7 +125,7 @@ pending `wait_for_agents` returns immediately with whatever is known so far.
|
|
|
125
125
|
**Expanded view** (Ctrl+O):
|
|
126
126
|
- Full task text
|
|
127
127
|
- All tool calls with formatted arguments
|
|
128
|
-
- Final output rendered as
|
|
128
|
+
- Final output rendered as width-wrapped plain text
|
|
129
129
|
- Per-task usage (for chain/parallel)
|
|
130
130
|
|
|
131
131
|
**Parallel mode streaming**:
|
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import * as fs from "node:fs";
|
|
6
|
+
import * as os from "node:os";
|
|
6
7
|
import * as path from "node:path";
|
|
7
8
|
import { fileURLToPath } from "node:url";
|
|
8
|
-
import {
|
|
9
|
+
import { parseFrontmatter } from "./frontmatter.ts";
|
|
9
10
|
|
|
10
11
|
export type AgentScope = "user" | "project" | "both";
|
|
11
12
|
|
|
@@ -145,6 +146,21 @@ function isRenderedAgentsTree(agentsDir: string): boolean {
|
|
|
145
146
|
return !entries.some((entry) => entry.isDirectory() && fs.existsSync(path.join(agentsDir, entry.name, "agent.md")));
|
|
146
147
|
}
|
|
147
148
|
|
|
149
|
+
/**
|
|
150
|
+
* pi's user config directory, which holds pi's own `agents/` directory.
|
|
151
|
+
*
|
|
152
|
+
* Read here rather than through `getAgentDir()` from
|
|
153
|
+
* `@earendil-works/pi-coding-agent`: a value import of pi's runtime makes this
|
|
154
|
+
* extension fail to load outside the agent-cortex checkout
|
|
155
|
+
* (test/pi-extensions-load.test.mjs). Same contract — `PI_CODING_AGENT_DIR`
|
|
156
|
+
* wins when set, otherwise `~/.pi/agent`.
|
|
157
|
+
*/
|
|
158
|
+
function getAgentDir(): string {
|
|
159
|
+
const override = process.env.PI_CODING_AGENT_DIR;
|
|
160
|
+
if (override) return path.resolve(override.replace(/^~(?=$|[\\/])/, os.homedir()));
|
|
161
|
+
return path.join(os.homedir(), ".pi", "agent");
|
|
162
|
+
}
|
|
163
|
+
|
|
148
164
|
/**
|
|
149
165
|
* Directories the "user" scope discovers agents from, in precedence order.
|
|
150
166
|
*
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The local transcript components pi's renderers draw for the subagent tool.
|
|
3
|
+
*
|
|
4
|
+
* pi-tui's own components are unreachable from an installed extension, so these
|
|
5
|
+
* implement pi's `Component` contract directly. The contract that matters is the
|
|
6
|
+
* width: pi-tui reports a rendered line wider than the terminal as an error, so
|
|
7
|
+
* every line these return must fit the width they are given, with SGR colour
|
|
8
|
+
* sequences still intact.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, it } from "node:test";
|
|
12
|
+
import assert from "node:assert/strict";
|
|
13
|
+
|
|
14
|
+
import { Container, Spacer, Text } from "./components.ts";
|
|
15
|
+
|
|
16
|
+
const RESET = "\x1b[0m";
|
|
17
|
+
const GREEN = "\x1b[32m";
|
|
18
|
+
|
|
19
|
+
/** Visible column count, ignoring SGR sequences. */
|
|
20
|
+
function visibleWidth(line: string): number {
|
|
21
|
+
return line.replace(/\x1b\[[0-9;]*m/g, "").length;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Visible terminal-cell count, ignoring SGR sequences and counting a wide or
|
|
26
|
+
* astral character as two cells. `String.length` counts a wide character as one
|
|
27
|
+
* unit, so a width assertion built on it misses the overflow this guards.
|
|
28
|
+
*/
|
|
29
|
+
function cellWidth(line: string): number {
|
|
30
|
+
return [...line.replace(/\x1b\[[0-9;]*m/g, "")].reduce((cells, character) => {
|
|
31
|
+
const codePoint = character.codePointAt(0) ?? 0;
|
|
32
|
+
const wide =
|
|
33
|
+
codePoint > 0xffff ||
|
|
34
|
+
(codePoint >= 0x1100 &&
|
|
35
|
+
(codePoint <= 0x115f ||
|
|
36
|
+
(codePoint >= 0x2e80 && codePoint <= 0xa4cf) ||
|
|
37
|
+
(codePoint >= 0xac00 && codePoint <= 0xd7a3) ||
|
|
38
|
+
(codePoint >= 0xf900 && codePoint <= 0xfaff) ||
|
|
39
|
+
(codePoint >= 0xfe30 && codePoint <= 0xfe6f) ||
|
|
40
|
+
(codePoint >= 0xff00 && codePoint <= 0xff60) ||
|
|
41
|
+
(codePoint >= 0xffe0 && codePoint <= 0xffe6)));
|
|
42
|
+
return cells + (wide ? 2 : 1);
|
|
43
|
+
}, 0);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe("Text", () => {
|
|
47
|
+
it("wraps a long line to the width it is given", () => {
|
|
48
|
+
const lines = new Text("alpha beta gamma delta").render(11);
|
|
49
|
+
|
|
50
|
+
assert.deepEqual(lines, ["alpha beta", "gamma delta"]);
|
|
51
|
+
assert.ok(lines.every((line) => visibleWidth(line) <= 11));
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("hard-wraps a word longer than the width instead of overflowing", () => {
|
|
55
|
+
const lines = new Text("abcdefghij").render(4);
|
|
56
|
+
|
|
57
|
+
assert.deepEqual(lines, ["abcd", "efgh", "ij"]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("keeps a colour sequence intact and carries it onto the wrapped line", () => {
|
|
61
|
+
const lines = new Text(`${GREEN}alpha beta gamma${RESET}`).render(11);
|
|
62
|
+
|
|
63
|
+
assert.equal(lines.length, 2);
|
|
64
|
+
for (const line of lines) assert.ok(visibleWidth(line) <= 11, `width of ${JSON.stringify(line)}`);
|
|
65
|
+
assert.ok(lines[0].endsWith(RESET), "a line cut inside a colour span closes it");
|
|
66
|
+
assert.ok(lines[1].startsWith(GREEN), "the continuation reopens the colour");
|
|
67
|
+
assert.equal(lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "")).join(" "), "alpha beta gamma");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("pads and separates lines with the padding it is given", () => {
|
|
71
|
+
const lines = new Text("body", 1, 1).render(10);
|
|
72
|
+
|
|
73
|
+
assert.deepEqual(lines, [" ", " body ", " "]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("keeps an empty line and renders blank text as one empty line", () => {
|
|
77
|
+
assert.deepEqual(new Text("a\n\nb").render(10), ["a", "", "b"]);
|
|
78
|
+
assert.deepEqual(new Text().render(10), [""]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("counts a wide character as two cells so it cannot overflow the width", () => {
|
|
82
|
+
// Each of these characters occupies two terminal cells but one string unit.
|
|
83
|
+
assert.deepEqual(new Text("日本語").render(4), ["日本", "語"]);
|
|
84
|
+
|
|
85
|
+
const lines = new Text("日本語のテキストは全角文字です。これは折り返しのテストです。").render(20);
|
|
86
|
+
assert.ok(lines.length > 1, "wide text must wrap");
|
|
87
|
+
for (const line of lines) assert.ok(cellWidth(line) <= 20, `width of ${JSON.stringify(line)}`);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("counts an astral character as two cells", () => {
|
|
91
|
+
assert.deepEqual(new Text("🚀🚀🚀").render(4), ["🚀🚀", "🚀"]);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe("Spacer", () => {
|
|
96
|
+
it("renders the blank lines it was asked for", () => {
|
|
97
|
+
assert.deepEqual(new Spacer(2).render(10), ["", ""]);
|
|
98
|
+
assert.deepEqual(new Spacer().render(10), [""]);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe("Container", () => {
|
|
103
|
+
it("draws its children in order, each at the given width", () => {
|
|
104
|
+
const container = new Container();
|
|
105
|
+
container.addChild(new Text("alpha beta"));
|
|
106
|
+
container.addChild(new Spacer(1));
|
|
107
|
+
container.addChild(new Text("gamma"));
|
|
108
|
+
|
|
109
|
+
assert.deepEqual(container.render(5), ["alpha", "beta", "", "gamma"]);
|
|
110
|
+
});
|
|
111
|
+
});
|