@lebronj/pi-suite 0.1.18 → 0.1.20
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 +13 -2
- package/extensions/update-plan.ts +364 -0
- package/package.json +1 -1
- package/skills/pi-skill/SKILL.md +25 -7
- package/vendor/pi-memory/README.md +5 -3
- package/vendor/pi-memory/package.json +1 -1
- package/vendor/pi-memory/src/governance/share-candidates.ts +16 -0
- package/vendor/pi-memory/src/skills/lifecycle.ts +17 -1
- package/vendor/pi-memory/src/sync/downflow.ts +4 -3
- package/vendor/pi-memory/src/sync/queue.ts +34 -7
- package/vendor/pi-memory/src/sync/schemas.ts +18 -0
- package/vendor/pi-memory/src/sync/skill-bundle.ts +150 -0
- package/vendor/pi-memory/test/skill-lifecycle.test.ts +5 -0
- package/vendor/pi-memory/test/sync-local-loop.test.ts +31 -1
package/README.md
CHANGED
|
@@ -15,12 +15,12 @@ pi install npm:pi-web-access
|
|
|
15
15
|
Or use the bootstrap script to install Pi, configure the team OpenAI-compatible endpoint, install this suite, and set up Bun + qmd for memory search:
|
|
16
16
|
|
|
17
17
|
```bash
|
|
18
|
-
curl -fsSL https://registry.npmjs.org/@lebronj/pi-suite/-/pi-suite-0.1.
|
|
18
|
+
curl -fsSL https://registry.npmjs.org/@lebronj/pi-suite/-/pi-suite-0.1.20.tgz | tar -xzO package/scripts/bootstrap.sh | bash
|
|
19
19
|
```
|
|
20
20
|
|
|
21
21
|
## What Is Included
|
|
22
22
|
|
|
23
|
-
- Local extensions: autogoal, goal mode, pet, prompt URL widget, snake, TPS notifications.
|
|
23
|
+
- Local extensions: autogoal, goal mode, update_plan, pet, prompt URL widget, snake, TPS notifications.
|
|
24
24
|
- Prompts: changelog audit, issue analysis, PR review, review workflow, commit workflow, wrap workflow.
|
|
25
25
|
- Skills: provider checklist, Pi capability reference, image-to-editable-PPT workflow.
|
|
26
26
|
- Vendored package: `@jhp/pi-memory`, including qmd search, external curator service, memory/skill-draft versioning, scoped Multica agent roots, review reminders, and local memory/skill self-evolution queues.
|
|
@@ -75,6 +75,17 @@ Behavior:
|
|
|
75
75
|
- Subagents are optional and budgeted; worker subagents must use worktree isolation.
|
|
76
76
|
- Run artifacts are written under `~/.pi/agent/workflow-runs/autogoal-<run-id>/`.
|
|
77
77
|
|
|
78
|
+
## Update Plan
|
|
79
|
+
|
|
80
|
+
The `update_plan` tool gives Pi a Codex-style visible execution checklist for non-trivial tasks. It supports `init`, `start`, `done`, `drop`, `rm`, `append`, and `note`, shows active plan progress in the UI, and injects guidance to use it for 3+ step tasks or user-provided checklists.
|
|
81
|
+
|
|
82
|
+
Useful commands:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
/plan-status
|
|
86
|
+
/plan-clear
|
|
87
|
+
```
|
|
88
|
+
|
|
78
89
|
## Team Model Setup
|
|
79
90
|
|
|
80
91
|
The bootstrap script can be run with `curl | bash`: it reads the API key from the terminal instead of stdin. It asks for an API key and writes:
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
4
|
+
import { Type } from "typebox";
|
|
5
|
+
|
|
6
|
+
type PlanStatus = "pending" | "in_progress" | "completed" | "abandoned";
|
|
7
|
+
|
|
8
|
+
interface PlanItem {
|
|
9
|
+
content: string;
|
|
10
|
+
status: PlanStatus;
|
|
11
|
+
notes?: string[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface PlanPhase {
|
|
15
|
+
name: string;
|
|
16
|
+
items: PlanItem[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface PlanDetails {
|
|
20
|
+
op: string;
|
|
21
|
+
phases: PlanPhase[];
|
|
22
|
+
error?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const PLAN_TOOL_NAME = "update_plan";
|
|
26
|
+
const PLAN_STATE_TYPE = "update-plan-state";
|
|
27
|
+
const WIDGET_KEY = "update-plan";
|
|
28
|
+
|
|
29
|
+
const PlanOp = StringEnum(["list", "init", "start", "done", "drop", "rm", "append", "note"] as const);
|
|
30
|
+
|
|
31
|
+
const PlanOperation = Type.Object({
|
|
32
|
+
op: PlanOp,
|
|
33
|
+
list: Type.Optional(
|
|
34
|
+
Type.Array(
|
|
35
|
+
Type.Object({
|
|
36
|
+
phase: Type.String({ description: "Phase name" }),
|
|
37
|
+
items: Type.Array(Type.String({ description: "Task content" }), { minItems: 1 }),
|
|
38
|
+
}),
|
|
39
|
+
),
|
|
40
|
+
),
|
|
41
|
+
task: Type.Optional(Type.String({ description: "Exact task content" })),
|
|
42
|
+
phase: Type.Optional(Type.String({ description: "Exact phase name" })),
|
|
43
|
+
items: Type.Optional(Type.Array(Type.String({ description: "Task content" }), { minItems: 1 })),
|
|
44
|
+
text: Type.Optional(Type.String({ description: "Note text" })),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const UpdatePlanParams = Type.Object({
|
|
48
|
+
ops: Type.Array(PlanOperation, { minItems: 1, description: "Ordered plan operations" }),
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
type PlanOperationInput = {
|
|
52
|
+
op: "list" | "init" | "start" | "done" | "drop" | "rm" | "append" | "note";
|
|
53
|
+
list?: Array<{ phase: string; items: string[] }>;
|
|
54
|
+
task?: string;
|
|
55
|
+
phase?: string;
|
|
56
|
+
items?: string[];
|
|
57
|
+
text?: string;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
type UpdatePlanInput = { ops: PlanOperationInput[] };
|
|
61
|
+
|
|
62
|
+
function cloneItem(item: PlanItem): PlanItem {
|
|
63
|
+
return {
|
|
64
|
+
content: item.content,
|
|
65
|
+
status: item.status,
|
|
66
|
+
...(item.notes && item.notes.length > 0 ? { notes: [...item.notes] } : {}),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function clonePhases(phases: PlanPhase[]): PlanPhase[] {
|
|
71
|
+
return phases.map((phase) => ({ name: phase.name, items: phase.items.map(cloneItem) }));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function countItems(phases: PlanPhase[]): { total: number; done: number; open: number } {
|
|
75
|
+
let total = 0;
|
|
76
|
+
let done = 0;
|
|
77
|
+
let open = 0;
|
|
78
|
+
for (const phase of phases) {
|
|
79
|
+
for (const item of phase.items) {
|
|
80
|
+
total++;
|
|
81
|
+
if (item.status === "completed") done++;
|
|
82
|
+
if (item.status === "pending" || item.status === "in_progress") open++;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return { total, done, open };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function normalizeOpenTask(phases: PlanPhase[]): void {
|
|
89
|
+
const items = phases.flatMap((phase) => phase.items);
|
|
90
|
+
const active = items.filter((item) => item.status === "in_progress");
|
|
91
|
+
for (const item of active.slice(1)) {
|
|
92
|
+
item.status = "pending";
|
|
93
|
+
}
|
|
94
|
+
if (active.length > 0) return;
|
|
95
|
+
const next = items.find((item) => item.status === "pending");
|
|
96
|
+
if (next) next.status = "in_progress";
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function findPhase(phases: PlanPhase[], name: string | undefined): PlanPhase | undefined {
|
|
100
|
+
if (!name) return undefined;
|
|
101
|
+
return phases.find((phase) => phase.name === name);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function findTask(phases: PlanPhase[], content: string | undefined): { phase: PlanPhase; item: PlanItem } | undefined {
|
|
105
|
+
if (!content) return undefined;
|
|
106
|
+
for (const phase of phases) {
|
|
107
|
+
const item = phase.items.find((candidate) => candidate.content === content);
|
|
108
|
+
if (item) return { phase, item };
|
|
109
|
+
}
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function renderTextPlan(phases: PlanPhase[]): string {
|
|
114
|
+
if (phases.length === 0) return "No active plan.";
|
|
115
|
+
const lines: string[] = [];
|
|
116
|
+
for (const phase of phases) {
|
|
117
|
+
lines.push(`${phase.name}:`);
|
|
118
|
+
for (const item of phase.items) {
|
|
119
|
+
const marker = item.status === "completed" ? "[x]" : item.status === "abandoned" ? "[-]" : item.status === "in_progress" ? "[>]" : "[ ]";
|
|
120
|
+
lines.push(` ${marker} ${item.content}`);
|
|
121
|
+
if (item.status === "in_progress" && item.notes && item.notes.length > 0) {
|
|
122
|
+
for (const note of item.notes.slice(-2)) lines.push(` note: ${note}`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const counts = countItems(phases);
|
|
127
|
+
lines.push(``);
|
|
128
|
+
lines.push(`${counts.done}/${counts.total} completed, ${counts.open} open`);
|
|
129
|
+
return lines.join("\n");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function renderWidgetLines(ctx: ExtensionContext, phases: PlanPhase[]): string[] | undefined {
|
|
133
|
+
if (phases.length === 0) return undefined;
|
|
134
|
+
const th = ctx.ui.theme;
|
|
135
|
+
const counts = countItems(phases);
|
|
136
|
+
const lines: string[] = [th.fg("accent", `Plan ${counts.done}/${counts.total}`)];
|
|
137
|
+
let shown = 0;
|
|
138
|
+
let hidden = 0;
|
|
139
|
+
for (const phase of phases) {
|
|
140
|
+
const openItems = phase.items.filter((item) => item.status === "pending" || item.status === "in_progress");
|
|
141
|
+
if (openItems.length === 0) continue;
|
|
142
|
+
if (shown >= 6) {
|
|
143
|
+
hidden += openItems.length;
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
lines.push(th.fg("muted", phase.name));
|
|
147
|
+
for (const item of openItems) {
|
|
148
|
+
if (shown >= 6) {
|
|
149
|
+
hidden++;
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
const marker = item.status === "in_progress" ? th.fg("accent", "[>]") : th.fg("muted", "[ ]");
|
|
153
|
+
lines.push(`${marker} ${item.content}`);
|
|
154
|
+
shown++;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (hidden > 0) lines.push(th.fg("dim", `... ${hidden} more`));
|
|
158
|
+
if (counts.open === 0) lines.push(th.fg("success", "All plan items are closed."));
|
|
159
|
+
return lines;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function updatePlanUi(ctx: ExtensionContext, phases: PlanPhase[]): void {
|
|
163
|
+
if (!ctx.hasUI) return;
|
|
164
|
+
ctx.ui.setWidget(WIDGET_KEY, renderWidgetLines(ctx, phases));
|
|
165
|
+
const counts = countItems(phases);
|
|
166
|
+
ctx.ui.setStatus(WIDGET_KEY, counts.total > 0 ? ctx.ui.theme.fg("accent", `plan ${counts.done}/${counts.total}`) : undefined);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function applyOperation(phases: PlanPhase[], op: PlanOperationInput): string | undefined {
|
|
170
|
+
switch (op.op) {
|
|
171
|
+
case "list":
|
|
172
|
+
return undefined;
|
|
173
|
+
case "init": {
|
|
174
|
+
if (!op.list || op.list.length === 0) return "init requires list";
|
|
175
|
+
phases.splice(
|
|
176
|
+
0,
|
|
177
|
+
phases.length,
|
|
178
|
+
...op.list.map((phase) => ({
|
|
179
|
+
name: phase.phase,
|
|
180
|
+
items: phase.items.map((content, index) => ({ content, status: index === 0 && phases.length === 0 ? "in_progress" : "pending" as PlanStatus })),
|
|
181
|
+
})),
|
|
182
|
+
);
|
|
183
|
+
normalizeOpenTask(phases);
|
|
184
|
+
return undefined;
|
|
185
|
+
}
|
|
186
|
+
case "append": {
|
|
187
|
+
if (!op.phase) return "append requires phase";
|
|
188
|
+
if (!op.items || op.items.length === 0) return "append requires items";
|
|
189
|
+
let phase = findPhase(phases, op.phase);
|
|
190
|
+
if (!phase) {
|
|
191
|
+
phase = { name: op.phase, items: [] };
|
|
192
|
+
phases.push(phase);
|
|
193
|
+
}
|
|
194
|
+
phase.items.push(...op.items.map((content) => ({ content, status: "pending" as PlanStatus })));
|
|
195
|
+
normalizeOpenTask(phases);
|
|
196
|
+
return undefined;
|
|
197
|
+
}
|
|
198
|
+
case "start": {
|
|
199
|
+
const hit = findTask(phases, op.task);
|
|
200
|
+
if (!hit) return "start requires an existing task";
|
|
201
|
+
for (const phase of phases) {
|
|
202
|
+
for (const item of phase.items) {
|
|
203
|
+
if (item.status === "in_progress") item.status = "pending";
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
hit.item.status = "in_progress";
|
|
207
|
+
return undefined;
|
|
208
|
+
}
|
|
209
|
+
case "done":
|
|
210
|
+
case "drop": {
|
|
211
|
+
const status: PlanStatus = op.op === "done" ? "completed" : "abandoned";
|
|
212
|
+
if (op.task) {
|
|
213
|
+
const hit = findTask(phases, op.task);
|
|
214
|
+
if (!hit) return `${op.op} requires an existing task`;
|
|
215
|
+
hit.item.status = status;
|
|
216
|
+
} else if (op.phase) {
|
|
217
|
+
const phase = findPhase(phases, op.phase);
|
|
218
|
+
if (!phase) return `${op.op} requires an existing phase`;
|
|
219
|
+
for (const item of phase.items) item.status = status;
|
|
220
|
+
} else {
|
|
221
|
+
return `${op.op} requires task or phase`;
|
|
222
|
+
}
|
|
223
|
+
normalizeOpenTask(phases);
|
|
224
|
+
return undefined;
|
|
225
|
+
}
|
|
226
|
+
case "rm": {
|
|
227
|
+
if (!op.task && !op.phase) {
|
|
228
|
+
phases.splice(0, phases.length);
|
|
229
|
+
return undefined;
|
|
230
|
+
}
|
|
231
|
+
if (op.task) {
|
|
232
|
+
const hit = findTask(phases, op.task);
|
|
233
|
+
if (!hit) return "rm requires an existing task";
|
|
234
|
+
hit.phase.items = hit.phase.items.filter((item) => item !== hit.item);
|
|
235
|
+
} else if (op.phase) {
|
|
236
|
+
const index = phases.findIndex((phase) => phase.name === op.phase);
|
|
237
|
+
if (index < 0) return "rm requires an existing phase";
|
|
238
|
+
phases.splice(index, 1);
|
|
239
|
+
}
|
|
240
|
+
for (let i = phases.length - 1; i >= 0; i--) {
|
|
241
|
+
if (phases[i].items.length === 0) phases.splice(i, 1);
|
|
242
|
+
}
|
|
243
|
+
normalizeOpenTask(phases);
|
|
244
|
+
return undefined;
|
|
245
|
+
}
|
|
246
|
+
case "note": {
|
|
247
|
+
if (!op.text) return "note requires text";
|
|
248
|
+
const hit = findTask(phases, op.task);
|
|
249
|
+
if (!hit) return "note requires an existing task";
|
|
250
|
+
hit.item.notes = [...(hit.item.notes ?? []), op.text];
|
|
251
|
+
return undefined;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export default function updatePlanExtension(pi: ExtensionAPI): void {
|
|
257
|
+
let phases: PlanPhase[] = [];
|
|
258
|
+
|
|
259
|
+
function restoreFromEntries(ctx: ExtensionContext): void {
|
|
260
|
+
phases = [];
|
|
261
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
262
|
+
if (entry.type === "custom" && entry.customType === PLAN_STATE_TYPE) {
|
|
263
|
+
const data = entry.data as { phases?: PlanPhase[] } | undefined;
|
|
264
|
+
if (data?.phases) phases = clonePhases(data.phases);
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
if (entry.type !== "message") continue;
|
|
268
|
+
const message = entry.message as { role?: string; toolName?: string; details?: unknown; isError?: boolean };
|
|
269
|
+
if (message.role !== "toolResult" || message.toolName !== PLAN_TOOL_NAME || message.isError) continue;
|
|
270
|
+
const details = message.details as PlanDetails | undefined;
|
|
271
|
+
if (details?.phases) phases = clonePhases(details.phases);
|
|
272
|
+
}
|
|
273
|
+
updatePlanUi(ctx, phases);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
pi.on("session_start", async (_event, ctx) => restoreFromEntries(ctx));
|
|
277
|
+
pi.on("session_tree", async (_event, ctx) => restoreFromEntries(ctx));
|
|
278
|
+
|
|
279
|
+
pi.on("before_agent_start", async () => {
|
|
280
|
+
if (!pi.getActiveTools().includes(PLAN_TOOL_NAME)) return;
|
|
281
|
+
return {
|
|
282
|
+
message: {
|
|
283
|
+
customType: "update-plan-guidance",
|
|
284
|
+
content: `<update_plan_guidance>\nFor non-trivial tasks with 3+ distinct steps, or when the user provides a checklist/plan, call update_plan before implementation. Keep exactly one open task in_progress, update it immediately after each completed step, and do not use update_plan for trivial one-step requests.\n</update_plan_guidance>`,
|
|
285
|
+
display: false,
|
|
286
|
+
},
|
|
287
|
+
};
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
pi.registerTool({
|
|
291
|
+
name: PLAN_TOOL_NAME,
|
|
292
|
+
label: "Update Plan",
|
|
293
|
+
description:
|
|
294
|
+
"Maintain a visible execution plan. Use for multi-step tasks: init the plan, mark exactly one task in_progress, and mark tasks done/drop as work proceeds.",
|
|
295
|
+
promptSnippet: "Maintain a visible execution plan for non-trivial multi-step tasks.",
|
|
296
|
+
promptGuidelines: [
|
|
297
|
+
"For tasks with 3+ distinct steps, or when the user provides a checklist, call update_plan with init before doing the work.",
|
|
298
|
+
"Keep exactly one open task in_progress; mark tasks done immediately after completing them.",
|
|
299
|
+
"Do not use update_plan for trivial single-step requests.",
|
|
300
|
+
],
|
|
301
|
+
parameters: UpdatePlanParams,
|
|
302
|
+
executionMode: "sequential",
|
|
303
|
+
async execute(_toolCallId, params: UpdatePlanInput, _signal, _onUpdate, ctx) {
|
|
304
|
+
let error: string | undefined;
|
|
305
|
+
for (const op of params.ops) {
|
|
306
|
+
error = applyOperation(phases, op);
|
|
307
|
+
if (error) break;
|
|
308
|
+
}
|
|
309
|
+
updatePlanUi(ctx, phases);
|
|
310
|
+
const lastOp = params.ops[params.ops.length - 1]?.op ?? "list";
|
|
311
|
+
const text = error ? `Error: ${error}` : renderTextPlan(phases);
|
|
312
|
+
return {
|
|
313
|
+
content: [{ type: "text", text }],
|
|
314
|
+
details: { op: lastOp, phases: clonePhases(phases), error } satisfies PlanDetails,
|
|
315
|
+
isError: error ? true : undefined,
|
|
316
|
+
};
|
|
317
|
+
},
|
|
318
|
+
renderCall(args: UpdatePlanInput, theme: Theme) {
|
|
319
|
+
const ops = args.ops?.map((op) => op.op).join("+") || "list";
|
|
320
|
+
return new Text(`${theme.fg("toolTitle", theme.bold("update_plan"))} ${theme.fg("muted", ops)}`, 0, 0);
|
|
321
|
+
},
|
|
322
|
+
renderResult(result, { expanded }, theme: Theme) {
|
|
323
|
+
const details = result.details as PlanDetails | undefined;
|
|
324
|
+
if (!details) {
|
|
325
|
+
const block = result.content?.find((item) => item.type === "text");
|
|
326
|
+
return new Text(block?.text ?? "", 0, 0);
|
|
327
|
+
}
|
|
328
|
+
if (details.error) return new Text(theme.fg("error", `Error: ${details.error}`), 0, 0);
|
|
329
|
+
const snapshot = details.phases ?? [];
|
|
330
|
+
if (snapshot.length === 0) return new Text(theme.fg("dim", "No active plan"), 0, 0);
|
|
331
|
+
const counts = countItems(snapshot);
|
|
332
|
+
const lines = [theme.fg("accent", `Plan ${counts.done}/${counts.total}`)];
|
|
333
|
+
for (const phase of snapshot) {
|
|
334
|
+
lines.push(theme.fg("muted", phase.name));
|
|
335
|
+
const items = expanded ? phase.items : phase.items.slice(0, 5);
|
|
336
|
+
for (const item of items) {
|
|
337
|
+
const marker = item.status === "completed" ? theme.fg("success", "[x]") : item.status === "abandoned" ? theme.fg("dim", "[-]") : item.status === "in_progress" ? theme.fg("accent", "[>]") : theme.fg("muted", "[ ]");
|
|
338
|
+
const text = item.status === "completed" || item.status === "abandoned" ? theme.fg("dim", item.content) : item.content;
|
|
339
|
+
lines.push(`${marker} ${text}`);
|
|
340
|
+
}
|
|
341
|
+
if (!expanded && phase.items.length > items.length) lines.push(theme.fg("dim", `... ${phase.items.length - items.length} more`));
|
|
342
|
+
}
|
|
343
|
+
return new Text(lines.join("\n"), 0, 0);
|
|
344
|
+
},
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
pi.registerCommand("plan-status", {
|
|
348
|
+
description: "Show the current update_plan state",
|
|
349
|
+
handler: async (_args, ctx) => {
|
|
350
|
+
restoreFromEntries(ctx);
|
|
351
|
+
ctx.ui.notify(renderTextPlan(phases), "info");
|
|
352
|
+
},
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
pi.registerCommand("plan-clear", {
|
|
356
|
+
description: "Clear the current update_plan state",
|
|
357
|
+
handler: async (_args, ctx) => {
|
|
358
|
+
phases = [];
|
|
359
|
+
pi.appendEntry(PLAN_STATE_TYPE, { phases: [] });
|
|
360
|
+
updatePlanUi(ctx, phases);
|
|
361
|
+
ctx.ui.notify("Plan cleared.", "info");
|
|
362
|
+
},
|
|
363
|
+
});
|
|
364
|
+
}
|
package/package.json
CHANGED
package/skills/pi-skill/SKILL.md
CHANGED
|
@@ -40,8 +40,9 @@ Memory files include:
|
|
|
40
40
|
- `.curator-state.json`: last curator run state.
|
|
41
41
|
- `.curator-service.json`: external curator service state.
|
|
42
42
|
- `audit/curator.jsonl`: curator audit trail.
|
|
43
|
-
- Resolved skill draft root `<slug>/SKILL.md`: disabled skill drafts created after explicit approval.
|
|
43
|
+
- Resolved skill draft root `<slug>/SKILL.md`: disabled skill drafts created after explicit approval; skill directories may include supporting files alongside `SKILL.md`.
|
|
44
44
|
- Multica agent roots also contain `inbox/`, `shared-cache/`, `skills/generated/`, `profile/`, `feedback/feedback.jsonl`, and `sync_queue/`.
|
|
45
|
+
- Skill share candidates are runnable bundles: `sync_queue/skill-candidates.jsonl` is the queue/manifest, while `sync_queue/skill-candidates/<local_unit_id>/` contains `SKILL.md` plus supporting files.
|
|
45
46
|
|
|
46
47
|
Memory tools:
|
|
47
48
|
|
|
@@ -55,7 +56,7 @@ Memory tools:
|
|
|
55
56
|
- `memory_learning_reject`: reject or archive a review candidate/proposal without deleting it.
|
|
56
57
|
- `memory_skill_drafts`: list proposed skill drafts.
|
|
57
58
|
- `memory_skill_list`: list current-agent draft, generated, and enabled memory-managed skills.
|
|
58
|
-
- `memory_skill_enable`: explicitly enable a `draft:<slug>` or `generated:<id>` skill by copying
|
|
59
|
+
- `memory_skill_enable`: explicitly enable a `draft:<slug>` or `generated:<id>` skill by copying the full skill directory into `skills/enabled/<skill-name>/` and auditing the action.
|
|
59
60
|
- `memory_skill_disable`: remove an enabled skill copy while preserving its draft/generated source.
|
|
60
61
|
- `/memory-skill`: slash command to list/enable/disable current-agent memory-managed skills.
|
|
61
62
|
- `/memory-review`: slash command to list/show/approve/reject/archive pending memory and skill proposals in the current resolved root.
|
|
@@ -87,14 +88,16 @@ Curator and learning behavior:
|
|
|
87
88
|
- Expired temporary memories go to `REVIEW.md`, not automatic deletion.
|
|
88
89
|
- Quotas reset when `month` or `reset` rolls over.
|
|
89
90
|
- Mutations are audited to `audit/curator.jsonl`.
|
|
90
|
-
- Session shutdown may extract conservative learning candidates into `REVIEW.md`;
|
|
91
|
+
- Session shutdown may extract conservative learning candidates into `REVIEW.md`; `/new` and `/fork` also run a lightweight structured-evidence pass.
|
|
91
92
|
- `memory_curate` scans yesterday's daily log once per content hash into review candidates, then curator lifecycle and proposal rules process those candidates.
|
|
92
|
-
- Repeated candidates can become proposed memory promotions or proposed disabled skill drafts after `memory_curate`.
|
|
93
|
-
-
|
|
94
|
-
-
|
|
93
|
+
- Repeated or high-confidence candidates can become proposed memory promotions or proposed disabled skill drafts after `memory_curate`.
|
|
94
|
+
- Skill-worthy `failure -> edit/action -> validation success` patterns become high-confidence skill candidates from structured tool evidence.
|
|
95
|
+
- Skill drafts default to `auto-draft`: high-quality proposals write disabled drafts under the resolved skill draft root, but memory proposals still require approval.
|
|
96
|
+
- Draft and generated skills stay disabled until `memory_skill_enable` copies their full directories into `skills/enabled`; enabled skills are injected as `<available_skills>` metadata for the current agent.
|
|
95
97
|
- Pi session start can show one pending-review hint; disable with `PI_MEMORY_REVIEW_STARTUP_HINT=0`.
|
|
96
98
|
- Local multi-agent self-evolution supports one Local Curator Manager registry/dirty-root API for many agent roots, plus a manager service that runs `manager-scan` every 6 hours and exits quickly when no root is dirty.
|
|
97
99
|
- The local loop also covers share candidate queue, profile generation, sync upload/pull, downflow receive cache, generated skills, enabled skill lifecycle, and feedback JSONL helpers.
|
|
100
|
+
- Skill upload/downflow follows Multica's runnable bundle shape: `content` is `SKILL.md`, `files` are supporting files, and `content_hash` covers both.
|
|
98
101
|
- Server downflow is per-Agent delivery, not broadcast; local receive writes only `inbox/`, `shared-cache/`, or `skills/generated/` and never overwrites formal memory or auto-enables skills.
|
|
99
102
|
- The curator avoids semantic auto-delete/merge; ambiguous learning stays in review first.
|
|
100
103
|
|
|
@@ -141,12 +144,16 @@ Useful memory environment variables:
|
|
|
141
144
|
- `PI_MEMORY_NO_SEARCH=1`: disable per-turn search injection.
|
|
142
145
|
- `PI_MEMORY_SUMMARIZE_TRANSITIONS=1`: also summarize lifecycle transitions such as `/reload`.
|
|
143
146
|
- `PI_MEMORY_LEARNING`: `off`, `review`, or `auto-review`.
|
|
144
|
-
- `PI_MEMORY_SKILL_DRAFTS`: `off` or `
|
|
147
|
+
- `PI_MEMORY_SKILL_DRAFTS`: `off`, `propose`/`review`, or `auto-draft` (default).
|
|
148
|
+
- `PI_MEMORY_SKILL_SEEN_THRESHOLD`: repeated medium-confidence skill candidate threshold; default `2`.
|
|
145
149
|
- `PI_MEMORY_AUTO_APPROVE_MEMORY=1`: automatically approve newly created memory proposals.
|
|
146
150
|
- `PI_MEMORY_AUTO_APPROVE_SKILL_DRAFTS=1`: automatically create newly proposed disabled skill drafts.
|
|
147
151
|
- `PI_MEMORY_CURATOR_STARTUP_HINT=0`: hide the disabled-curator startup hint.
|
|
148
152
|
- `PI_MEMORY_REVIEW_STARTUP_HINT=0`: hide pending review proposal startup hints.
|
|
149
153
|
- `PI_MEMORY_REMOTE_URL` and `PI_MEMORY_REMOTE_TOKEN`: enable Multica candidate/profile/feedback upload and current-agent delivery pull.
|
|
154
|
+
- Local CLI Pi can bind to Multica by setting `MULTICA_WORKSPACE_ID`, `MULTICA_AGENT_ID`, `PI_MEMORY_REMOTE_URL`, and `PI_MEMORY_REMOTE_TOKEN`; it then uses the same agent root/sync loop as a Multica-wrapped Pi agent.
|
|
155
|
+
- `PI_MEMORY_AUTO_SYNC=1`: best-effort automatic pull on session start and upload on session shutdown; narrower aliases are `PI_MEMORY_AUTO_SYNC_PULL_ON_START=1`, `PI_MEMORY_AUTO_SYNC_PULL=1`, `PI_MEMORY_AUTO_SYNC_UPLOAD_ON_SHUTDOWN=1`, and `PI_MEMORY_AUTO_SYNC_UPLOAD=1`.
|
|
156
|
+
- `PI_MEMORY_AUTO_SYNC_PULL_LIMIT`: maximum automatic delivery pull count; default `20`.
|
|
150
157
|
- `PI_EVOLUTION_ENABLED=0`: disable snapshot + git versioning.
|
|
151
158
|
- `PI_EVOLUTION_DIR`: override evolution repo directory; default `~/.pi/agent/evolution`.
|
|
152
159
|
- `PI_EVOLUTION_REMOTE`: optional personal private Git remote; unset by default.
|
|
@@ -219,6 +226,16 @@ Goal mode is provided by `goal-mode.ts`.
|
|
|
219
226
|
- The agent must verify current files/checks before calling `goal({ op: "complete" })`.
|
|
220
227
|
- Useful commands: `/goal show`, `/goal pause`, `/goal resume`, `/goal drop`, `/goal budget <tokens|off>`, `/goal auto on`, `/goal auto off`.
|
|
221
228
|
|
|
229
|
+
## Update Plan
|
|
230
|
+
|
|
231
|
+
`update-plan.ts` provides a lightweight Codex-style execution checklist without changing Pi core.
|
|
232
|
+
|
|
233
|
+
- Tool: `update_plan`.
|
|
234
|
+
- Use it for non-trivial tasks with 3+ distinct steps, user-provided checklists, or explicit plan/progress tracking requests.
|
|
235
|
+
- Operations: `list`, `init`, `start`, `done`, `drop`, `rm`, `append`, and `note`.
|
|
236
|
+
- It keeps phased task state in session history, auto-promotes the next pending task to `in_progress`, and shows widget/footer progress when active.
|
|
237
|
+
- Commands: `/plan-status` shows the current plan; `/plan-clear` clears it.
|
|
238
|
+
|
|
222
239
|
## Pet Companion
|
|
223
240
|
|
|
224
241
|
The pet extension provides a small terminal companion and durable profile.
|
|
@@ -231,6 +248,7 @@ The pet extension provides a small terminal companion and durable profile.
|
|
|
231
248
|
|
|
232
249
|
## UI And Utility Extensions
|
|
233
250
|
|
|
251
|
+
- `update-plan.ts`: registers `update_plan`, `/plan-status`, and `/plan-clear` for visible per-session execution planning.
|
|
234
252
|
- `prompt-url-widget.ts`: detects PR/issue prompt templates, fetches GitHub metadata with `gh`, shows a widget, and names the session when possible.
|
|
235
253
|
- `snake.ts`: `/snake` opens a TUI snake game; `Esc` pauses/saves, `q` quits, arrows/WASD move.
|
|
236
254
|
- `tps.ts`: after each assistant run, shows tokens-per-second and token usage details.
|
|
@@ -59,6 +59,8 @@ The extension auto-creates the `pi-memory` qmd collection and path contexts on s
|
|
|
59
59
|
profile/user-profile.md agent-profile.md task-profile.md capability-profile.md
|
|
60
60
|
feedback/feedback.jsonl
|
|
61
61
|
sync_queue/memory-candidates.jsonl skill-candidates.jsonl
|
|
62
|
+
sync_queue/skill-candidates/<local_unit_id>/SKILL.md # Runnable upload bundle
|
|
63
|
+
sync_queue/skill-candidates/<local_unit_id>/** # Supporting files
|
|
62
64
|
```
|
|
63
65
|
|
|
64
66
|
Structured entries are separated by `§` and may start with metadata:
|
|
@@ -190,7 +192,7 @@ Approval is explicit by default:
|
|
|
190
192
|
- `memory_learning_approve` on a memory proposal writes `MEMORY.md`, `USER.md`, or `STATE.md` depending on the proposal target.
|
|
191
193
|
- `memory_learning_approve` on a skill proposal writes the current resolved skill draft root and marks the proposal approved.
|
|
192
194
|
- Skill drafts are disabled. They are not moved into enabled skill directories automatically.
|
|
193
|
-
- `memory_skill_enable` explicitly copies a `draft:<slug>` or `generated:<id>` skill into `skills/enabled/<skill-name>/` and writes `memory/audit/skill-lifecycle.jsonl`.
|
|
195
|
+
- `memory_skill_enable` explicitly copies a full `draft:<slug>` or `generated:<id>` skill directory into `skills/enabled/<skill-name>/` and writes `memory/audit/skill-lifecycle.jsonl`.
|
|
194
196
|
- `memory_skill_disable` removes only the enabled copy; the draft/generated source remains for later review.
|
|
195
197
|
- Enabled skills are injected as available-skill metadata so the agent can read the corresponding `SKILL.md` when the task matches.
|
|
196
198
|
- `memory_learning_reject` marks a candidate or proposal as `rejected` or `archived` without deleting it.
|
|
@@ -214,11 +216,11 @@ The package includes local primitives for the full local loop:
|
|
|
214
216
|
|
|
215
217
|
- `ensureAgentRoot()` initializes the scoped directory tree.
|
|
216
218
|
- `markCurrentRootDirty()` and `scanDirtyRoots()` implement a single Local Curator Manager registry, manager-level locking, stale lock cleanup, and per-root `.curator.lock` processing.
|
|
217
|
-
- `generateShareCandidatesFromReview()` and `appendEvolutionCandidate()` write governed share candidates to `sync_queue/` and block secret-like payloads.
|
|
219
|
+
- `generateShareCandidatesFromReview()` and `appendEvolutionCandidate()` write governed share candidates to `sync_queue/` and block secret-like payloads. Skill candidates follow Multica's runnable bundle shape: JSONL contains queue metadata plus `content` as `SKILL.md` and `files` as supporting files, while `sync_queue/skill-candidates/<local_unit_id>/` stores the same runnable directory for inspection/upload.
|
|
218
220
|
- `generateProfiles()` writes conservative local profiles for remote matching input.
|
|
219
221
|
- `syncUpload()` / `memory_sync_upload` POST candidates, profiles, and feedback, using a checkpoint to avoid re-uploading prior candidate ids or feedback lines.
|
|
220
222
|
- `syncPull()` / `memory_sync_pull` pull only current-agent deliveries and call `receiveDelivery()`.
|
|
221
|
-
- `receiveDelivery()` writes server downflow only to `inbox/`, `shared-cache/`, or `skills/generated/`;
|
|
223
|
+
- `receiveDelivery()` writes server downflow only to `inbox/`, `shared-cache/`, or `skills/generated/`; skill deliveries restore `SKILL.md` plus supporting `files`, but never overwrite formal memory or enable skills.
|
|
222
224
|
- `appendFeedbackEvent()` / `memory_feedback` writes injected/used/ignored/success/failure/conflict events to `feedback/feedback.jsonl` for connector upload.
|
|
223
225
|
|
|
224
226
|
Server delivery is per-agent matching, not broadcast. The local runtime must only pull deliveries for the current `MULTICA_AGENT_ID` and still filter before injection.
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
1
3
|
import { parseEntry } from "../curator-core/metadata.ts";
|
|
2
4
|
import type { MemoryStore } from "../curator-store/types.ts";
|
|
3
5
|
import type { PiAgentEnv } from "../paths/resolve-roots.ts";
|
|
@@ -23,9 +25,17 @@ export async function generateShareCandidatesFromReview(memoryStore: MemoryStore
|
|
|
23
25
|
}
|
|
24
26
|
try {
|
|
25
27
|
const type = parsed.metadata.kind === "skill_promotion" || parsed.metadata.target_hints?.includes("skill") ? "skill" : "memory";
|
|
28
|
+
const sourcePath = type === "skill" ? sourceSkillDir(parsed.metadata.promotes_to) : undefined;
|
|
29
|
+
if (type === "skill" && !sourcePath) {
|
|
30
|
+
result.skipped += 1;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
26
33
|
const appended = appendEvolutionCandidate({
|
|
27
34
|
type,
|
|
28
35
|
content,
|
|
36
|
+
name: parsed.metadata.name,
|
|
37
|
+
description: parsed.metadata.description,
|
|
38
|
+
source_path: sourcePath,
|
|
29
39
|
tags: tagsFromEntry(parsed.metadata.tags || parsed.metadata.kind || "memory"),
|
|
30
40
|
source: "local_curator",
|
|
31
41
|
suggested_scope: suggestedScope(parsed.metadata.scope),
|
|
@@ -66,6 +76,12 @@ function tagsFromEntry(value: string): string[] {
|
|
|
66
76
|
return value.split(/[ ,#]+/).map((tag) => tag.trim()).filter(Boolean).slice(0, 8);
|
|
67
77
|
}
|
|
68
78
|
|
|
79
|
+
function sourceSkillDir(promotesTo: string | undefined): string | undefined {
|
|
80
|
+
if (!promotesTo || !promotesTo.replace(/\\/g, "/").endsWith("/SKILL.md")) return undefined;
|
|
81
|
+
const dir = dirname(promotesTo);
|
|
82
|
+
return existsSync(join(dir, "SKILL.md")) ? dir : undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
69
85
|
function suggestedScope(value: string | undefined): "agent" | "workspace" | "project" | "team" | "global" | "agent_type" {
|
|
70
86
|
if (value === "workspace" || value === "project" || value === "team" || value === "global") return value;
|
|
71
87
|
return "workspace";
|
|
@@ -61,7 +61,7 @@ export function enableMemorySkill(input: string, options: { force?: boolean; env
|
|
|
61
61
|
if (existsSync(targetPath) && !options.force) throw new Error(`enabled skill '${source.name}' already exists; pass force to replace it`);
|
|
62
62
|
if (existsSync(targetDir)) rmSync(targetDir, { recursive: true, force: true });
|
|
63
63
|
mkdirSync(targetDir, { recursive: true });
|
|
64
|
-
|
|
64
|
+
copySkillDirectory(dirname(source.path), targetDir);
|
|
65
65
|
const manifest = {
|
|
66
66
|
name: source.name,
|
|
67
67
|
description: source.description,
|
|
@@ -166,6 +166,22 @@ function readSkillItem(skillPath: string, kind: SkillLifecycleKind, id = basenam
|
|
|
166
166
|
};
|
|
167
167
|
}
|
|
168
168
|
|
|
169
|
+
function copySkillDirectory(sourceDir: string, targetDir: string): void {
|
|
170
|
+
for (const entry of readdirSync(sourceDir, { withFileTypes: true })) {
|
|
171
|
+
if (entry.isSymbolicLink() || entry.name === ENABLED_MANIFEST) continue;
|
|
172
|
+
const sourcePath = join(sourceDir, entry.name);
|
|
173
|
+
const targetPath = join(targetDir, entry.name);
|
|
174
|
+
if (entry.isDirectory()) {
|
|
175
|
+
mkdirSync(targetPath, { recursive: true });
|
|
176
|
+
copySkillDirectory(sourcePath, targetPath);
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
if (!entry.isFile()) continue;
|
|
180
|
+
mkdirSync(dirname(targetPath), { recursive: true });
|
|
181
|
+
writeFileSync(targetPath, readFileSync(sourcePath));
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
169
185
|
function parseSkillFrontmatter(content: string): SkillFrontmatter | null {
|
|
170
186
|
if (!content.startsWith("---\n")) return null;
|
|
171
187
|
const end = content.indexOf("\n---", 4);
|
|
@@ -3,6 +3,7 @@ import { dirname, join } from "node:path";
|
|
|
3
3
|
import { resolveAgentRoots, type PiAgentEnv } from "../paths/resolve-roots.ts";
|
|
4
4
|
import { detectSensitivity } from "./sensitivity.ts";
|
|
5
5
|
import type { Delivery } from "./schemas.ts";
|
|
6
|
+
import { validateSkillBundleFiles, writeSkillBundle } from "./skill-bundle.ts";
|
|
6
7
|
|
|
7
8
|
export type ReceiveDeliveryResult = {
|
|
8
9
|
written: string[];
|
|
@@ -29,10 +30,10 @@ export function receiveDelivery(delivery: Delivery, env: PiAgentEnv = process.en
|
|
|
29
30
|
mkdirSync(inboxDir, { recursive: true });
|
|
30
31
|
mkdirSync(generatedDir, { recursive: true });
|
|
31
32
|
const skillContent = delivery.content.endsWith("\n") ? delivery.content : `${delivery.content}\n`;
|
|
32
|
-
|
|
33
|
-
|
|
33
|
+
const files = validateSkillBundleFiles(delivery.files || []);
|
|
34
|
+
written.push(...writeSkillBundle(inboxDir, skillContent, files));
|
|
35
|
+
written.push(...writeSkillBundle(generatedDir, skillContent, files));
|
|
34
36
|
writeJsonIfChanged(join(inboxDir, "delivery.json"), delivery);
|
|
35
|
-
written.push(join(inboxDir, "SKILL.md"), join(generatedDir, "SKILL.md"));
|
|
36
37
|
return { written, accepted: true };
|
|
37
38
|
}
|
|
38
39
|
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
2
|
-
import { join } from "node:path";
|
|
1
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join, relative } from "node:path";
|
|
3
3
|
import { createHash } from "node:crypto";
|
|
4
4
|
import { resolveAgentRoots, type PiAgentEnv } from "../paths/resolve-roots.ts";
|
|
5
5
|
import { detectSensitivity, redactLocalPaths } from "./sensitivity.ts";
|
|
6
6
|
import type { EvolutionCandidate } from "./schemas.ts";
|
|
7
|
+
import { hashSkillBundle, loadSkillBundle, writeSkillBundle } from "./skill-bundle.ts";
|
|
7
8
|
|
|
8
9
|
export function appendEvolutionCandidate(input: Omit<EvolutionCandidate, "workspace_id" | "agent_id" | "local_unit_id" | "signature" | "created_at"> & Partial<Pick<EvolutionCandidate, "workspace_id" | "agent_id" | "local_unit_id" | "signature" | "created_at">>, env: PiAgentEnv = process.env): { path: string; candidate: EvolutionCandidate; appended: boolean } {
|
|
9
10
|
const roots = resolveAgentRoots(env);
|
|
@@ -11,30 +12,56 @@ export function appendEvolutionCandidate(input: Omit<EvolutionCandidate, "worksp
|
|
|
11
12
|
const workspaceId = input.workspace_id || roots.workspaceId;
|
|
12
13
|
const agentId = input.agent_id || roots.agentId;
|
|
13
14
|
if (!workspaceId || !agentId) throw new Error("candidate requires workspace_id and agent_id");
|
|
14
|
-
|
|
15
|
+
|
|
16
|
+
const skillBundle = input.type === "skill" && input.source_path ? loadSkillBundle(input.source_path) : null;
|
|
17
|
+
const rawContent = skillBundle?.content ?? input.content;
|
|
18
|
+
const allSkillContent = skillBundle ? [skillBundle.content, ...skillBundle.files.map((file) => file.content)].join("\n") : rawContent;
|
|
19
|
+
const sensitivity = input.sensitivity || detectSensitivity(allSkillContent);
|
|
15
20
|
if (sensitivity === "secret") throw new Error("secret-like content cannot enter sync_queue");
|
|
16
|
-
const content = sensitivity === "local_path" ? redactLocalPaths(
|
|
17
|
-
const
|
|
21
|
+
const content = sensitivity === "local_path" ? redactLocalPaths(rawContent) : rawContent;
|
|
22
|
+
const files = sensitivity === "local_path"
|
|
23
|
+
? skillBundle?.files.map((file) => ({ ...file, content: redactLocalPaths(file.content) }))
|
|
24
|
+
: skillBundle?.files;
|
|
25
|
+
const signature = input.signature || stableHash([input.type, content, files?.map((file) => `${file.path}\0${file.content}`).join("\0") || "", input.tags.join(",")].join("\n"));
|
|
26
|
+
const localUnitId = input.local_unit_id || `${input.type}_${signature.slice(0, 12)}`;
|
|
18
27
|
const candidate: EvolutionCandidate = {
|
|
19
28
|
...input,
|
|
20
29
|
workspace_id: workspaceId,
|
|
21
30
|
agent_id: agentId,
|
|
22
|
-
local_unit_id:
|
|
31
|
+
local_unit_id: localUnitId,
|
|
23
32
|
signature,
|
|
24
33
|
content,
|
|
25
34
|
sensitivity,
|
|
26
35
|
created_at: input.created_at || new Date().toISOString(),
|
|
27
36
|
};
|
|
37
|
+
if (skillBundle) {
|
|
38
|
+
const bundleDir = join(roots.syncQueueDir, "skill-candidates", localUnitId);
|
|
39
|
+
const written = writeSkillBundle(bundleDir, content, files || []);
|
|
40
|
+
candidate.name = skillBundle.name;
|
|
41
|
+
candidate.description = skillBundle.description;
|
|
42
|
+
candidate.provider = skillBundle.provider;
|
|
43
|
+
candidate.content_hash = hashSkillBundle(content, files || []);
|
|
44
|
+
candidate.files = files || [];
|
|
45
|
+
candidate.bundle_path = relative(roots.syncQueueDir, bundleDir).replace(/\\/g, "/");
|
|
46
|
+
candidate.source_path = candidate.bundle_path;
|
|
47
|
+
writeCandidateManifest(join(bundleDir, "candidate.json"), candidate);
|
|
48
|
+
if (written.length === 0) throw new Error("skill bundle was not written");
|
|
49
|
+
}
|
|
28
50
|
const filePath = join(roots.syncQueueDir, input.type === "skill" ? "skill-candidates.jsonl" : "memory-candidates.jsonl");
|
|
29
51
|
mkdirSync(roots.syncQueueDir, { recursive: true });
|
|
30
52
|
if (existsSync(filePath)) {
|
|
31
|
-
const exists = readFileSync(filePath, "utf-8").split("\n").some((line) => line.includes(`\"local_unit_id\":\"${candidate.local_unit_id}\"`));
|
|
53
|
+
const exists = readFileSync(filePath, "utf-8").split("\n").some((line: string) => line.includes(`\"local_unit_id\":\"${candidate.local_unit_id}\"`));
|
|
32
54
|
if (exists) return { path: filePath, candidate, appended: false };
|
|
33
55
|
}
|
|
34
56
|
appendFileSync(filePath, `${JSON.stringify(candidate)}\n`, "utf-8");
|
|
35
57
|
return { path: filePath, candidate, appended: true };
|
|
36
58
|
}
|
|
37
59
|
|
|
60
|
+
function writeCandidateManifest(filePath: string, candidate: EvolutionCandidate): void {
|
|
61
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
62
|
+
writeFileSync(filePath, `${JSON.stringify(candidate, null, 2)}\n`, "utf-8");
|
|
63
|
+
}
|
|
64
|
+
|
|
38
65
|
function stableHash(value: string): string {
|
|
39
66
|
return createHash("sha256").update(value).digest("hex");
|
|
40
67
|
}
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
export type EvolutionUnitType = "memory" | "skill" | "workflow" | "tool_pattern" | "preference";
|
|
2
2
|
export type FeedbackEventType = "injected" | "used" | "ignored" | "success" | "failure" | "conflict";
|
|
3
3
|
|
|
4
|
+
export type SkillFileData = {
|
|
5
|
+
path: string;
|
|
6
|
+
content: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
4
9
|
export type EvolutionCandidate = {
|
|
5
10
|
type: EvolutionUnitType;
|
|
6
11
|
workspace_id: string;
|
|
@@ -15,6 +20,13 @@ export type EvolutionCandidate = {
|
|
|
15
20
|
sensitivity?: "none" | "local_path" | "personal" | "secret" | "unknown";
|
|
16
21
|
source_candidate_ids?: string[];
|
|
17
22
|
created_at: string;
|
|
23
|
+
name?: string;
|
|
24
|
+
description?: string;
|
|
25
|
+
source_path?: string;
|
|
26
|
+
provider?: "pi";
|
|
27
|
+
content_hash?: string;
|
|
28
|
+
files?: SkillFileData[];
|
|
29
|
+
bundle_path?: string;
|
|
18
30
|
};
|
|
19
31
|
|
|
20
32
|
export type FeedbackEvent = {
|
|
@@ -34,6 +46,12 @@ export type Delivery = {
|
|
|
34
46
|
shared_unit_id: string;
|
|
35
47
|
unit_type: "memory" | "skill";
|
|
36
48
|
content: string;
|
|
49
|
+
name?: string;
|
|
50
|
+
description?: string;
|
|
51
|
+
files?: SkillFileData[];
|
|
52
|
+
source_path?: string;
|
|
53
|
+
provider?: "pi";
|
|
54
|
+
content_hash?: string;
|
|
37
55
|
tags?: string[];
|
|
38
56
|
score?: number;
|
|
39
57
|
task_types?: string[];
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
4
|
+
|
|
5
|
+
export type SkillBundleFile = {
|
|
6
|
+
path: string;
|
|
7
|
+
content: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type SkillBundle = {
|
|
11
|
+
name: string;
|
|
12
|
+
description: string;
|
|
13
|
+
content: string;
|
|
14
|
+
source_path: string;
|
|
15
|
+
provider: "pi";
|
|
16
|
+
content_hash: string;
|
|
17
|
+
files: SkillBundleFile[];
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const MAX_SKILL_FILE_SIZE = 1 << 20;
|
|
21
|
+
const MAX_SKILL_BUNDLE_SIZE = 8 << 20;
|
|
22
|
+
const MAX_SKILL_FILE_COUNT = 128;
|
|
23
|
+
|
|
24
|
+
export function loadSkillBundle(skillDir: string): SkillBundle {
|
|
25
|
+
const resolvedDir = resolve(skillDir);
|
|
26
|
+
const skillPath = join(resolvedDir, "SKILL.md");
|
|
27
|
+
if (!existsSync(skillPath)) throw new Error(`skill bundle requires SKILL.md: ${skillPath}`);
|
|
28
|
+
const content = readBoundedFile(skillPath);
|
|
29
|
+
const frontmatter = parseSkillFrontmatter(content);
|
|
30
|
+
if (!frontmatter.name) throw new Error(`skill SKILL.md must include frontmatter name: ${skillPath}`);
|
|
31
|
+
const files = collectSkillSupportingFiles(resolvedDir);
|
|
32
|
+
return {
|
|
33
|
+
name: frontmatter.name,
|
|
34
|
+
description: frontmatter.description || "",
|
|
35
|
+
content,
|
|
36
|
+
source_path: resolvedDir,
|
|
37
|
+
provider: "pi",
|
|
38
|
+
content_hash: hashSkillBundle(content, files),
|
|
39
|
+
files,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function writeSkillBundle(skillDir: string, content: string, files: SkillBundleFile[] = []): string[] {
|
|
44
|
+
const resolvedDir = resolve(skillDir);
|
|
45
|
+
const written: string[] = [];
|
|
46
|
+
const mainPath = join(resolvedDir, "SKILL.md");
|
|
47
|
+
writeTextIfChanged(mainPath, content.endsWith("\n") ? content : `${content}\n`);
|
|
48
|
+
written.push(mainPath);
|
|
49
|
+
for (const file of validateSkillBundleFiles(files)) {
|
|
50
|
+
const target = join(resolvedDir, file.path);
|
|
51
|
+
writeTextIfChanged(target, file.content);
|
|
52
|
+
written.push(target);
|
|
53
|
+
}
|
|
54
|
+
return written;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function validateSkillBundleFiles(files: SkillBundleFile[] = []): SkillBundleFile[] {
|
|
58
|
+
const valid: SkillBundleFile[] = [];
|
|
59
|
+
for (const file of files) {
|
|
60
|
+
const clean = normalizeSkillFilePath(file.path);
|
|
61
|
+
if (!clean) continue;
|
|
62
|
+
valid.push({ path: clean, content: String(file.content ?? "") });
|
|
63
|
+
}
|
|
64
|
+
return valid.sort((a, b) => a.path.localeCompare(b.path));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function hashSkillBundle(content: string, files: SkillBundleFile[]): string {
|
|
68
|
+
const h = createHash("sha256");
|
|
69
|
+
h.update(content);
|
|
70
|
+
for (const file of validateSkillBundleFiles(files)) {
|
|
71
|
+
h.update(`\0${file.path}\0${file.content}`);
|
|
72
|
+
}
|
|
73
|
+
return `sha256:${h.digest("hex")}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function collectSkillSupportingFiles(skillDir: string): SkillBundleFile[] {
|
|
77
|
+
const files: SkillBundleFile[] = [];
|
|
78
|
+
let totalSize = 0;
|
|
79
|
+
function walk(dir: string): void {
|
|
80
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
81
|
+
if (entry.isSymbolicLink()) continue;
|
|
82
|
+
if (isIgnoredSkillEntry(entry.name)) continue;
|
|
83
|
+
const fullPath = join(dir, entry.name);
|
|
84
|
+
if (entry.isDirectory()) {
|
|
85
|
+
walk(fullPath);
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (!entry.isFile()) continue;
|
|
89
|
+
const rel = normalizeSkillFilePath(relative(skillDir, fullPath));
|
|
90
|
+
if (!rel) continue;
|
|
91
|
+
const info = statSync(fullPath);
|
|
92
|
+
if (info.size > MAX_SKILL_FILE_SIZE) continue;
|
|
93
|
+
if (files.length >= MAX_SKILL_FILE_COUNT) throw new Error(`local skill exceeds ${MAX_SKILL_FILE_COUNT} files`);
|
|
94
|
+
totalSize += info.size;
|
|
95
|
+
if (totalSize > MAX_SKILL_BUNDLE_SIZE) throw new Error(`local skill exceeds ${MAX_SKILL_BUNDLE_SIZE} bytes in total`);
|
|
96
|
+
files.push({ path: rel, content: readFileSync(fullPath, "utf-8") });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
walk(skillDir);
|
|
100
|
+
return files.sort((a, b) => a.path.localeCompare(b.path));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function readBoundedFile(filePath: string): string {
|
|
104
|
+
const info = statSync(filePath);
|
|
105
|
+
if (info.size > MAX_SKILL_FILE_SIZE) throw new Error(`SKILL.md exceeds ${MAX_SKILL_FILE_SIZE} bytes`);
|
|
106
|
+
return readFileSync(filePath, "utf-8");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function normalizeSkillFilePath(path: string): string | null {
|
|
110
|
+
const normalized = path.replace(/\\/g, "/").split("/").filter(Boolean).join("/");
|
|
111
|
+
if (!normalized || normalized === "." || normalized.startsWith("../") || normalized.includes("/../")) return null;
|
|
112
|
+
if (normalized.startsWith("/") || normalized.startsWith("~")) return null;
|
|
113
|
+
if (normalized.toLowerCase() === "skill.md") return null;
|
|
114
|
+
return normalized;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function isIgnoredSkillEntry(name: string): boolean {
|
|
118
|
+
if (!name || name.startsWith(".")) return true;
|
|
119
|
+
switch (name.toLowerCase()) {
|
|
120
|
+
case "skill.md":
|
|
121
|
+
case "license":
|
|
122
|
+
case "license.md":
|
|
123
|
+
case "license.txt":
|
|
124
|
+
return true;
|
|
125
|
+
default:
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function parseSkillFrontmatter(content: string): { name: string; description: string } {
|
|
131
|
+
if (!content.startsWith("---\n")) return { name: "", description: "" };
|
|
132
|
+
const end = content.indexOf("\n---", 4);
|
|
133
|
+
if (end < 0) return { name: "", description: "" };
|
|
134
|
+
const result: { name: string; description: string } = { name: "", description: "" };
|
|
135
|
+
for (const line of content.slice(4, end).split("\n")) {
|
|
136
|
+
const index = line.indexOf(":");
|
|
137
|
+
if (index < 0) continue;
|
|
138
|
+
const key = line.slice(0, index).trim();
|
|
139
|
+
const value = line.slice(index + 1).trim().replace(/^["']|["']$/g, "");
|
|
140
|
+
if (key === "name") result.name = value;
|
|
141
|
+
if (key === "description") result.description = value;
|
|
142
|
+
}
|
|
143
|
+
return result;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function writeTextIfChanged(filePath: string, value: string): void {
|
|
147
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
148
|
+
if (existsSync(filePath)) return;
|
|
149
|
+
writeFileSync(filePath, value, "utf-8");
|
|
150
|
+
}
|
|
@@ -34,6 +34,8 @@ test("enables and disables a draft skill without deleting the draft", () => {
|
|
|
34
34
|
const { agentRoot, env } = agentEnv();
|
|
35
35
|
const draftDir = join(agentRoot, "skills", "drafts", "draft-one");
|
|
36
36
|
writeSkill(join(draftDir, "SKILL.md"), "draft-one");
|
|
37
|
+
mkdirSync(join(draftDir, "templates"), { recursive: true });
|
|
38
|
+
writeFileSync(join(draftDir, "templates", "prompt.md"), "supporting file\n", "utf-8");
|
|
37
39
|
|
|
38
40
|
let skills = listMemorySkills(env);
|
|
39
41
|
assert.equal(skills.drafts.length, 1);
|
|
@@ -42,6 +44,7 @@ test("enables and disables a draft skill without deleting the draft", () => {
|
|
|
42
44
|
const enabled = enableMemorySkill("draft:draft-one", { env });
|
|
43
45
|
assert.equal(enabled.enabled.name, "draft-one");
|
|
44
46
|
assert.equal(existsSync(join(agentRoot, "skills", "enabled", "draft-one", "SKILL.md")), true);
|
|
47
|
+
assert.equal(existsSync(join(agentRoot, "skills", "enabled", "draft-one", "templates", "prompt.md")), true);
|
|
45
48
|
assert.equal(existsSync(join(agentRoot, "skills", "drafts", "draft-one", "SKILL.md")), true);
|
|
46
49
|
assert.match(readFileSync(join(agentRoot, "memory", "audit", "skill-lifecycle.jsonl"), "utf-8"), /"action":"enable"/);
|
|
47
50
|
|
|
@@ -64,11 +67,13 @@ test("enables a generated skill delivery by generated id", () => {
|
|
|
64
67
|
shared_unit_id: "unit_skill_1",
|
|
65
68
|
unit_type: "skill",
|
|
66
69
|
content: "---\nname: shared-demo\ndescription: Use for tests.\n---\n# Shared Demo\n",
|
|
70
|
+
files: [{ path: "templates/prompt.md", content: "shared supporting file\n" }],
|
|
67
71
|
}, env);
|
|
68
72
|
|
|
69
73
|
const enabled = enableMemorySkill("generated:unit_skill_1", { env });
|
|
70
74
|
assert.equal(enabled.source.kind, "generated");
|
|
71
75
|
assert.equal(enabled.enabled.name, "shared-demo");
|
|
76
|
+
assert.equal(existsSync(join(agentRoot, "skills", "enabled", "shared-demo", "templates", "prompt.md")), true);
|
|
72
77
|
assert.equal(existsSync(join(agentRoot, "skills", "enabled", "shared-demo", ".pi-skill-enabled.json")), true);
|
|
73
78
|
const manifest = readFileSync(join(agentRoot, "skills", "enabled", "shared-demo", ".pi-skill-enabled.json"), "utf-8");
|
|
74
79
|
assert.match(manifest, /generated:unit_skill_1/);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
-
import { existsSync, mkdtempSync, readFileSync } from "node:fs";
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { test } from "node:test";
|
|
@@ -48,6 +48,33 @@ test("sync queue writes share candidates and blocks secret-like payloads", () =>
|
|
|
48
48
|
}, env), /secret-like content/);
|
|
49
49
|
});
|
|
50
50
|
|
|
51
|
+
test("skill candidates upload a runnable bundle with supporting files", () => {
|
|
52
|
+
const { agentRoot, env } = agentEnv();
|
|
53
|
+
const skillDir = join(agentRoot, "skills", "drafts", "bundle-demo");
|
|
54
|
+
mkdirSync(join(skillDir, "scripts"), { recursive: true });
|
|
55
|
+
writeFileSync(join(skillDir, "SKILL.md"), "---\nname: bundle-demo\ndescription: Use bundle demo.\n---\n# Bundle Demo\n", "utf-8");
|
|
56
|
+
writeFileSync(join(skillDir, "scripts", "run.sh"), "echo bundle\n", "utf-8");
|
|
57
|
+
|
|
58
|
+
const result = appendEvolutionCandidate({
|
|
59
|
+
type: "skill",
|
|
60
|
+
content: "fallback should be replaced by SKILL.md",
|
|
61
|
+
source_path: skillDir,
|
|
62
|
+
tags: ["coding"],
|
|
63
|
+
source: "local_curator",
|
|
64
|
+
suggested_scope: "agent_type",
|
|
65
|
+
status: "candidate",
|
|
66
|
+
}, env);
|
|
67
|
+
|
|
68
|
+
assert.equal(result.appended, true);
|
|
69
|
+
assert.equal(result.candidate.name, "bundle-demo");
|
|
70
|
+
assert.equal(result.candidate.files?.[0]?.path, "scripts/run.sh");
|
|
71
|
+
assert.match(result.candidate.content, /# Bundle Demo/);
|
|
72
|
+
assert.equal(existsSync(join(agentRoot, "sync_queue", "skill-candidates", result.candidate.local_unit_id, "SKILL.md")), true);
|
|
73
|
+
assert.equal(existsSync(join(agentRoot, "sync_queue", "skill-candidates", result.candidate.local_unit_id, "scripts", "run.sh")), true);
|
|
74
|
+
const queue = readFileSync(join(agentRoot, "sync_queue", "skill-candidates.jsonl"), "utf-8");
|
|
75
|
+
assert.match(queue, /"files":\[\{"path":"scripts\/run.sh"/);
|
|
76
|
+
});
|
|
77
|
+
|
|
51
78
|
test("downflow receive writes only inbox/cache/generated locations", () => {
|
|
52
79
|
const { agentRoot, env } = agentEnv();
|
|
53
80
|
const memoryResult = receiveDelivery({
|
|
@@ -67,10 +94,13 @@ test("downflow receive writes only inbox/cache/generated locations", () => {
|
|
|
67
94
|
shared_unit_id: "unit_skill_1",
|
|
68
95
|
unit_type: "skill",
|
|
69
96
|
content: "---\nname: shared-demo\ndescription: Use for tests.\n---\n# Shared Demo\n",
|
|
97
|
+
files: [{ path: "scripts/run.sh", content: "echo shared\n" }],
|
|
70
98
|
}, env);
|
|
71
99
|
assert.equal(skillResult.accepted, true);
|
|
72
100
|
assert.equal(existsSync(join(agentRoot, "inbox", "skills", "unit_skill_1", "SKILL.md")), true);
|
|
101
|
+
assert.equal(existsSync(join(agentRoot, "inbox", "skills", "unit_skill_1", "scripts", "run.sh")), true);
|
|
73
102
|
assert.equal(existsSync(join(agentRoot, "skills", "generated", "unit_skill_1", "SKILL.md")), true);
|
|
103
|
+
assert.equal(existsSync(join(agentRoot, "skills", "generated", "unit_skill_1", "scripts", "run.sh")), true);
|
|
74
104
|
assert.equal(existsSync(join(agentRoot, "skills", "enabled", "unit_skill_1", "SKILL.md")), false);
|
|
75
105
|
});
|
|
76
106
|
|