pi-subagents 0.8.2 → 0.8.4
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 +30 -1
- package/README.md +43 -18
- package/agent-management.ts +7 -0
- package/agent-manager-detail.ts +4 -0
- package/agent-manager-edit.ts +4 -2
- package/agent-scope.ts +6 -0
- package/agent-selection.ts +20 -0
- package/agent-serializer.ts +6 -0
- package/agents.ts +13 -12
- package/artifacts.ts +23 -1
- package/async-execution.ts +8 -18
- package/chain-execution.ts +15 -7
- package/completion-dedupe.ts +63 -0
- package/execution.ts +36 -13
- package/file-coalescer.ts +40 -0
- package/index.ts +54 -24
- package/jsonl-writer.ts +81 -0
- package/notify.ts +4 -13
- package/package.json +2 -2
- package/pi-spawn.ts +77 -0
- package/render.ts +44 -18
- package/schemas.ts +5 -4
- package/settings.ts +18 -2
- package/single-output.ts +55 -0
- package/skills.ts +221 -80
- package/subagent-runner.ts +32 -6
- package/types.ts +5 -3
package/render.ts
CHANGED
|
@@ -28,6 +28,21 @@ function computeWidgetHash(jobs: AsyncJobState[]): string {
|
|
|
28
28
|
).join("|");
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
function extractOutputTarget(task: string): string | undefined {
|
|
32
|
+
const writeToMatch = task.match(/\[Write to:\s*([^\]\n]+)\]/i);
|
|
33
|
+
if (writeToMatch?.[1]?.trim()) return writeToMatch[1].trim();
|
|
34
|
+
const findingsMatch = task.match(/Write your findings to:\s*(\S+)/i);
|
|
35
|
+
if (findingsMatch?.[1]?.trim()) return findingsMatch[1].trim();
|
|
36
|
+
const outputMatch = task.match(/[Oo]utput(?:\s+to)?\s*:\s*(\S+)/i);
|
|
37
|
+
if (outputMatch?.[1]?.trim()) return outputMatch[1].trim();
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function hasEmptyTextOutputWithoutOutputTarget(task: string, output: string): boolean {
|
|
42
|
+
if (output.trim()) return false;
|
|
43
|
+
return !extractOutputTarget(task);
|
|
44
|
+
}
|
|
45
|
+
|
|
31
46
|
/**
|
|
32
47
|
* Render the async jobs widget
|
|
33
48
|
*/
|
|
@@ -158,11 +173,18 @@ export function renderSubagentResult(
|
|
|
158
173
|
const hasRunning = d.progress?.some((p) => p.status === "running")
|
|
159
174
|
|| d.results.some((r) => r.progress?.status === "running");
|
|
160
175
|
const ok = d.results.filter((r) => r.progress?.status === "completed" || (r.exitCode === 0 && r.progress?.status !== "running")).length;
|
|
176
|
+
const hasEmptyWithoutTarget = d.results.some((r) =>
|
|
177
|
+
r.exitCode === 0
|
|
178
|
+
&& r.progress?.status !== "running"
|
|
179
|
+
&& hasEmptyTextOutputWithoutOutputTarget(r.task, getFinalOutput(r.messages)),
|
|
180
|
+
);
|
|
161
181
|
const icon = hasRunning
|
|
162
182
|
? theme.fg("warning", "...")
|
|
163
|
-
:
|
|
164
|
-
? theme.fg("
|
|
165
|
-
:
|
|
183
|
+
: hasEmptyWithoutTarget
|
|
184
|
+
? theme.fg("warning", "⚠")
|
|
185
|
+
: ok === d.results.length
|
|
186
|
+
? theme.fg("success", "ok")
|
|
187
|
+
: theme.fg("error", "X");
|
|
166
188
|
|
|
167
189
|
const totalSummary =
|
|
168
190
|
d.progressSummary ||
|
|
@@ -205,14 +227,19 @@ export function renderSubagentResult(
|
|
|
205
227
|
const result = d.results[i];
|
|
206
228
|
const isFailed = result && result.exitCode !== 0 && result.progress?.status !== "running";
|
|
207
229
|
const isComplete = result && result.exitCode === 0 && result.progress?.status !== "running";
|
|
230
|
+
const isEmptyWithoutTarget = Boolean(result)
|
|
231
|
+
&& Boolean(isComplete)
|
|
232
|
+
&& hasEmptyTextOutputWithoutOutputTarget(result.task, getFinalOutput(result.messages));
|
|
208
233
|
const isCurrent = i === (d.currentStepIndex ?? d.results.length);
|
|
209
234
|
const icon = isFailed
|
|
210
235
|
? theme.fg("error", "✗")
|
|
211
|
-
:
|
|
212
|
-
? theme.fg("
|
|
213
|
-
:
|
|
214
|
-
? theme.fg("
|
|
215
|
-
:
|
|
236
|
+
: isEmptyWithoutTarget
|
|
237
|
+
? theme.fg("warning", "⚠")
|
|
238
|
+
: isComplete
|
|
239
|
+
? theme.fg("success", "✓")
|
|
240
|
+
: isCurrent && hasRunning
|
|
241
|
+
? theme.fg("warning", "●")
|
|
242
|
+
: theme.fg("dim", "○");
|
|
216
243
|
return `${icon} ${agent}`;
|
|
217
244
|
})
|
|
218
245
|
.join(theme.fg("dim", " → "))
|
|
@@ -259,28 +286,27 @@ export function renderSubagentResult(
|
|
|
259
286
|
const rProg = r.progress || progressFromArray || r.progressSummary;
|
|
260
287
|
const rRunning = rProg?.status === "running";
|
|
261
288
|
|
|
262
|
-
|
|
289
|
+
const resultOutput = getFinalOutput(r.messages);
|
|
263
290
|
const statusIcon = rRunning
|
|
264
291
|
? theme.fg("warning", "●")
|
|
265
|
-
: r.exitCode
|
|
266
|
-
? theme.fg("
|
|
267
|
-
:
|
|
292
|
+
: r.exitCode !== 0
|
|
293
|
+
? theme.fg("error", "✗")
|
|
294
|
+
: hasEmptyTextOutputWithoutOutputTarget(r.task, resultOutput)
|
|
295
|
+
? theme.fg("warning", "⚠")
|
|
296
|
+
: theme.fg("success", "✓");
|
|
268
297
|
const stats = rProg ? ` | ${rProg.toolCount} tools, ${formatDuration(rProg.durationMs)}` : "";
|
|
269
|
-
// Show model if available (full provider/model format)
|
|
270
298
|
const modelDisplay = r.model ? theme.fg("dim", ` (${r.model})`) : "";
|
|
271
299
|
const stepHeader = rRunning
|
|
272
300
|
? `${statusIcon} Step ${i + 1}: ${theme.bold(theme.fg("warning", r.agent))}${modelDisplay}${stats}`
|
|
273
301
|
: `${statusIcon} Step ${i + 1}: ${theme.bold(r.agent)}${modelDisplay}${stats}`;
|
|
274
302
|
c.addChild(new Text(stepHeader, 0, 0));
|
|
275
303
|
|
|
276
|
-
// Task (truncated)
|
|
277
304
|
const taskPreview = r.task.slice(0, 120) + (r.task.length > 120 ? "..." : "");
|
|
278
305
|
c.addChild(new Text(theme.fg("dim", ` task: ${taskPreview}`), 0, 0));
|
|
279
306
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
c.addChild(new Text(theme.fg("dim", ` output: ${outputMatch[1]}`), 0, 0));
|
|
307
|
+
const outputTarget = extractOutputTarget(r.task);
|
|
308
|
+
if (outputTarget) {
|
|
309
|
+
c.addChild(new Text(theme.fg("dim", ` output: ${outputTarget}`), 0, 0));
|
|
284
310
|
}
|
|
285
311
|
|
|
286
312
|
if (r.skills?.length) {
|
package/schemas.ts
CHANGED
|
@@ -11,6 +11,7 @@ export const TaskItem = Type.Object({
|
|
|
11
11
|
agent: Type.String(),
|
|
12
12
|
task: Type.String(),
|
|
13
13
|
cwd: Type.Optional(Type.String()),
|
|
14
|
+
model: Type.Optional(Type.String({ description: "Override model for this task (e.g. 'google/gemini-3-pro')" })),
|
|
14
15
|
skill: Type.Optional(SkillOverride),
|
|
15
16
|
});
|
|
16
17
|
|
|
@@ -71,13 +72,13 @@ export const SubagentParams = Type.Object({
|
|
|
71
72
|
})),
|
|
72
73
|
// Agent/chain configuration for create/update (nested to avoid conflicts with execution fields)
|
|
73
74
|
config: Type.Optional(Type.Any({
|
|
74
|
-
description: "Agent or chain config for create/update. Agent: name, description, scope ('user'|'project', default 'user'), systemPrompt, model, tools (comma-separated), skills (comma-separated), thinking, output, reads, progress. Chain: name, description, scope, steps (array of {agent, task?, output?, reads?, model?, skills?, progress?}). Presence of 'steps' creates a chain instead of an agent."
|
|
75
|
+
description: "Agent or chain config for create/update. Agent: name, description, scope ('user'|'project', default 'user'), systemPrompt, model, tools (comma-separated), extensions (comma-separated), skills (comma-separated), thinking, output, reads, progress. Chain: name, description, scope, steps (array of {agent, task?, output?, reads?, model?, skills?, progress?}). Presence of 'steps' creates a chain instead of an agent."
|
|
75
76
|
})),
|
|
76
77
|
tasks: Type.Optional(Type.Array(TaskItem, { description: "PARALLEL mode: [{agent, task}, ...]" })),
|
|
77
78
|
chain: Type.Optional(Type.Array(ChainItem, { description: "CHAIN mode: sequential pipeline where each step's response becomes {previous} for the next. Use {task}, {previous}, {chain_dir} in task templates." })),
|
|
78
|
-
chainDir: Type.Optional(Type.String({ description: "Persistent directory for chain artifacts. Default:
|
|
79
|
+
chainDir: Type.Optional(Type.String({ description: "Persistent directory for chain artifacts. Default: <tmpdir>/pi-chain-runs/ (auto-cleaned after 24h)" })),
|
|
79
80
|
async: Type.Optional(Type.Boolean({ description: "Run in background (default: false, or per config)" })),
|
|
80
|
-
agentScope: Type.Optional(Type.String({ description: "Agent discovery scope: 'user', 'project', or 'both' (default: '
|
|
81
|
+
agentScope: Type.Optional(Type.String({ description: "Agent discovery scope: 'user', 'project', or 'both' (default: 'both'; project wins on name collisions)" })),
|
|
81
82
|
cwd: Type.Optional(Type.String()),
|
|
82
83
|
maxOutput: MaxOutputSchema,
|
|
83
84
|
artifacts: Type.Optional(Type.Boolean({ description: "Write debug artifacts (default: true)" })),
|
|
@@ -89,7 +90,7 @@ export const SubagentParams = Type.Object({
|
|
|
89
90
|
// Clarification TUI
|
|
90
91
|
clarify: Type.Optional(Type.Boolean({ description: "Show TUI to preview/edit before execution (default: true for chains, false for single/parallel). Implies sync mode." })),
|
|
91
92
|
// Solo agent overrides
|
|
92
|
-
output: Type.Optional(Type.Any({ description: "Override output file for single agent (string), or false to disable (uses agent default if omitted)" })),
|
|
93
|
+
output: Type.Optional(Type.Any({ description: "Override output file for single agent (string), or false to disable (uses agent default if omitted). Absolute paths are used as-is; relative paths resolve against cwd." })),
|
|
93
94
|
skill: Type.Optional(SkillOverride),
|
|
94
95
|
model: Type.Optional(Type.String({ description: "Override model for single agent (e.g. 'anthropic/claude-sonnet-4')" })),
|
|
95
96
|
});
|
package/settings.ts
CHANGED
|
@@ -3,11 +3,12 @@
|
|
|
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 type { AgentConfig } from "./agents.js";
|
|
8
9
|
import { normalizeSkillInput } from "./skills.js";
|
|
9
10
|
|
|
10
|
-
const CHAIN_RUNS_DIR = "
|
|
11
|
+
const CHAIN_RUNS_DIR = path.join(os.tmpdir(), "pi-chain-runs");
|
|
11
12
|
const CHAIN_DIR_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
12
13
|
|
|
13
14
|
// =============================================================================
|
|
@@ -358,6 +359,8 @@ export interface ParallelTaskResult {
|
|
|
358
359
|
output: string;
|
|
359
360
|
exitCode: number;
|
|
360
361
|
error?: string;
|
|
362
|
+
outputTargetPath?: string;
|
|
363
|
+
outputTargetExists?: boolean;
|
|
361
364
|
}
|
|
362
365
|
|
|
363
366
|
/**
|
|
@@ -368,7 +371,20 @@ export function aggregateParallelOutputs(results: ParallelTaskResult[]): string
|
|
|
368
371
|
return results
|
|
369
372
|
.map((r, i) => {
|
|
370
373
|
const header = `=== Parallel Task ${i + 1} (${r.agent}) ===`;
|
|
371
|
-
|
|
374
|
+
const hasTextOutput = Boolean(r.output?.trim());
|
|
375
|
+
const status = r.exitCode !== 0
|
|
376
|
+
? `⚠️ FAILED (exit code ${r.exitCode})${r.error ? `: ${r.error}` : ""}`
|
|
377
|
+
: r.error
|
|
378
|
+
? `⚠️ WARNING: ${r.error}`
|
|
379
|
+
: !hasTextOutput && r.outputTargetPath && r.outputTargetExists === false
|
|
380
|
+
? `⚠️ EMPTY OUTPUT (expected output file missing: ${r.outputTargetPath})`
|
|
381
|
+
: !hasTextOutput && !r.outputTargetPath
|
|
382
|
+
? "⚠️ EMPTY OUTPUT (no textual response returned)"
|
|
383
|
+
: "";
|
|
384
|
+
const body = status
|
|
385
|
+
? (hasTextOutput ? `${status}\n${r.output}` : status)
|
|
386
|
+
: r.output;
|
|
387
|
+
return `${header}\n${body}`;
|
|
372
388
|
})
|
|
373
389
|
.join("\n\n");
|
|
374
390
|
}
|
package/single-output.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
|
|
4
|
+
export function resolveSingleOutputPath(
|
|
5
|
+
output: string | false | undefined,
|
|
6
|
+
runtimeCwd: string,
|
|
7
|
+
requestedCwd?: string,
|
|
8
|
+
): string | undefined {
|
|
9
|
+
if (typeof output !== "string" || !output) return undefined;
|
|
10
|
+
if (path.isAbsolute(output)) return output;
|
|
11
|
+
const baseCwd = requestedCwd
|
|
12
|
+
? (path.isAbsolute(requestedCwd) ? requestedCwd : path.resolve(runtimeCwd, requestedCwd))
|
|
13
|
+
: runtimeCwd;
|
|
14
|
+
return path.resolve(baseCwd, output);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function injectSingleOutputInstruction(task: string, outputPath: string | undefined): string {
|
|
18
|
+
if (!outputPath) return task;
|
|
19
|
+
return `${task}\n\n---\n**Output:** Write your findings to: ${outputPath}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function persistSingleOutput(
|
|
23
|
+
outputPath: string | undefined,
|
|
24
|
+
fullOutput: string,
|
|
25
|
+
): { savedPath?: string; error?: string } {
|
|
26
|
+
if (!outputPath) return {};
|
|
27
|
+
try {
|
|
28
|
+
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
|
29
|
+
fs.writeFileSync(outputPath, fullOutput, "utf-8");
|
|
30
|
+
return { savedPath: outputPath };
|
|
31
|
+
} catch (err) {
|
|
32
|
+
return { error: err instanceof Error ? err.message : String(err) };
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function finalizeSingleOutput(params: {
|
|
37
|
+
fullOutput: string;
|
|
38
|
+
truncatedOutput?: string;
|
|
39
|
+
outputPath?: string;
|
|
40
|
+
exitCode: number;
|
|
41
|
+
}): { displayOutput: string; savedPath?: string; saveError?: string } {
|
|
42
|
+
let displayOutput = params.truncatedOutput || params.fullOutput;
|
|
43
|
+
if (params.outputPath && params.exitCode === 0) {
|
|
44
|
+
const save = persistSingleOutput(params.outputPath, params.fullOutput);
|
|
45
|
+
if (save.savedPath) {
|
|
46
|
+
displayOutput += `\n\n📄 Output saved to: ${save.savedPath}`;
|
|
47
|
+
return { displayOutput, savedPath: save.savedPath };
|
|
48
|
+
}
|
|
49
|
+
if (save.error) {
|
|
50
|
+
displayOutput += `\n\n⚠️ Failed to save output to: ${params.outputPath}\n${save.error}`;
|
|
51
|
+
return { displayOutput, saveError: save.error };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return { displayOutput };
|
|
55
|
+
}
|
package/skills.ts
CHANGED
|
@@ -5,12 +5,24 @@
|
|
|
5
5
|
import * as fs from "node:fs";
|
|
6
6
|
import * as os from "node:os";
|
|
7
7
|
import * as path from "node:path";
|
|
8
|
+
import { loadSkills, type Skill } from "@mariozechner/pi-coding-agent";
|
|
9
|
+
|
|
10
|
+
export type SkillSource =
|
|
11
|
+
| "project"
|
|
12
|
+
| "user"
|
|
13
|
+
| "project-package"
|
|
14
|
+
| "user-package"
|
|
15
|
+
| "project-settings"
|
|
16
|
+
| "user-settings"
|
|
17
|
+
| "extension"
|
|
18
|
+
| "builtin"
|
|
19
|
+
| "unknown";
|
|
8
20
|
|
|
9
21
|
export interface ResolvedSkill {
|
|
10
22
|
name: string;
|
|
11
23
|
path: string;
|
|
12
24
|
content: string;
|
|
13
|
-
source:
|
|
25
|
+
source: SkillSource;
|
|
14
26
|
}
|
|
15
27
|
|
|
16
28
|
interface SkillCacheEntry {
|
|
@@ -18,9 +30,35 @@ interface SkillCacheEntry {
|
|
|
18
30
|
skill: ResolvedSkill;
|
|
19
31
|
}
|
|
20
32
|
|
|
33
|
+
interface CachedSkillEntry {
|
|
34
|
+
name: string;
|
|
35
|
+
filePath: string;
|
|
36
|
+
source: SkillSource;
|
|
37
|
+
description?: string;
|
|
38
|
+
order: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
21
41
|
const skillCache = new Map<string, SkillCacheEntry>();
|
|
22
42
|
const MAX_CACHE_SIZE = 50;
|
|
23
43
|
|
|
44
|
+
let loadSkillsCache: { cwd: string; skills: CachedSkillEntry[]; timestamp: number } | null = null;
|
|
45
|
+
const LOAD_SKILLS_CACHE_TTL_MS = 5000;
|
|
46
|
+
|
|
47
|
+
const CONFIG_DIR = ".pi";
|
|
48
|
+
const AGENT_DIR = path.join(os.homedir(), ".pi", "agent");
|
|
49
|
+
|
|
50
|
+
const SOURCE_PRIORITY: Record<SkillSource, number> = {
|
|
51
|
+
project: 700,
|
|
52
|
+
"project-settings": 650,
|
|
53
|
+
"project-package": 600,
|
|
54
|
+
user: 300,
|
|
55
|
+
"user-settings": 250,
|
|
56
|
+
"user-package": 200,
|
|
57
|
+
extension: 150,
|
|
58
|
+
builtin: 100,
|
|
59
|
+
unknown: 0,
|
|
60
|
+
};
|
|
61
|
+
|
|
24
62
|
function stripSkillFrontmatter(content: string): string {
|
|
25
63
|
const normalized = content.replace(/\r\n/g, "\n");
|
|
26
64
|
if (!normalized.startsWith("---")) return normalized;
|
|
@@ -31,27 +69,187 @@ function stripSkillFrontmatter(content: string): string {
|
|
|
31
69
|
return normalized.slice(endIndex + 4).trim();
|
|
32
70
|
}
|
|
33
71
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
72
|
+
function isWithinPath(filePath: string, dir: string): boolean {
|
|
73
|
+
const relative = path.relative(dir, filePath);
|
|
74
|
+
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function getPackageSkillPaths(packageRoot: string): string[] {
|
|
78
|
+
const pkgJsonPath = path.join(packageRoot, "package.json");
|
|
79
|
+
try {
|
|
80
|
+
const content = fs.readFileSync(pkgJsonPath, "utf-8");
|
|
81
|
+
const pkg = JSON.parse(content);
|
|
82
|
+
const piSkills = pkg?.pi?.skills;
|
|
83
|
+
if (!Array.isArray(piSkills)) return [];
|
|
84
|
+
return piSkills
|
|
85
|
+
.filter((s: unknown) => typeof s === "string")
|
|
86
|
+
.map((s: string) => path.resolve(packageRoot, s));
|
|
87
|
+
} catch {
|
|
88
|
+
return [];
|
|
41
89
|
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function collectPackageSkillPaths(cwd: string): string[] {
|
|
93
|
+
const dirs = [
|
|
94
|
+
path.join(cwd, CONFIG_DIR, "npm", "node_modules"),
|
|
95
|
+
path.join(AGENT_DIR, "npm", "node_modules"),
|
|
96
|
+
];
|
|
97
|
+
const results: string[] = [];
|
|
42
98
|
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
99
|
+
for (const dir of dirs) {
|
|
100
|
+
if (!fs.existsSync(dir)) continue;
|
|
101
|
+
let entries: fs.Dirent[];
|
|
102
|
+
try {
|
|
103
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
104
|
+
} catch {
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
for (const entry of entries) {
|
|
109
|
+
if (entry.name.startsWith(".")) continue;
|
|
110
|
+
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
|
|
111
|
+
|
|
112
|
+
if (entry.name.startsWith("@")) {
|
|
113
|
+
const scopeDir = path.join(dir, entry.name);
|
|
114
|
+
let scopeEntries: fs.Dirent[];
|
|
115
|
+
try {
|
|
116
|
+
scopeEntries = fs.readdirSync(scopeDir, { withFileTypes: true });
|
|
117
|
+
} catch {
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
for (const scopeEntry of scopeEntries) {
|
|
121
|
+
if (scopeEntry.name.startsWith(".")) continue;
|
|
122
|
+
if (!scopeEntry.isDirectory() && !scopeEntry.isSymbolicLink()) continue;
|
|
123
|
+
const pkgRoot = path.join(scopeDir, scopeEntry.name);
|
|
124
|
+
results.push(...getPackageSkillPaths(pkgRoot));
|
|
125
|
+
}
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const pkgRoot = path.join(dir, entry.name);
|
|
130
|
+
results.push(...getPackageSkillPaths(pkgRoot));
|
|
131
|
+
}
|
|
46
132
|
}
|
|
47
133
|
|
|
48
|
-
return
|
|
134
|
+
return results;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function collectSettingsSkillPaths(cwd: string): string[] {
|
|
138
|
+
const results: string[] = [];
|
|
139
|
+
const settingsFiles = [
|
|
140
|
+
{ file: path.join(cwd, CONFIG_DIR, "settings.json"), base: path.join(cwd, CONFIG_DIR) },
|
|
141
|
+
{ file: path.join(AGENT_DIR, "settings.json"), base: AGENT_DIR },
|
|
142
|
+
];
|
|
143
|
+
|
|
144
|
+
for (const { file, base } of settingsFiles) {
|
|
145
|
+
try {
|
|
146
|
+
const content = fs.readFileSync(file, "utf-8");
|
|
147
|
+
const settings = JSON.parse(content);
|
|
148
|
+
const skills = settings?.skills;
|
|
149
|
+
if (!Array.isArray(skills)) continue;
|
|
150
|
+
for (const entry of skills) {
|
|
151
|
+
if (typeof entry !== "string") continue;
|
|
152
|
+
let resolved = entry;
|
|
153
|
+
if (resolved.startsWith("~/")) {
|
|
154
|
+
resolved = path.join(os.homedir(), resolved.slice(2));
|
|
155
|
+
} else if (!path.isAbsolute(resolved)) {
|
|
156
|
+
resolved = path.resolve(base, resolved);
|
|
157
|
+
}
|
|
158
|
+
results.push(resolved);
|
|
159
|
+
}
|
|
160
|
+
} catch {}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return results;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function buildSkillPaths(cwd: string): string[] {
|
|
167
|
+
const defaultSkillPaths = [
|
|
168
|
+
path.join(cwd, CONFIG_DIR, "skills"),
|
|
169
|
+
path.join(AGENT_DIR, "skills"),
|
|
170
|
+
];
|
|
171
|
+
const packagePaths = collectPackageSkillPaths(cwd);
|
|
172
|
+
const settingsPaths = collectSettingsSkillPaths(cwd);
|
|
173
|
+
return [...new Set([...defaultSkillPaths, ...packagePaths, ...settingsPaths])];
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function inferSkillSource(rawSource: unknown, filePath: string, cwd: string): SkillSource {
|
|
177
|
+
const source = typeof rawSource === "string" ? rawSource : "";
|
|
178
|
+
const projectRoot = path.resolve(cwd, CONFIG_DIR);
|
|
179
|
+
const isProjectScoped = isWithinPath(filePath, projectRoot);
|
|
180
|
+
const isUserScoped = isWithinPath(filePath, AGENT_DIR);
|
|
181
|
+
|
|
182
|
+
if (source === "project") return "project";
|
|
183
|
+
if (source === "user") return "user";
|
|
184
|
+
if (source === "settings") {
|
|
185
|
+
if (isProjectScoped) return "project-settings";
|
|
186
|
+
if (isUserScoped) return "user-settings";
|
|
187
|
+
return "unknown";
|
|
188
|
+
}
|
|
189
|
+
if (source === "package") {
|
|
190
|
+
if (isProjectScoped) return "project-package";
|
|
191
|
+
if (isUserScoped) return "user-package";
|
|
192
|
+
return "unknown";
|
|
193
|
+
}
|
|
194
|
+
if (source === "extension") return "extension";
|
|
195
|
+
if (source === "builtin") return "builtin";
|
|
196
|
+
|
|
197
|
+
if (isProjectScoped) return "project";
|
|
198
|
+
if (isUserScoped) return "user";
|
|
199
|
+
return "unknown";
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function chooseHigherPrioritySkill(existing: CachedSkillEntry | undefined, candidate: CachedSkillEntry): CachedSkillEntry {
|
|
203
|
+
if (!existing) return candidate;
|
|
204
|
+
const existingPriority = SOURCE_PRIORITY[existing.source] ?? 0;
|
|
205
|
+
const candidatePriority = SOURCE_PRIORITY[candidate.source] ?? 0;
|
|
206
|
+
if (candidatePriority > existingPriority) return candidate;
|
|
207
|
+
if (candidatePriority < existingPriority) return existing;
|
|
208
|
+
return candidate.order < existing.order ? candidate : existing;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function getCachedSkills(cwd: string): CachedSkillEntry[] {
|
|
212
|
+
const now = Date.now();
|
|
213
|
+
if (loadSkillsCache && loadSkillsCache.cwd === cwd && now - loadSkillsCache.timestamp < LOAD_SKILLS_CACHE_TTL_MS) {
|
|
214
|
+
return loadSkillsCache.skills;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const skillPaths = buildSkillPaths(cwd);
|
|
218
|
+
const loaded = loadSkills({ cwd, skillPaths, includeDefaults: false });
|
|
219
|
+
const dedupedByName = new Map<string, CachedSkillEntry>();
|
|
220
|
+
|
|
221
|
+
for (let i = 0; i < loaded.skills.length; i++) {
|
|
222
|
+
const skill = loaded.skills[i] as Skill;
|
|
223
|
+
const entry: CachedSkillEntry = {
|
|
224
|
+
name: skill.name,
|
|
225
|
+
filePath: skill.filePath,
|
|
226
|
+
source: inferSkillSource((skill as { source?: unknown }).source, skill.filePath, cwd),
|
|
227
|
+
description: skill.description,
|
|
228
|
+
order: i,
|
|
229
|
+
};
|
|
230
|
+
const current = dedupedByName.get(entry.name);
|
|
231
|
+
dedupedByName.set(entry.name, chooseHigherPrioritySkill(current, entry));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const skills = [...dedupedByName.values()].sort((a, b) => a.order - b.order);
|
|
235
|
+
loadSkillsCache = { cwd, skills, timestamp: now };
|
|
236
|
+
return skills;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function resolveSkillPath(
|
|
240
|
+
skillName: string,
|
|
241
|
+
cwd: string,
|
|
242
|
+
): { path: string; source: SkillSource } | undefined {
|
|
243
|
+
const skills = getCachedSkills(cwd);
|
|
244
|
+
const skill = skills.find((s) => s.name === skillName);
|
|
245
|
+
if (!skill) return undefined;
|
|
246
|
+
return { path: skill.filePath, source: skill.source };
|
|
49
247
|
}
|
|
50
248
|
|
|
51
249
|
export function readSkill(
|
|
52
250
|
skillName: string,
|
|
53
251
|
skillPath: string,
|
|
54
|
-
source:
|
|
252
|
+
source: SkillSource,
|
|
55
253
|
): ResolvedSkill | undefined {
|
|
56
254
|
try {
|
|
57
255
|
const stat = fs.statSync(skillPath);
|
|
@@ -123,84 +321,27 @@ export function normalizeSkillInput(
|
|
|
123
321
|
if (input === false) return false;
|
|
124
322
|
if (input === true || input === undefined) return undefined;
|
|
125
323
|
if (Array.isArray(input)) {
|
|
126
|
-
// Deduplicate while preserving order
|
|
127
324
|
return [...new Set(input.map((s) => s.trim()).filter((s) => s.length > 0))];
|
|
128
325
|
}
|
|
129
|
-
// Deduplicate while preserving order
|
|
130
326
|
return [...new Set(input.split(",").map((s) => s.trim()).filter((s) => s.length > 0))];
|
|
131
327
|
}
|
|
132
328
|
|
|
133
|
-
function isDirectory(p: string): boolean {
|
|
134
|
-
try {
|
|
135
|
-
return fs.statSync(p).isDirectory();
|
|
136
|
-
} catch {
|
|
137
|
-
return false;
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
|
|
141
329
|
export function discoverAvailableSkills(cwd: string): Array<{
|
|
142
330
|
name: string;
|
|
143
|
-
source:
|
|
331
|
+
source: SkillSource;
|
|
144
332
|
description?: string;
|
|
145
333
|
}> {
|
|
146
|
-
const skills
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
for (const entry of entries) {
|
|
155
|
-
const fullPath = path.join(dir, entry.name);
|
|
156
|
-
|
|
157
|
-
const isDir = entry.isDirectory() || (entry.isSymbolicLink() && isDirectory(fullPath));
|
|
158
|
-
if (!isDir) continue;
|
|
159
|
-
|
|
160
|
-
const skillPath = path.join(fullPath, "SKILL.md");
|
|
161
|
-
if (!fs.existsSync(skillPath)) continue;
|
|
162
|
-
|
|
163
|
-
if (source === "project" || !seen.has(entry.name)) {
|
|
164
|
-
let description: string | undefined;
|
|
165
|
-
try {
|
|
166
|
-
const content = fs.readFileSync(skillPath, "utf-8");
|
|
167
|
-
if (content.startsWith("---")) {
|
|
168
|
-
const endIndex = content.indexOf("\n---", 3);
|
|
169
|
-
if (endIndex !== -1) {
|
|
170
|
-
const fmBlock = content.slice(0, endIndex);
|
|
171
|
-
const match = fmBlock.match(/description:\s*(.+)/);
|
|
172
|
-
if (match) {
|
|
173
|
-
let desc = match[1].trim();
|
|
174
|
-
if (
|
|
175
|
-
(desc.startsWith("\"") && desc.endsWith("\"")) ||
|
|
176
|
-
(desc.startsWith("'") && desc.endsWith("'"))
|
|
177
|
-
) {
|
|
178
|
-
desc = desc.slice(1, -1);
|
|
179
|
-
}
|
|
180
|
-
description = desc;
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
} catch {}
|
|
185
|
-
|
|
186
|
-
if (source === "project" && seen.has(entry.name)) {
|
|
187
|
-
const idx = skills.findIndex((s) => s.name === entry.name);
|
|
188
|
-
if (idx !== -1) skills.splice(idx, 1);
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
skills.push({ name: entry.name, source, description });
|
|
192
|
-
seen.add(entry.name);
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
} catch {}
|
|
196
|
-
};
|
|
197
|
-
|
|
198
|
-
scanDir(path.join(os.homedir(), ".pi", "agent", "skills"), "user");
|
|
199
|
-
scanDir(path.resolve(cwd, ".pi", "skills"), "project");
|
|
200
|
-
|
|
201
|
-
return skills.sort((a, b) => a.name.localeCompare(b.name));
|
|
334
|
+
const skills = getCachedSkills(cwd);
|
|
335
|
+
return skills
|
|
336
|
+
.map((s) => ({
|
|
337
|
+
name: s.name,
|
|
338
|
+
source: s.source,
|
|
339
|
+
description: s.description,
|
|
340
|
+
}))
|
|
341
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
202
342
|
}
|
|
203
343
|
|
|
204
344
|
export function clearSkillCache(): void {
|
|
205
345
|
skillCache.clear();
|
|
346
|
+
loadSkillsCache = null;
|
|
206
347
|
}
|