pi-roundtable 0.21.0 → 0.22.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 +14 -0
- package/docs/plugins.md +54 -5
- package/package.json +1 -1
- package/src/core/agents/team-turns.ts +2 -7
- package/src/core/builtin/skills.ts +11 -0
- package/src/core/builtin/worker-sessions.ts +44 -0
- package/src/core/config/config.ts +4 -6
- package/src/core/config/skills-config.ts +23 -0
- package/src/core/define-roundtable.ts +8 -0
- package/src/core/discord/owner-card-rows.ts +7 -3
- package/src/core/discord/owner-card-types.ts +4 -0
- package/src/core/discord/owner-cards.ts +10 -3
- package/src/core/domain/turn-timeout.ts +38 -0
- package/src/core/i18n/en.ts +2 -0
- package/src/core/i18n/turn-timeout.ts +131 -0
- package/src/core/i18n/zh-tw.ts +2 -0
- package/src/core/interactions/prompts.ts +17 -0
- package/src/core/modules/delegation/web-research-worker.ts +10 -38
- package/src/core/modules/delegation/worker-session.ts +111 -0
- package/src/core/modules/skills/package-skills-fixture.ts +33 -0
- package/src/core/modules/skills/package-skills.ts +121 -0
- package/src/core/modules/skills/skill-listing.ts +11 -0
- package/src/core/modules/skills/skill-registry.ts +47 -3
- package/src/core/modules/skills/skill-rules.ts +13 -1
- package/src/core/presentation/failure-notice.ts +13 -0
- package/src/core/routing/conversation-turns.ts +2 -7
- package/src/core/runtime/pi-agent-runtime.ts +10 -10
- package/src/core/runtime/prompt-slot.ts +8 -0
- package/src/core/runtime/runtime-types.ts +11 -3
- package/src/core/runtime/turn-activity.ts +42 -0
- package/src/core/runtime/turn-budget.ts +163 -19
- package/src/core/runtime/turn-limits.ts +61 -0
- package/src/core/services.ts +44 -1
- package/src/index.ts +11 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import type { TurnTimeout } from "../domain/turn-timeout.ts";
|
|
2
|
+
|
|
3
|
+
/** A duration in whole seconds, as "3 min 12 s", "45 s", or "1 h 5 min". */
|
|
4
|
+
function durationEn(ms: number): string {
|
|
5
|
+
const s = Math.max(0, Math.round(ms / 1000));
|
|
6
|
+
const h = Math.floor(s / 3600);
|
|
7
|
+
const m = Math.floor((s % 3600) / 60);
|
|
8
|
+
const sec = s % 60;
|
|
9
|
+
if (h > 0) return m > 0 ? `${h} h ${m} min` : `${h} h`;
|
|
10
|
+
if (m > 0) return sec > 0 ? `${m} min ${sec} s` : `${m} min`;
|
|
11
|
+
return `${sec} s`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function durationZh(ms: number): string {
|
|
15
|
+
const s = Math.max(0, Math.round(ms / 1000));
|
|
16
|
+
const h = Math.floor(s / 3600);
|
|
17
|
+
const m = Math.floor((s % 3600) / 60);
|
|
18
|
+
const sec = s % 60;
|
|
19
|
+
if (h > 0) return m > 0 ? `${h} 小時 ${m} 分` : `${h} 小時`;
|
|
20
|
+
if (m > 0) return sec > 0 ? `${m} 分 ${sec} 秒` : `${m} 分`;
|
|
21
|
+
return `${sec} 秒`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The tools called so far, as the interim tool line shows them. */
|
|
25
|
+
function toolList(tools: TurnTimeout["tools"], separator: string): string {
|
|
26
|
+
return tools
|
|
27
|
+
.map(([name, count]) => (count > 1 ? `${name} ×${count}` : name))
|
|
28
|
+
.join(separator);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The timeout card and the notice a turn posts when it ends at a time limit. */
|
|
32
|
+
export function turnTimeoutEn() {
|
|
33
|
+
const doing = (t: TurnTimeout) =>
|
|
34
|
+
t.running.length > 0
|
|
35
|
+
? t.running
|
|
36
|
+
.map(({ tool, ms }) => `${tool}, running for ${durationEn(ms)}`)
|
|
37
|
+
.join("; ")
|
|
38
|
+
: t.compacting
|
|
39
|
+
? "compacting the conversation"
|
|
40
|
+
: "waiting on the model";
|
|
41
|
+
const limit = (t: TurnTimeout) =>
|
|
42
|
+
t.limit === "turn"
|
|
43
|
+
? `its time limit of ${durationEn(t.limitMs)} (since it started or since the last message steered into it)`
|
|
44
|
+
: `its cap of ${durationEn(t.limitMs)} of work in all`;
|
|
45
|
+
const tools = (t: TurnTimeout) =>
|
|
46
|
+
t.tools.length > 0 ? `Tools so far: ${toolList(t.tools, " · ")}.` : "";
|
|
47
|
+
return {
|
|
48
|
+
turnTimeoutTitle: "⏱️ This turn is taking long",
|
|
49
|
+
turnTimeoutQuestion: (t: TurnTimeout, budgetMs: number) =>
|
|
50
|
+
[
|
|
51
|
+
`It reached ${limit(t)} and has worked ${durationEn(t.workedMs)}.`,
|
|
52
|
+
`Now: ${doing(t)}.`,
|
|
53
|
+
tools(t),
|
|
54
|
+
`It keeps working while this card is open. Continue gives it another ${durationEn(budgetMs)}; Cancel stops it. With no answer in ${durationEn(t.graceMs)} it stops.`,
|
|
55
|
+
]
|
|
56
|
+
.filter(Boolean)
|
|
57
|
+
.join("\n"),
|
|
58
|
+
turnTimeoutContinue: "Continue",
|
|
59
|
+
turnTimeoutCancel: "Cancel",
|
|
60
|
+
turnTimeoutFooter:
|
|
61
|
+
"The turn is still working; answer whenever you are ready.",
|
|
62
|
+
turnTimeoutContinued: "▶️ Continued.",
|
|
63
|
+
turnTimeoutCancelled: "❌ Cancelled; the turn stops.",
|
|
64
|
+
turnTimeoutFinished: "✅ The turn finished; nothing to answer.",
|
|
65
|
+
turnTimeoutUnanswered: (t: TurnTimeout) =>
|
|
66
|
+
`⏱️ No answer in ${durationEn(t.graceMs)}; the turn was stopped.`,
|
|
67
|
+
turnTimedOutNotice: (t: TurnTimeout) => {
|
|
68
|
+
const what =
|
|
69
|
+
t.running.length > 0
|
|
70
|
+
? `while ${t.running.map(({ tool, ms }) => `${tool} was running (${durationEn(ms)})`).join(" and ")}`
|
|
71
|
+
: `while ${doing(t)}`;
|
|
72
|
+
const head =
|
|
73
|
+
t.end === "cancelled"
|
|
74
|
+
? `⏱️ Cancelled by the owner after ${durationEn(t.workedMs)} ${what}.`
|
|
75
|
+
: t.end === "unanswered"
|
|
76
|
+
? `⏱️ No answer to the timeout card in ${durationEn(t.graceMs)}, so the turn was stopped after ${durationEn(t.workedMs)} ${what}.`
|
|
77
|
+
: `⏱️ The turn reached ${limit(t)} and was stopped after ${durationEn(t.workedMs)} ${what}.`;
|
|
78
|
+
return [head, tools(t)].filter(Boolean).join(" ");
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function turnTimeoutZhTW(): ReturnType<typeof turnTimeoutEn> {
|
|
84
|
+
const doing = (t: TurnTimeout) =>
|
|
85
|
+
t.running.length > 0
|
|
86
|
+
? t.running
|
|
87
|
+
.map(({ tool, ms }) => `${tool},已執行 ${durationZh(ms)}`)
|
|
88
|
+
.join(";")
|
|
89
|
+
: t.compacting
|
|
90
|
+
? "正在壓縮對話"
|
|
91
|
+
: "正在等模型回應";
|
|
92
|
+
const limit = (t: TurnTimeout) =>
|
|
93
|
+
t.limit === "turn"
|
|
94
|
+
? `時間上限 ${durationZh(t.limitMs)}(從開始或最後一則插入的訊息算起)`
|
|
95
|
+
: `總工作時間上限 ${durationZh(t.limitMs)}`;
|
|
96
|
+
const tools = (t: TurnTimeout) =>
|
|
97
|
+
t.tools.length > 0 ? `目前用過的工具:${toolList(t.tools, " · ")}。` : "";
|
|
98
|
+
return {
|
|
99
|
+
turnTimeoutTitle: "⏱️ 這一輪做得比較久",
|
|
100
|
+
turnTimeoutQuestion: (t: TurnTimeout, budgetMs: number) =>
|
|
101
|
+
[
|
|
102
|
+
`已達到${limit(t)},已工作 ${durationZh(t.workedMs)}。`,
|
|
103
|
+
`現在:${doing(t)}。`,
|
|
104
|
+
tools(t),
|
|
105
|
+
`這張卡片開著時它會繼續工作。按「繼續」再給它 ${durationZh(budgetMs)};按「取消」停止。${durationZh(t.graceMs)}內沒有回答就會停止。`,
|
|
106
|
+
]
|
|
107
|
+
.filter(Boolean)
|
|
108
|
+
.join("\n"),
|
|
109
|
+
turnTimeoutContinue: "繼續",
|
|
110
|
+
turnTimeoutCancel: "取消",
|
|
111
|
+
turnTimeoutFooter: "這一輪仍在工作;準備好再回答即可。",
|
|
112
|
+
turnTimeoutContinued: "▶️ 已繼續。",
|
|
113
|
+
turnTimeoutCancelled: "❌ 已取消,這一輪會停止。",
|
|
114
|
+
turnTimeoutFinished: "✅ 這一輪已完成,不需要回答。",
|
|
115
|
+
turnTimeoutUnanswered: (t: TurnTimeout) =>
|
|
116
|
+
`⏱️ ${durationZh(t.graceMs)}內沒有回答,這一輪已停止。`,
|
|
117
|
+
turnTimedOutNotice: (t: TurnTimeout) => {
|
|
118
|
+
const what =
|
|
119
|
+
t.running.length > 0
|
|
120
|
+
? `當時 ${t.running.map(({ tool, ms }) => `${tool} 正在執行(${durationZh(ms)})`).join("、")}`
|
|
121
|
+
: `當時${doing(t)}`;
|
|
122
|
+
const head =
|
|
123
|
+
t.end === "cancelled"
|
|
124
|
+
? `⏱️ 擁有者在工作 ${durationZh(t.workedMs)} 後取消了這一輪,${what}。`
|
|
125
|
+
: t.end === "unanswered"
|
|
126
|
+
? `⏱️ 逾時卡片在 ${durationZh(t.graceMs)}內沒有回答,這一輪在工作 ${durationZh(t.workedMs)} 後停止,${what}。`
|
|
127
|
+
: `⏱️ 這一輪達到${limit(t)},在工作 ${durationZh(t.workedMs)} 後停止,${what}。`;
|
|
128
|
+
return [head, tools(t)].filter(Boolean).join(" ");
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
}
|
package/src/core/i18n/zh-tw.ts
CHANGED
|
@@ -8,6 +8,7 @@ import type { Messages } from "./en.ts";
|
|
|
8
8
|
import { ownerZhTW } from "./owner.ts";
|
|
9
9
|
import { schedulesZhTW } from "./schedules.ts";
|
|
10
10
|
import { timeZonesZhTW } from "./time-zones.ts";
|
|
11
|
+
import { turnTimeoutZhTW } from "./turn-timeout.ts";
|
|
11
12
|
import type { CatalogContext } from "./types.ts";
|
|
12
13
|
|
|
13
14
|
/** The Traditional Chinese catalog. */
|
|
@@ -22,5 +23,6 @@ export function createZhTW(ctx: CatalogContext): Messages {
|
|
|
22
23
|
...dashboardZhTW(ctx),
|
|
23
24
|
...channelsZhTW(ctx),
|
|
24
25
|
...discordZhTW(ctx),
|
|
26
|
+
...turnTimeoutZhTW(),
|
|
25
27
|
};
|
|
26
28
|
}
|
|
@@ -128,6 +128,23 @@ export interface ApprovalOptions {
|
|
|
128
128
|
* withdrawn call whatever the card answers.
|
|
129
129
|
*/
|
|
130
130
|
supersede?: AbortSignal;
|
|
131
|
+
/**
|
|
132
|
+
* The card's own words, for a card that asks something other than whether to run an action,
|
|
133
|
+
* such as a turn's timeout card; a surface that cannot show them keeps its own.
|
|
134
|
+
*/
|
|
135
|
+
wording?: ApprovalWording;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** A card's words in place of an approval's; see `ApprovalOptions.wording`. */
|
|
139
|
+
export interface ApprovalWording {
|
|
140
|
+
/** The labels of the controls that answer approved and declined. */
|
|
141
|
+
approve: string;
|
|
142
|
+
decline: string;
|
|
143
|
+
/** The footer while the card is open. */
|
|
144
|
+
footer: string;
|
|
145
|
+
/** The footer once it was answered approved, or declined. */
|
|
146
|
+
approved: string;
|
|
147
|
+
declined: string;
|
|
131
148
|
}
|
|
132
149
|
|
|
133
150
|
/** The longest footer a withdrawn card shows, in characters. */
|
|
@@ -1,20 +1,12 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
createAgentSession,
|
|
4
|
-
DefaultResourceLoader,
|
|
5
|
-
type ModelRuntime,
|
|
6
|
-
SessionManager,
|
|
7
|
-
SettingsManager,
|
|
8
|
-
} from "@earendil-works/pi-coding-agent";
|
|
1
|
+
import type { ModelRuntime } from "@earendil-works/pi-coding-agent";
|
|
9
2
|
import { PluginError } from "../../errors.ts";
|
|
10
3
|
import {
|
|
11
4
|
formatModelRef,
|
|
12
5
|
type ModelRef,
|
|
13
6
|
type ThinkingLevel,
|
|
14
7
|
} from "../../models.ts";
|
|
15
|
-
import { runWorkerTask } from "../../runtime/worker-task.ts";
|
|
16
|
-
import { activeToolsExtension } from "../../shared/active-tools.ts";
|
|
17
8
|
import { packageDir } from "../../shared/package-dir.ts";
|
|
9
|
+
import { runWorkerSession } from "./worker-session.ts";
|
|
18
10
|
|
|
19
11
|
const WEB_TOOLS = ["web_search", "fetch_content", "get_search_content"];
|
|
20
12
|
|
|
@@ -50,39 +42,19 @@ export class WebResearchWorker {
|
|
|
50
42
|
constructor(options: WebResearchWorkerOptions) {
|
|
51
43
|
this.#options = options;
|
|
52
44
|
this.#webAccessPath = webAccessDir();
|
|
53
|
-
mkdirSync(options.workDir, { recursive: true });
|
|
54
45
|
}
|
|
55
46
|
|
|
56
|
-
|
|
47
|
+
run(task: string, signal: AbortSignal): Promise<string> {
|
|
57
48
|
const { modelRuntime, agentDir, workDir, model, thinking } = this.#options;
|
|
58
|
-
|
|
59
|
-
cwd: workDir,
|
|
60
|
-
agentDir,
|
|
61
|
-
noExtensions: true,
|
|
62
|
-
noSkills: true,
|
|
63
|
-
noPromptTemplates: true,
|
|
64
|
-
noThemes: true,
|
|
65
|
-
noContextFiles: true,
|
|
66
|
-
additionalExtensionPaths: [this.#webAccessPath],
|
|
67
|
-
extensionFactories: [
|
|
68
|
-
{ name: "web-only", factory: activeToolsExtension(() => WEB_TOOLS) },
|
|
69
|
-
],
|
|
70
|
-
appendSystemPrompt: [WORKER_PROMPT],
|
|
71
|
-
});
|
|
72
|
-
await resourceLoader.reload();
|
|
73
|
-
const { session } = await createAgentSession({
|
|
74
|
-
cwd: workDir,
|
|
75
|
-
agentDir,
|
|
76
|
-
thinkingLevel: thinking,
|
|
77
|
-
modelRuntime,
|
|
78
|
-
resourceLoader,
|
|
79
|
-
sessionManager: SessionManager.inMemory(workDir),
|
|
80
|
-
settingsManager: SettingsManager.inMemory({}),
|
|
81
|
-
noTools: "builtin",
|
|
82
|
-
});
|
|
83
|
-
return runWorkerTask(session, {
|
|
49
|
+
return runWorkerSession({
|
|
84
50
|
modelRuntime,
|
|
51
|
+
agentDir,
|
|
52
|
+
workDir,
|
|
85
53
|
model: formatModelRef(model),
|
|
54
|
+
thinking,
|
|
55
|
+
prompt: WORKER_PROMPT,
|
|
56
|
+
extensionPaths: [this.#webAccessPath],
|
|
57
|
+
toolNames: WEB_TOOLS,
|
|
86
58
|
task,
|
|
87
59
|
signal,
|
|
88
60
|
aborted: "the worker ran out of time",
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { mkdirSync } from "node:fs";
|
|
2
|
+
import {
|
|
3
|
+
createAgentSession,
|
|
4
|
+
DefaultResourceLoader,
|
|
5
|
+
type ExtensionFactory,
|
|
6
|
+
type ModelRuntime,
|
|
7
|
+
SessionManager,
|
|
8
|
+
SettingsManager,
|
|
9
|
+
} from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import type { ThinkingLevel } from "../../models.ts";
|
|
11
|
+
import { runWorkerTask } from "../../runtime/worker-task.ts";
|
|
12
|
+
import type { WorkerSessionRequest, WorkerSessions } from "../../services.ts";
|
|
13
|
+
import { activeToolsExtension } from "../../shared/active-tools.ts";
|
|
14
|
+
|
|
15
|
+
export interface WorkerSessionOptions {
|
|
16
|
+
/** Shared with the rest of the host, so the Codex login refreshes in one place. */
|
|
17
|
+
modelRuntime: ModelRuntime;
|
|
18
|
+
agentDir: string;
|
|
19
|
+
workDir: string;
|
|
20
|
+
/** `<provider>/<id>`. */
|
|
21
|
+
model: string;
|
|
22
|
+
thinking: ThinkingLevel;
|
|
23
|
+
prompt: string;
|
|
24
|
+
/** Installed Pi extension packages that register tools. */
|
|
25
|
+
extensionPaths?: readonly string[];
|
|
26
|
+
/** Extensions that register tools. */
|
|
27
|
+
extensions?: readonly { name: string; factory: ExtensionFactory }[];
|
|
28
|
+
/** The only tools active in the session; no built-in tool is. */
|
|
29
|
+
toolNames: readonly string[];
|
|
30
|
+
task: string;
|
|
31
|
+
signal: AbortSignal;
|
|
32
|
+
aborted: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Runs one task in a fresh, unsaved session with no built-in tool, no skills, no context files and
|
|
37
|
+
* only the named tools active, and returns the worker's report.
|
|
38
|
+
*/
|
|
39
|
+
export async function runWorkerSession(
|
|
40
|
+
options: WorkerSessionOptions,
|
|
41
|
+
): Promise<string> {
|
|
42
|
+
const { modelRuntime, agentDir, workDir, model, thinking } = options;
|
|
43
|
+
mkdirSync(workDir, { recursive: true });
|
|
44
|
+
const resourceLoader = new DefaultResourceLoader({
|
|
45
|
+
cwd: workDir,
|
|
46
|
+
agentDir,
|
|
47
|
+
noExtensions: true,
|
|
48
|
+
noSkills: true,
|
|
49
|
+
noPromptTemplates: true,
|
|
50
|
+
noThemes: true,
|
|
51
|
+
noContextFiles: true,
|
|
52
|
+
additionalExtensionPaths: [...(options.extensionPaths ?? [])],
|
|
53
|
+
extensionFactories: [
|
|
54
|
+
...(options.extensions ?? []),
|
|
55
|
+
// Last, so it undoes any tool another extension re-activates.
|
|
56
|
+
{
|
|
57
|
+
name: "worker-tools-only",
|
|
58
|
+
factory: activeToolsExtension(() => options.toolNames),
|
|
59
|
+
},
|
|
60
|
+
],
|
|
61
|
+
appendSystemPrompt: [options.prompt],
|
|
62
|
+
});
|
|
63
|
+
await resourceLoader.reload();
|
|
64
|
+
options.signal.throwIfAborted();
|
|
65
|
+
const { session } = await createAgentSession({
|
|
66
|
+
cwd: workDir,
|
|
67
|
+
agentDir,
|
|
68
|
+
thinkingLevel: thinking,
|
|
69
|
+
modelRuntime,
|
|
70
|
+
resourceLoader,
|
|
71
|
+
sessionManager: SessionManager.inMemory(workDir),
|
|
72
|
+
settingsManager: SettingsManager.inMemory({}),
|
|
73
|
+
noTools: "builtin",
|
|
74
|
+
});
|
|
75
|
+
return runWorkerTask(session, {
|
|
76
|
+
modelRuntime,
|
|
77
|
+
model,
|
|
78
|
+
task: options.task,
|
|
79
|
+
signal: options.signal,
|
|
80
|
+
aborted: options.aborted,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The `WORKER_SESSIONS` service: worker sessions on the host's model runtime and delegation settings. */
|
|
85
|
+
export function workerSessions(options: {
|
|
86
|
+
modelRuntime: ModelRuntime;
|
|
87
|
+
agentDir: string;
|
|
88
|
+
workDir: string;
|
|
89
|
+
delegation: { model: string; thinking: ThinkingLevel };
|
|
90
|
+
}): WorkerSessions {
|
|
91
|
+
return {
|
|
92
|
+
delegation: options.delegation,
|
|
93
|
+
run: (request: WorkerSessionRequest) =>
|
|
94
|
+
runWorkerSession({
|
|
95
|
+
modelRuntime: options.modelRuntime,
|
|
96
|
+
agentDir: options.agentDir,
|
|
97
|
+
workDir: options.workDir,
|
|
98
|
+
model: request.model ?? options.delegation.model,
|
|
99
|
+
thinking: request.thinking ?? options.delegation.thinking,
|
|
100
|
+
prompt: request.prompt,
|
|
101
|
+
extensions: request.extensions.map((factory, index) => ({
|
|
102
|
+
name: `worker-tools-${index}`,
|
|
103
|
+
factory,
|
|
104
|
+
})),
|
|
105
|
+
toolNames: request.toolNames,
|
|
106
|
+
task: request.task,
|
|
107
|
+
signal: request.signal,
|
|
108
|
+
aborted: request.aborted ?? "the worker ran out of time",
|
|
109
|
+
}),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
import { packageDir } from "../../shared/package-dir.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Installs a fake npm package under `<root>/node_modules` with the layout of `pi-workflow-skills`:
|
|
8
|
+
* `"pi": { "skills": ["./skills"] }` and one folder with a SKILL.md per skill.
|
|
9
|
+
*/
|
|
10
|
+
export function installPackage(
|
|
11
|
+
root: string,
|
|
12
|
+
name: string,
|
|
13
|
+
skills: Record<string, string>,
|
|
14
|
+
manifest: Record<string, unknown> = { pi: { skills: ["./skills"] } },
|
|
15
|
+
): void {
|
|
16
|
+
const dir = join(root, "node_modules", name);
|
|
17
|
+
mkdirSync(dir, { recursive: true });
|
|
18
|
+
writeFileSync(
|
|
19
|
+
join(dir, "package.json"),
|
|
20
|
+
JSON.stringify({ name, version: "1.0.0", ...manifest }),
|
|
21
|
+
);
|
|
22
|
+
for (const [skill, description] of Object.entries(skills)) {
|
|
23
|
+
mkdirSync(join(dir, "skills", skill, "playbooks"), { recursive: true });
|
|
24
|
+
writeFileSync(
|
|
25
|
+
join(dir, "skills", skill, "SKILL.md"),
|
|
26
|
+
`---\nname: ${skill}\ndescription: ${description}\n---\n\n# ${skill}\n`,
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Looks packages up from `<root>`, the way a host finds its own dependencies. */
|
|
32
|
+
export const packageDirIn = (root: string) => (name: string) =>
|
|
33
|
+
packageDir(name, pathToFileURL(join(root, "host.js")));
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { isAbsolute, join, relative as relativePath, resolve } from "node:path";
|
|
3
|
+
import { PluginError } from "../../errors.ts";
|
|
4
|
+
import { packageDir } from "../../shared/package-dir.ts";
|
|
5
|
+
import {
|
|
6
|
+
BUILTIN_SKILLS,
|
|
7
|
+
checkSkillName,
|
|
8
|
+
type PackageSkill,
|
|
9
|
+
readSkillFile,
|
|
10
|
+
} from "./skill-rules.ts";
|
|
11
|
+
|
|
12
|
+
/** The SKILL.md files under `path`: the file itself, or each skill folder found below a directory. */
|
|
13
|
+
function skillFilesAt(path: string): string[] {
|
|
14
|
+
if (statSync(path).isFile()) return [path];
|
|
15
|
+
if (existsSync(join(path, "SKILL.md"))) return [join(path, "SKILL.md")];
|
|
16
|
+
return readdirSync(path, { withFileTypes: true })
|
|
17
|
+
.filter(
|
|
18
|
+
(entry) =>
|
|
19
|
+
entry.isDirectory() &&
|
|
20
|
+
!entry.name.startsWith(".") &&
|
|
21
|
+
entry.name !== "node_modules",
|
|
22
|
+
)
|
|
23
|
+
.flatMap((entry) => skillFilesAt(join(path, entry.name)));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The `pi.skills` entries of a package manifest, or undefined when it declares none. */
|
|
27
|
+
function manifestSkills(manifest: unknown): string[] | undefined {
|
|
28
|
+
const skills = (manifest as { pi?: { skills?: unknown } } | null)?.pi?.skills;
|
|
29
|
+
return Array.isArray(skills) &&
|
|
30
|
+
skills.length > 0 &&
|
|
31
|
+
skills.every((entry) => typeof entry === "string")
|
|
32
|
+
? skills
|
|
33
|
+
: undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function packageSkillsOf(
|
|
37
|
+
name: string,
|
|
38
|
+
resolveDir: (name: string) => string,
|
|
39
|
+
): PackageSkill[] {
|
|
40
|
+
let dir: string;
|
|
41
|
+
try {
|
|
42
|
+
dir = resolveDir(name);
|
|
43
|
+
} catch {
|
|
44
|
+
throw new PluginError(
|
|
45
|
+
`config skills.packages: the package "${name}" is not installed. Install it next to the host, or remove it from skills.packages.`,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
let manifest: unknown;
|
|
49
|
+
try {
|
|
50
|
+
manifest = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
|
|
51
|
+
} catch (error) {
|
|
52
|
+
throw new PluginError(
|
|
53
|
+
`config skills.packages: the package "${name}" has no readable package.json (${(error as Error).message}). Reinstall it, or remove it from skills.packages.`,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
const entries = manifestSkills(manifest);
|
|
57
|
+
if (!entries)
|
|
58
|
+
throw new PluginError(
|
|
59
|
+
`config skills.packages: the package "${name}" lists no skills in its package.json "pi.skills". Remove it from skills.packages, or use a package that ships skills.`,
|
|
60
|
+
);
|
|
61
|
+
const files = entries.flatMap((entry) => {
|
|
62
|
+
const path = resolve(dir, entry);
|
|
63
|
+
const relative = relativePath(dir, path);
|
|
64
|
+
if (relative.startsWith("..") || isAbsolute(relative))
|
|
65
|
+
throw new PluginError(
|
|
66
|
+
`config skills.packages: "${name}" lists the skills path "${entry}" outside the package. Use a package whose pi.skills stay inside it.`,
|
|
67
|
+
);
|
|
68
|
+
if (!existsSync(path))
|
|
69
|
+
throw new PluginError(
|
|
70
|
+
`config skills.packages: "${name}" lists the skills path "${entry}", which is not in the installed package. Reinstall the package, or remove it from skills.packages.`,
|
|
71
|
+
);
|
|
72
|
+
return skillFilesAt(path);
|
|
73
|
+
});
|
|
74
|
+
if (files.length === 0)
|
|
75
|
+
throw new PluginError(
|
|
76
|
+
`config skills.packages: the package "${name}" has no SKILL.md under its pi.skills. Remove it from skills.packages, or use a package that ships skills.`,
|
|
77
|
+
);
|
|
78
|
+
return files.map((file) => {
|
|
79
|
+
const read = readSkillFile(file);
|
|
80
|
+
if ("error" in read)
|
|
81
|
+
throw new PluginError(
|
|
82
|
+
`config skills.packages: the skill file ${relativePath(dir, file)} of "${name}" cannot be read (${read.error}). Reinstall the package, or remove it from skills.packages.`,
|
|
83
|
+
);
|
|
84
|
+
try {
|
|
85
|
+
checkSkillName(read.name);
|
|
86
|
+
} catch (error) {
|
|
87
|
+
throw new PluginError(
|
|
88
|
+
`config skills.packages: "${name}" holds a skill that cannot be offered. ${(error as Error).message} Use another version of the package, or remove it from skills.packages.`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
return { name: read.name, package: name, file };
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The skills of installed npm packages, read from each package's `pi.skills` folders (Pi's
|
|
97
|
+
* manifest). Throws a PluginError naming the fix when a package is not installed or has no
|
|
98
|
+
* skills, or when two packages (or one package twice) offer the same name or a built-in name.
|
|
99
|
+
* A clash with a linked or written skill is found by the registry, which holds those.
|
|
100
|
+
*/
|
|
101
|
+
export function discoverPackageSkills(
|
|
102
|
+
packages: readonly string[],
|
|
103
|
+
resolveDir: (name: string) => string = packageDir,
|
|
104
|
+
): PackageSkill[] {
|
|
105
|
+
const found = new Map<string, PackageSkill>();
|
|
106
|
+
for (const name of new Set(packages))
|
|
107
|
+
for (const skill of packageSkillsOf(name, resolveDir)) {
|
|
108
|
+
const earlier = found.get(skill.name);
|
|
109
|
+
if (earlier)
|
|
110
|
+
throw new PluginError(
|
|
111
|
+
`config skills.packages: the skill "${skill.name}" comes from both "${earlier.package}" and "${skill.package}". Drop one of the packages from skills.packages.`,
|
|
112
|
+
);
|
|
113
|
+
if ((BUILTIN_SKILLS as readonly string[]).includes(skill.name))
|
|
114
|
+
throw new PluginError(
|
|
115
|
+
`config skills.packages: the skill "${skill.name}" of "${skill.package}" has the name of a built-in skill. Drop the package from skills.packages.`,
|
|
116
|
+
);
|
|
117
|
+
found.set(skill.name, skill);
|
|
118
|
+
}
|
|
119
|
+
// pi-lens-ignore: no-sort-without-comparator — skill names sort by code unit, the same order on every machine
|
|
120
|
+
return [...found.values()].sort((a, b) => (a.name < b.name ? -1 : 1));
|
|
121
|
+
}
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
BUILTIN_SKILLS,
|
|
4
4
|
cut,
|
|
5
5
|
MAX_LIST_DESCRIPTION_CHARS,
|
|
6
|
+
type PackageSkill,
|
|
6
7
|
type ResolvedSkill,
|
|
7
8
|
type SkillCatalogEntry,
|
|
8
9
|
type SkillSource,
|
|
@@ -17,6 +18,7 @@ export type SkillResolver = (
|
|
|
17
18
|
/** Every skill, built-in ones first, with what `list` shows as text (web app spec behavior 15). */
|
|
18
19
|
export function skillCatalog(
|
|
19
20
|
store: SkillStore,
|
|
21
|
+
packageSkills: readonly PackageSkill[],
|
|
20
22
|
resolveOne: SkillResolver,
|
|
21
23
|
): SkillCatalogEntry[] {
|
|
22
24
|
const sources: { name: string; source: SkillSource }[] = [
|
|
@@ -24,6 +26,10 @@ export function skillCatalog(
|
|
|
24
26
|
name,
|
|
25
27
|
source: { kind: "builtin" } as const,
|
|
26
28
|
})),
|
|
29
|
+
...packageSkills.map((skill) => ({
|
|
30
|
+
name: skill.name,
|
|
31
|
+
source: { kind: "package", package: skill.package } as const,
|
|
32
|
+
})),
|
|
27
33
|
...store.skills().map((entry) => ({
|
|
28
34
|
name: entry.name,
|
|
29
35
|
source:
|
|
@@ -51,6 +57,7 @@ export function skillCatalog(
|
|
|
51
57
|
/** Every skill, or those of a group or matching a query (behavior 14). */
|
|
52
58
|
export function skillListText(
|
|
53
59
|
store: SkillStore,
|
|
60
|
+
packageSkills: readonly PackageSkill[],
|
|
54
61
|
resolveOne: SkillResolver,
|
|
55
62
|
filter: { group?: string; query?: string } = {},
|
|
56
63
|
): string {
|
|
@@ -72,6 +79,10 @@ export function skillListText(
|
|
|
72
79
|
}));
|
|
73
80
|
const entries = [
|
|
74
81
|
...builtins,
|
|
82
|
+
...packageSkills.map((skill) => ({
|
|
83
|
+
name: skill.name,
|
|
84
|
+
source: `from the package ${skill.package}`,
|
|
85
|
+
})),
|
|
75
86
|
...store.skills().map((entry) => ({
|
|
76
87
|
name: entry.name,
|
|
77
88
|
source:
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { mkdirSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { AgentError } from "../../domain/errors.ts";
|
|
4
|
+
import { PluginError } from "../../errors.ts";
|
|
4
5
|
import type { SkillRegistry } from "../../services.ts";
|
|
5
6
|
import { planLinkedSkills } from "./skill-link.ts";
|
|
6
7
|
import { skillCatalog, skillListText } from "./skill-listing.ts";
|
|
@@ -9,6 +10,7 @@ import {
|
|
|
9
10
|
checkBody,
|
|
10
11
|
checkDescription,
|
|
11
12
|
checkSkillName,
|
|
13
|
+
type PackageSkill,
|
|
12
14
|
type ResolvedSkill,
|
|
13
15
|
readSkillFile,
|
|
14
16
|
type SkillCatalogEntry,
|
|
@@ -22,6 +24,7 @@ export {
|
|
|
22
24
|
BUILTIN_SKILLS,
|
|
23
25
|
checkSkillName,
|
|
24
26
|
MAX_SKILL_BODY_CHARS,
|
|
27
|
+
type PackageSkill,
|
|
25
28
|
type ResolvedSkill,
|
|
26
29
|
type SkillCatalogEntry,
|
|
27
30
|
type SkillRegistryOptions,
|
|
@@ -32,9 +35,19 @@ export {
|
|
|
32
35
|
/** The skill registry and what each agent carries. */
|
|
33
36
|
export class StoredSkillRegistry implements SkillRegistry {
|
|
34
37
|
readonly #options: SkillRegistryOptions;
|
|
38
|
+
readonly #packageSkills = new Map<string, PackageSkill>();
|
|
35
39
|
|
|
40
|
+
/** Throws a PluginError when a package skill has the name of a linked or written one. */
|
|
36
41
|
constructor(options: SkillRegistryOptions) {
|
|
37
42
|
this.#options = options;
|
|
43
|
+
for (const skill of options.packageSkills ?? []) {
|
|
44
|
+
const stored = options.store.skill(skill.name);
|
|
45
|
+
if (stored)
|
|
46
|
+
throw new PluginError(
|
|
47
|
+
`The skill "${skill.name}" of the package "${skill.package}" clashes with a ${stored.kind} skill of the same name. Drop the package from skills.packages and start, remove the ${stored.kind} skill (${stored.kind === "linked" ? "skill_unlink" : "skill_delete"}), then list the package again.`,
|
|
48
|
+
);
|
|
49
|
+
this.#packageSkills.set(skill.name, skill);
|
|
50
|
+
}
|
|
38
51
|
}
|
|
39
52
|
|
|
40
53
|
/** Writes every agent-written skill for Pi and removes folders left by deleted ones. */
|
|
@@ -105,13 +118,18 @@ export class StoredSkillRegistry implements SkillRegistry {
|
|
|
105
118
|
|
|
106
119
|
/** Every skill, built-in ones first, with what `list` shows as text (web app spec behavior 15). */
|
|
107
120
|
catalog(): SkillCatalogEntry[] {
|
|
108
|
-
return skillCatalog(
|
|
121
|
+
return skillCatalog(
|
|
122
|
+
this.#options.store,
|
|
123
|
+
[...this.#packageSkills.values()],
|
|
124
|
+
(name) => this.#resolveOne(name),
|
|
125
|
+
);
|
|
109
126
|
}
|
|
110
127
|
|
|
111
128
|
/** Every skill, or those of a group or matching a query (behavior 14). */
|
|
112
129
|
list(filter: { group?: string; query?: string } = {}): string {
|
|
113
130
|
return skillListText(
|
|
114
131
|
this.#options.store,
|
|
132
|
+
[...this.#packageSkills.values()],
|
|
115
133
|
(name) => this.#resolveOne(name),
|
|
116
134
|
filter,
|
|
117
135
|
);
|
|
@@ -119,7 +137,7 @@ export class StoredSkillRegistry implements SkillRegistry {
|
|
|
119
137
|
|
|
120
138
|
/** Throws when a name is not registered; built-in names are carried already. */
|
|
121
139
|
checkRegistered(names: readonly string[]): void {
|
|
122
|
-
const unknown = names.filter((name) => !this.#
|
|
140
|
+
const unknown = names.filter((name) => !this.#registered(name));
|
|
123
141
|
if (unknown.length > 0)
|
|
124
142
|
throw new AgentError(
|
|
125
143
|
`Unknown skills: ${unknown.join(", ")}. Find names with skill_list.${unknown.some((n) => (BUILTIN_SKILLS as readonly string[]).includes(n)) ? " Built-in skills are always carried." : ""}`,
|
|
@@ -142,6 +160,7 @@ export class StoredSkillRegistry implements SkillRegistry {
|
|
|
142
160
|
}
|
|
143
161
|
|
|
144
162
|
async unlink(name: string): Promise<string> {
|
|
163
|
+
this.#refusePackage(name);
|
|
145
164
|
const entry = this.#entry(name);
|
|
146
165
|
if (entry.kind !== "linked")
|
|
147
166
|
throw new AgentError(
|
|
@@ -206,7 +225,7 @@ export class StoredSkillRegistry implements SkillRegistry {
|
|
|
206
225
|
throw new AgentError("A skill group needs a description.");
|
|
207
226
|
const unknown = skills.filter(
|
|
208
227
|
(skill) =>
|
|
209
|
-
!this.#
|
|
228
|
+
!this.#registered(skill) &&
|
|
210
229
|
!(BUILTIN_SKILLS as readonly string[]).includes(skill),
|
|
211
230
|
);
|
|
212
231
|
if (unknown.length > 0)
|
|
@@ -270,6 +289,8 @@ export class StoredSkillRegistry implements SkillRegistry {
|
|
|
270
289
|
const { store, reposDir, writtenDir, builtinDir } = this.#options;
|
|
271
290
|
if ((BUILTIN_SKILLS as readonly string[]).includes(name))
|
|
272
291
|
return join(builtinDir, name, "SKILL.md");
|
|
292
|
+
const fromPackage = this.#packageSkills.get(name);
|
|
293
|
+
if (fromPackage) return fromPackage.file;
|
|
273
294
|
const entry = store.skill(name);
|
|
274
295
|
if (!entry) return undefined;
|
|
275
296
|
return entry.kind === "linked"
|
|
@@ -288,7 +309,24 @@ export class StoredSkillRegistry implements SkillRegistry {
|
|
|
288
309
|
return entry;
|
|
289
310
|
}
|
|
290
311
|
|
|
312
|
+
#registered(name: string): boolean {
|
|
313
|
+
return (
|
|
314
|
+
this.#packageSkills.has(name) ||
|
|
315
|
+
this.#options.store.skill(name) !== undefined
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Package skills change with their package, never here. */
|
|
320
|
+
#refusePackage(name: string): void {
|
|
321
|
+
const fromPackage = this.#packageSkills.get(name);
|
|
322
|
+
if (fromPackage)
|
|
323
|
+
throw new AgentError(
|
|
324
|
+
`${name} comes from the package ${fromPackage.package} and is read-only here. It changes when the package is upgraded.`,
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
|
|
291
328
|
#writable(name: string): Extract<SkillEntry, { kind: "written" }> {
|
|
329
|
+
this.#refusePackage(name);
|
|
292
330
|
const entry = this.#entry(name);
|
|
293
331
|
if (entry.kind !== "written")
|
|
294
332
|
throw new AgentError(
|
|
@@ -298,8 +336,14 @@ export class StoredSkillRegistry implements SkillRegistry {
|
|
|
298
336
|
}
|
|
299
337
|
|
|
300
338
|
#checkFreeName(name: string): void {
|
|
339
|
+
const fromPackage = this.#packageSkills.get(name);
|
|
340
|
+
if (fromPackage)
|
|
341
|
+
throw new AgentError(
|
|
342
|
+
`A skill named "${name}" already exists: it comes from the package ${fromPackage.package}.`,
|
|
343
|
+
);
|
|
301
344
|
if (
|
|
302
345
|
(BUILTIN_SKILLS as readonly string[]).includes(name) ||
|
|
346
|
+
this.#packageSkills.has(name) ||
|
|
303
347
|
this.#options.store.skill(name)
|
|
304
348
|
)
|
|
305
349
|
throw new AgentError(`A skill named "${name}" already exists.`);
|