@lebronj/pi-suite 0.1.19 → 0.1.21
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 +20 -4
- package/vendor/pi-memory/README.md +16 -5
- package/vendor/pi-memory/index.ts +260 -40
- package/vendor/pi-memory/package.json +1 -1
- package/vendor/pi-memory/src/index.ts +1 -0
- package/vendor/pi-memory/src/learning/skills.ts +33 -6
- package/vendor/pi-memory/test/skill-drafts.test.ts +26 -3
- package/vendor/pi-memory/test/transition-handoff.test.ts +57 -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.21.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
|
@@ -88,10 +88,11 @@ Curator and learning behavior:
|
|
|
88
88
|
- Expired temporary memories go to `REVIEW.md`, not automatic deletion.
|
|
89
89
|
- Quotas reset when `month` or `reset` rolls over.
|
|
90
90
|
- Mutations are audited to `audit/curator.jsonl`.
|
|
91
|
-
- 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.
|
|
92
92
|
- `memory_curate` scans yesterday's daily log once per content hash into review candidates, then curator lifecycle and proposal rules process those candidates.
|
|
93
|
-
- Repeated candidates can become proposed memory promotions or proposed disabled skill drafts after `memory_curate`.
|
|
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.
|
|
95
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.
|
|
96
97
|
- Pi session start can show one pending-review hint; disable with `PI_MEMORY_REVIEW_STARTUP_HINT=0`.
|
|
97
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.
|
|
@@ -143,12 +144,16 @@ Useful memory environment variables:
|
|
|
143
144
|
- `PI_MEMORY_NO_SEARCH=1`: disable per-turn search injection.
|
|
144
145
|
- `PI_MEMORY_SUMMARIZE_TRANSITIONS=1`: also summarize lifecycle transitions such as `/reload`.
|
|
145
146
|
- `PI_MEMORY_LEARNING`: `off`, `review`, or `auto-review`.
|
|
146
|
-
- `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`.
|
|
147
149
|
- `PI_MEMORY_AUTO_APPROVE_MEMORY=1`: automatically approve newly created memory proposals.
|
|
148
150
|
- `PI_MEMORY_AUTO_APPROVE_SKILL_DRAFTS=1`: automatically create newly proposed disabled skill drafts.
|
|
149
151
|
- `PI_MEMORY_CURATOR_STARTUP_HINT=0`: hide the disabled-curator startup hint.
|
|
150
152
|
- `PI_MEMORY_REVIEW_STARTUP_HINT=0`: hide pending review proposal startup hints.
|
|
151
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`.
|
|
152
157
|
- `PI_EVOLUTION_ENABLED=0`: disable snapshot + git versioning.
|
|
153
158
|
- `PI_EVOLUTION_DIR`: override evolution repo directory; default `~/.pi/agent/evolution`.
|
|
154
159
|
- `PI_EVOLUTION_REMOTE`: optional personal private Git remote; unset by default.
|
|
@@ -221,6 +226,16 @@ Goal mode is provided by `goal-mode.ts`.
|
|
|
221
226
|
- The agent must verify current files/checks before calling `goal({ op: "complete" })`.
|
|
222
227
|
- Useful commands: `/goal show`, `/goal pause`, `/goal resume`, `/goal drop`, `/goal budget <tokens|off>`, `/goal auto on`, `/goal auto off`.
|
|
223
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
|
+
|
|
224
239
|
## Pet Companion
|
|
225
240
|
|
|
226
241
|
The pet extension provides a small terminal companion and durable profile.
|
|
@@ -233,6 +248,7 @@ The pet extension provides a small terminal companion and durable profile.
|
|
|
233
248
|
|
|
234
249
|
## UI And Utility Extensions
|
|
235
250
|
|
|
251
|
+
- `update-plan.ts`: registers `update_plan`, `/plan-status`, and `/plan-clear` for visible per-session execution planning.
|
|
236
252
|
- `prompt-url-widget.ts`: detects PR/issue prompt templates, fetches GitHub metadata with `gh`, shows a widget, and names the session when possible.
|
|
237
253
|
- `snake.ts`: `/snake` opens a TUI snake game; `Esc` pauses/saves, `q` quits, arrows/WASD move.
|
|
238
254
|
- `tps.ts`: after each assistant run, shows tokens-per-second and token usage details.
|
|
@@ -161,7 +161,7 @@ The curator deliberately avoids semantic auto-delete or semantic auto-merge in t
|
|
|
161
161
|
|
|
162
162
|
## Review-First Learning
|
|
163
163
|
|
|
164
|
-
Session shutdown may extract conservative learning candidates from the recent conversation text. Candidates are written only to `REVIEW.md`; they are not injected as normal context, not written to long-term memory, and not enabled as skills.
|
|
164
|
+
Session shutdown may extract conservative learning candidates from the recent conversation text, and `/new` or `/fork` transitions run a lightweight structured-evidence pass. Candidates are written only to `REVIEW.md`; they are not injected as normal context, not written to long-term memory, and not enabled as skills.
|
|
165
165
|
|
|
166
166
|
Candidate examples:
|
|
167
167
|
|
|
@@ -182,7 +182,7 @@ When candidates repeat, pi-memory updates the existing candidate instead of appe
|
|
|
182
182
|
`memory_curate` can turn stable candidates into proposals:
|
|
183
183
|
|
|
184
184
|
- `kind:memory_promotion status:proposed` for durable preferences, project facts, or concise lessons.
|
|
185
|
-
- `kind:skill_promotion status:proposed` for repeated skill-worthy methods.
|
|
185
|
+
- `kind:skill_promotion status:proposed` for repeated or high-confidence skill-worthy methods.
|
|
186
186
|
|
|
187
187
|
Approval is explicit by default:
|
|
188
188
|
|
|
@@ -191,7 +191,7 @@ Approval is explicit by default:
|
|
|
191
191
|
- `/memory-review` lists pending proposals and supports `show <id>`, `approve <id>`, `reject <id>`, and `archive <id>` for the current resolved root.
|
|
192
192
|
- `memory_learning_approve` on a memory proposal writes `MEMORY.md`, `USER.md`, or `STATE.md` depending on the proposal target.
|
|
193
193
|
- `memory_learning_approve` on a skill proposal writes the current resolved skill draft root and marks the proposal approved.
|
|
194
|
-
- Skill drafts are disabled.
|
|
194
|
+
- Skill drafts are disabled. In the default `PI_MEMORY_SKILL_DRAFTS=auto-draft` mode, high-quality proposals are written to the draft root automatically, but they are not moved into enabled skill directories.
|
|
195
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`.
|
|
196
196
|
- `memory_skill_disable` removes only the enabled copy; the draft/generated source remains for later review.
|
|
197
197
|
- Enabled skills are injected as available-skill metadata so the agent can read the corresponding `SKILL.md` when the task matches.
|
|
@@ -199,7 +199,7 @@ Approval is explicit by default:
|
|
|
199
199
|
|
|
200
200
|
Old candidates are lifecycle-managed without deletion. Low-confidence candidates can become `archived`; others become `needs_review` first. `REVIEW.md` remains the evidence and audit trail, so approved items are marked rather than removed.
|
|
201
201
|
|
|
202
|
-
|
|
202
|
+
Learning extraction combines text-based model extraction with a lightweight structured tool-evidence pass. The structured pass looks for `failure -> edit/action -> validation success` patterns and emits high-confidence skill candidates. Curator patch audit remains in `audit/curator.jsonl`; learning approvals are tracked through `REVIEW.md` proposal metadata and status changes.
|
|
203
203
|
|
|
204
204
|
## Local Multi-Agent Self-Evolution
|
|
205
205
|
|
|
@@ -225,6 +225,10 @@ The package includes local primitives for the full local loop:
|
|
|
225
225
|
|
|
226
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.
|
|
227
227
|
|
|
228
|
+
Local CLI Pi agents can participate in the same Multica loop as hosted/wrapped agents. Bind the local run to a Multica identity by setting `MULTICA_WORKSPACE_ID`, `MULTICA_AGENT_ID`, `PI_MEMORY_REMOTE_URL`, and `PI_MEMORY_REMOTE_TOKEN` before launching pi. Optionally set `PI_AGENT_ROOT` to choose the exact local agent root; otherwise the package derives `~/multica_workspaces/<workspace_id>/.pi/agents/<agent_id>/`. With that identity, local Pi can manually call `memory_sync_pull` / `memory_sync_upload`, or enable the env-gated automatic fallback below.
|
|
229
|
+
|
|
230
|
+
Automatic sync is off by default. Set `PI_MEMORY_AUTO_SYNC_PULL_ON_START=1` (or `PI_MEMORY_AUTO_SYNC_PULL=1`) to pull current-agent deliveries during `session_start`. Set `PI_MEMORY_AUTO_SYNC_UPLOAD_ON_SHUTDOWN=1` (or `PI_MEMORY_AUTO_SYNC_UPLOAD=1`) to upload candidates, profiles, and feedback during `session_shutdown`. `PI_MEMORY_AUTO_SYNC=1` enables both. These best-effort hooks never block startup/shutdown on sync errors.
|
|
231
|
+
|
|
228
232
|
### Local Curator Manager Service
|
|
229
233
|
|
|
230
234
|
The manager service is separate from the standalone daily memory curator. It runs `jhp-pi-memory-curator manager-scan` against the registry and only processes roots marked `dirty`; when there are no dirty roots it exits after a cheap registry check.
|
|
@@ -284,9 +288,16 @@ The controller uses a systemd user timer when available and falls back to cron.
|
|
|
284
288
|
| `PI_MEMORY_SUMMARIZE_TRANSITIONS` | `1`, `true`, `yes`, `on` | unset | Also summarize lifecycle transitions |
|
|
285
289
|
| `PI_MEMORY_LEARNING` | `off`, `review`, `auto-review` | `review` | Control session learning candidate extraction |
|
|
286
290
|
| `PI_MEMORY_LEARNING_MIN_CONFIDENCE` | `low`, `medium`, `high` | `medium` | Minimum extractor confidence to keep |
|
|
287
|
-
| `PI_MEMORY_SKILL_DRAFTS` | `off`, `review` | `
|
|
291
|
+
| `PI_MEMORY_SKILL_DRAFTS` | `off`, `propose`/`review`, `auto-draft` | `auto-draft` | Control disabled skill draft creation; `propose` only writes proposals, `auto-draft` writes disabled drafts |
|
|
292
|
+
| `PI_MEMORY_SKILL_SEEN_THRESHOLD` | positive integer | `2` | Repeated medium-confidence skill candidate threshold |
|
|
288
293
|
| `PI_MEMORY_AUTO_APPROVE_MEMORY` | `1`, `true`, `yes`, `on` | unset | YOLO mode for approving newly created memory proposals |
|
|
289
294
|
| `PI_MEMORY_AUTO_APPROVE_SKILL_DRAFTS` | `1`, `true`, `yes`, `on` | unset | YOLO mode for creating newly proposed disabled skill drafts |
|
|
295
|
+
| `PI_MEMORY_REMOTE_URL` | URL | unset | Multica evolution sync endpoint for upload/pull |
|
|
296
|
+
| `PI_MEMORY_REMOTE_TOKEN` | token | unset | Bearer token for Multica evolution sync |
|
|
297
|
+
| `PI_MEMORY_AUTO_SYNC` | `1`, `true`, `yes`, `on` | unset | Enable best-effort pull on start and upload on shutdown |
|
|
298
|
+
| `PI_MEMORY_AUTO_SYNC_PULL` / `PI_MEMORY_AUTO_SYNC_PULL_ON_START` | `1`, `true`, `yes`, `on` | unset | Best-effort `syncPull()` during session start |
|
|
299
|
+
| `PI_MEMORY_AUTO_SYNC_UPLOAD` / `PI_MEMORY_AUTO_SYNC_UPLOAD_ON_SHUTDOWN` | `1`, `true`, `yes`, `on` | unset | Best-effort `syncUpload()` during session shutdown |
|
|
300
|
+
| `PI_MEMORY_AUTO_SYNC_PULL_LIMIT` | positive integer | `20` | Maximum deliveries for automatic pull |
|
|
290
301
|
|
|
291
302
|
## Development
|
|
292
303
|
|
|
@@ -45,6 +45,7 @@ import {
|
|
|
45
45
|
REVIEW_CANDIDATE_KINDS,
|
|
46
46
|
REVIEW_CONFIDENCES,
|
|
47
47
|
REVIEW_TARGET_HINTS,
|
|
48
|
+
parseReviewCandidate,
|
|
48
49
|
upsertReviewCandidate,
|
|
49
50
|
type ReviewCandidateInput,
|
|
50
51
|
} from "./src/learning/candidates.ts";
|
|
@@ -53,7 +54,7 @@ import { generateShareCandidatesFromReview } from "./src/governance/share-candid
|
|
|
53
54
|
import { compactProcessedReviewEntries } from "./src/learning/review-compact.ts";
|
|
54
55
|
import { countPendingReviewItems, formatPendingReviewList, formatPendingReviewSummary, listPendingReviewItems } from "./src/learning/review-summary.ts";
|
|
55
56
|
import { defaultRegistryPath, markCurrentRootDirty, scanDirtyRoots } from "./src/manager/local-curator-manager.ts";
|
|
56
|
-
import { approveSkillDraft, listSkillDraftProposals, proposeSkillDrafts } from "./src/learning/skills.ts";
|
|
57
|
+
import { approvePendingSkillDrafts, approveSkillDraft, listSkillDraftProposals, proposeSkillDrafts } from "./src/learning/skills.ts";
|
|
57
58
|
import { disableMemorySkill, enableMemorySkill, formatEnabledSkillsForPrompt, formatSkillList, listMemorySkills } from "./src/skills/lifecycle.ts";
|
|
58
59
|
import { generateProfiles } from "./src/profile/generator.ts";
|
|
59
60
|
import { syncPull, syncUpload } from "./src/sync/connector.ts";
|
|
@@ -585,8 +586,16 @@ export function getMemoryLearningMode(env: MemoryEnv = process.env): "off" | "re
|
|
|
585
586
|
return value === "off" || value === "auto-review" ? value : "review";
|
|
586
587
|
}
|
|
587
588
|
|
|
588
|
-
export function getMemorySkillDraftsMode(env: MemoryEnv = process.env): "off" | "
|
|
589
|
-
|
|
589
|
+
export function getMemorySkillDraftsMode(env: MemoryEnv = process.env): "off" | "propose" | "auto-draft" {
|
|
590
|
+
const value = (env.PI_MEMORY_SKILL_DRAFTS || "auto-draft").toLowerCase();
|
|
591
|
+
if (value === "off") return "off";
|
|
592
|
+
if (value === "propose" || value === "review") return "propose";
|
|
593
|
+
return "auto-draft";
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function getMemorySkillSeenThreshold(env: MemoryEnv = process.env): number {
|
|
597
|
+
const value = Number.parseInt(env.PI_MEMORY_SKILL_SEEN_THRESHOLD || "2", 10);
|
|
598
|
+
return Number.isFinite(value) && value > 0 ? value : 2;
|
|
590
599
|
}
|
|
591
600
|
|
|
592
601
|
function getMemoryLearningMinConfidence(env: MemoryEnv = process.env): "low" | "medium" | "high" {
|
|
@@ -602,6 +611,51 @@ function getMemoryAutoApproveSkillDrafts(env: MemoryEnv = process.env): boolean
|
|
|
602
611
|
return ["1", "true", "yes", "on"].includes((env.PI_MEMORY_AUTO_APPROVE_SKILL_DRAFTS || "").toLowerCase());
|
|
603
612
|
}
|
|
604
613
|
|
|
614
|
+
function envFlag(env: MemoryEnv, name: string): boolean | undefined {
|
|
615
|
+
const value = env[name]?.trim().toLowerCase();
|
|
616
|
+
if (!value) return undefined;
|
|
617
|
+
if (["1", "true", "yes", "on"].includes(value)) return true;
|
|
618
|
+
if (["0", "false", "no", "off"].includes(value)) return false;
|
|
619
|
+
return undefined;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
export function getMemoryAutoSyncPullOnStart(env: MemoryEnv = process.env): boolean {
|
|
623
|
+
return envFlag(env, "PI_MEMORY_AUTO_SYNC_PULL_ON_START")
|
|
624
|
+
?? envFlag(env, "PI_MEMORY_AUTO_SYNC_PULL")
|
|
625
|
+
?? envFlag(env, "PI_MEMORY_AUTO_SYNC")
|
|
626
|
+
?? false;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
export function getMemoryAutoSyncUploadOnShutdown(env: MemoryEnv = process.env): boolean {
|
|
630
|
+
return envFlag(env, "PI_MEMORY_AUTO_SYNC_UPLOAD_ON_SHUTDOWN")
|
|
631
|
+
?? envFlag(env, "PI_MEMORY_AUTO_SYNC_UPLOAD")
|
|
632
|
+
?? envFlag(env, "PI_MEMORY_AUTO_SYNC")
|
|
633
|
+
?? false;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
function getMemoryAutoSyncPullLimit(env: MemoryEnv = process.env): number {
|
|
637
|
+
const value = Number.parseInt(env.PI_MEMORY_AUTO_SYNC_PULL_LIMIT || "20", 10);
|
|
638
|
+
return Number.isFinite(value) && value > 0 ? value : 20;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
async function runAutoSyncPullBestEffort(): Promise<void> {
|
|
642
|
+
if (!getMemoryAutoSyncPullOnStart()) return;
|
|
643
|
+
try {
|
|
644
|
+
await syncPull(process.env, getMemoryAutoSyncPullLimit());
|
|
645
|
+
} catch {
|
|
646
|
+
// Automatic sync is a Multica/local-agent convenience; never block startup.
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
async function runAutoSyncUploadBestEffort(): Promise<void> {
|
|
651
|
+
if (!getMemoryAutoSyncUploadOnShutdown()) return;
|
|
652
|
+
try {
|
|
653
|
+
await syncUpload();
|
|
654
|
+
} catch {
|
|
655
|
+
// Automatic sync is a Multica/local-agent convenience; never block shutdown.
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
605
659
|
function confidenceRank(confidence: "low" | "medium" | "high"): number {
|
|
606
660
|
return confidence === "low" ? 0 : confidence === "medium" ? 1 : 2;
|
|
607
661
|
}
|
|
@@ -615,6 +669,7 @@ function buildLearningExtractorPrompt(conversationText: string, truncated: boole
|
|
|
615
669
|
"Extract zero or more review candidates from this session transcript.",
|
|
616
670
|
"Return JSON exactly shaped as: {\"candidates\":[{\"kind\":\"bug_fix|skill_candidate|preference|project_fact\",\"confidence\":\"low|medium|high\",\"signature\":\"short stable signature\",\"summary\":\"optional concise summary\",\"targetHints\":[\"memory\",\"skill\"],\"evidence\":\"optional compact evidence\"}]}",
|
|
617
671
|
"Only include verified bug fixes when a failure was followed by an edit/action and successful validation.",
|
|
672
|
+
"For skill candidates, prefer reusable methods with clear trigger signals, steps, validation signals, and stop/avoid conditions.",
|
|
618
673
|
"Drop one-off trivia, transient status, workflow artifacts, and loop artifacts.",
|
|
619
674
|
];
|
|
620
675
|
if (truncated) lines.push(`Transcript was truncated to the most recent ${conversationText.length} of ${totalChars} characters.`);
|
|
@@ -683,7 +738,7 @@ function serializeSessionConversation(branch: SessionEntry[]): { text: string; h
|
|
|
683
738
|
return { text: serializeConversation(convertToLlm(messages)), hasMessages: true };
|
|
684
739
|
}
|
|
685
740
|
|
|
686
|
-
export function parseLearningExtractorResponse(raw: string): ReviewCandidateInput[] {
|
|
741
|
+
export function parseLearningExtractorResponse(raw: string, source = "session_shutdown"): ReviewCandidateInput[] {
|
|
687
742
|
let parsed: unknown;
|
|
688
743
|
try {
|
|
689
744
|
parsed = JSON.parse(raw.trim());
|
|
@@ -713,40 +768,194 @@ export function parseLearningExtractorResponse(raw: string): ReviewCandidateInpu
|
|
|
713
768
|
summary: typeof record.summary === "string" ? record.summary.trim() : undefined,
|
|
714
769
|
targetHints,
|
|
715
770
|
evidence: typeof record.evidence === "string" ? record.evidence.trim() : undefined,
|
|
716
|
-
source
|
|
771
|
+
source,
|
|
717
772
|
});
|
|
718
773
|
}
|
|
719
774
|
return candidates;
|
|
720
775
|
}
|
|
721
776
|
|
|
722
|
-
|
|
777
|
+
|
|
778
|
+
type ToolCallInfo = {
|
|
779
|
+
name: string;
|
|
780
|
+
arguments?: Record<string, unknown>;
|
|
781
|
+
};
|
|
782
|
+
|
|
783
|
+
type StructuredFailure = {
|
|
784
|
+
summary: string;
|
|
785
|
+
signature: string;
|
|
786
|
+
toolName: string;
|
|
787
|
+
};
|
|
788
|
+
|
|
789
|
+
function countReviewCandidates(reviewText: string): { total: number; skill: number } {
|
|
790
|
+
const entries = reviewText.split(ENTRY_DELIMITER).map((entry) => entry.trim()).filter(Boolean);
|
|
791
|
+
let total = 0;
|
|
792
|
+
let skill = 0;
|
|
793
|
+
for (const entry of entries) {
|
|
794
|
+
const candidate = parseReviewCandidate(entry);
|
|
795
|
+
if (!candidate) continue;
|
|
796
|
+
total += 1;
|
|
797
|
+
if (candidate.kind === "skill_candidate" || candidate.targetHints.includes("skill")) skill += 1;
|
|
798
|
+
}
|
|
799
|
+
return { total, skill };
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
function collectToolCalls(branch: SessionEntry[]): Map<string, ToolCallInfo> {
|
|
803
|
+
const calls = new Map<string, ToolCallInfo>();
|
|
804
|
+
for (const entry of branch) {
|
|
805
|
+
if (entry.type !== "message") continue;
|
|
806
|
+
const message = entry.message as Message;
|
|
807
|
+
if (message.role !== "assistant") continue;
|
|
808
|
+
for (const part of message.content) {
|
|
809
|
+
const candidate = part as { type?: string; id?: string; name?: string; arguments?: Record<string, unknown> };
|
|
810
|
+
if (candidate.type !== "toolCall" || !candidate.id || !candidate.name) continue;
|
|
811
|
+
calls.set(candidate.id, { name: candidate.name, arguments: candidate.arguments });
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
return calls;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
function getToolArgumentText(args: Record<string, unknown> | undefined, key: string): string {
|
|
818
|
+
const value = args?.[key];
|
|
819
|
+
return typeof value === "string" ? value : "";
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
function summarizeToolText(text: string): string {
|
|
823
|
+
const line = text
|
|
824
|
+
.split("\n")
|
|
825
|
+
.map((candidate) => candidate.trim())
|
|
826
|
+
.find((candidate) => candidate && !candidate.startsWith("{"));
|
|
827
|
+
return previewMessageText(line || text);
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
function isFailureToolResult(message: Message, text: string): boolean {
|
|
831
|
+
if (message.role !== "toolResult") return false;
|
|
832
|
+
if (message.isError) return true;
|
|
833
|
+
return /\b(Command exited with code [1-9]|failed|failure|error|exception|traceback|diagnostics?:\s*(?!ok)|ERR_[A-Z0-9_]+)\b/i.test(text);
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
function isEditOrActionTool(toolName: string, args: Record<string, unknown> | undefined): boolean {
|
|
837
|
+
if (["edit", "write", "memory_write", "memory_edit", "lsp"].includes(toolName)) return true;
|
|
838
|
+
if (toolName !== "bash") return false;
|
|
839
|
+
const command = getToolArgumentText(args, "command");
|
|
840
|
+
return /\b(apply_patch|python3?|node|perl|sed\s+-i|mv\s+|cp\s+|npm\s+install|pnpm\s+|bun\s+|cat\s+>|tee\s+)\b/.test(command);
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
function isValidationToolSuccess(toolName: string, args: Record<string, unknown> | undefined, text: string): boolean {
|
|
844
|
+
if (toolName === "lsp") return /diagnostics.*ok|\bOK\b/i.test(text);
|
|
845
|
+
if (toolName !== "bash") return false;
|
|
846
|
+
const command = getToolArgumentText(args, "command");
|
|
847
|
+
return /\b(test|typecheck|lint|check|tsc|build|pytest|go\s+test|cargo\s+test|mvn\s+test|gradle\b|npm\s+run|pnpm\s+(test|run|--filter))\b/i.test(command);
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
function toolLabel(toolName: string, args: Record<string, unknown> | undefined): string {
|
|
851
|
+
if (toolName === "bash") return previewMessageText(getToolArgumentText(args, "command")) || "bash";
|
|
852
|
+
if (toolName === "lsp") return `lsp ${getToolArgumentText(args, "action")}`.trim();
|
|
853
|
+
return toolName;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
export function extractStructuredToolEvidenceCandidates(branch: SessionEntry[], source = "tool_evidence"): ReviewCandidateInput[] {
|
|
857
|
+
const calls = collectToolCalls(branch);
|
|
858
|
+
const candidates: ReviewCandidateInput[] = [];
|
|
859
|
+
const emitted = new Set<string>();
|
|
860
|
+
let failure: StructuredFailure | null = null;
|
|
861
|
+
let actionAfterFailure = false;
|
|
862
|
+
let actionLabel = "";
|
|
863
|
+
|
|
864
|
+
for (const entry of branch) {
|
|
865
|
+
if (entry.type !== "message") continue;
|
|
866
|
+
const message = entry.message as Message;
|
|
867
|
+
if (message.role !== "toolResult") continue;
|
|
868
|
+
const info = calls.get(message.toolCallId);
|
|
869
|
+
const toolName = message.toolName || info?.name || "tool";
|
|
870
|
+
const args = info?.arguments;
|
|
871
|
+
const text = getMessageText(message);
|
|
872
|
+
|
|
873
|
+
if (isFailureToolResult(message, text)) {
|
|
874
|
+
const summary = summarizeToolText(text);
|
|
875
|
+
failure = {
|
|
876
|
+
summary,
|
|
877
|
+
signature: `${toolName} ${summary}`.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim().slice(0, 96) || toolName,
|
|
878
|
+
toolName,
|
|
879
|
+
};
|
|
880
|
+
actionAfterFailure = false;
|
|
881
|
+
actionLabel = "";
|
|
882
|
+
continue;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
if (!failure) continue;
|
|
886
|
+
if (isEditOrActionTool(toolName, args)) {
|
|
887
|
+
actionAfterFailure = true;
|
|
888
|
+
actionLabel = toolLabel(toolName, args);
|
|
889
|
+
continue;
|
|
890
|
+
}
|
|
891
|
+
if (!actionAfterFailure || !isValidationToolSuccess(toolName, args, text)) continue;
|
|
892
|
+
|
|
893
|
+
const validation = toolLabel(toolName, args);
|
|
894
|
+
const signature = `fix ${failure.signature} validated by ${validation}`.slice(0, 120);
|
|
895
|
+
if (!emitted.has(signature)) {
|
|
896
|
+
emitted.add(signature);
|
|
897
|
+
candidates.push({
|
|
898
|
+
kind: "skill_candidate",
|
|
899
|
+
confidence: "high",
|
|
900
|
+
signature,
|
|
901
|
+
summary: `Use a failure -> edit/action -> validation loop: inspect ${failure.toolName} failure, apply ${actionLabel || "the smallest relevant fix"}, then rerun ${validation}.`,
|
|
902
|
+
targetHints: ["skill"],
|
|
903
|
+
evidence: `Failure: ${failure.summary}; action: ${actionLabel || "edit/action"}; validation: ${validation}.`,
|
|
904
|
+
source,
|
|
905
|
+
});
|
|
906
|
+
}
|
|
907
|
+
failure = null;
|
|
908
|
+
actionAfterFailure = false;
|
|
909
|
+
actionLabel = "";
|
|
910
|
+
if (candidates.length >= 3) break;
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
return candidates;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
async function writeLearningCandidates(candidates: ReviewCandidateInput[]): Promise<number> {
|
|
917
|
+
if (candidates.length === 0) return 0;
|
|
918
|
+
let written = 0;
|
|
919
|
+
const store = new FileMemoryStore(MEMORY_DIR);
|
|
920
|
+
for (const candidate of candidates) {
|
|
921
|
+
const result = await upsertReviewCandidate(store, candidate);
|
|
922
|
+
if (result.changed) written += 1;
|
|
923
|
+
}
|
|
924
|
+
return written;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
async function runSessionLearningExtractor(
|
|
928
|
+
ctx: ExtensionContext,
|
|
929
|
+
options: { source?: string; includeModel?: boolean; includeStructured?: boolean } = {},
|
|
930
|
+
): Promise<number> {
|
|
723
931
|
if (getMemoryLearningMode() === "off") return 0;
|
|
724
932
|
const branch = getSessionBranch(ctx);
|
|
725
|
-
if (!branch
|
|
726
|
-
const
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
933
|
+
if (!branch) return 0;
|
|
934
|
+
const source = options.source || "session_shutdown";
|
|
935
|
+
const candidates: ReviewCandidateInput[] = [];
|
|
936
|
+
if (options.includeStructured !== false) {
|
|
937
|
+
candidates.push(...extractStructuredToolEvidenceCandidates(branch, source));
|
|
938
|
+
}
|
|
939
|
+
if (options.includeModel !== false && ctx.model) {
|
|
940
|
+
const apiKey = await resolveExitSummaryApiKey(ctx);
|
|
941
|
+
const conversation = serializeSessionConversation(branch);
|
|
942
|
+
if (apiKey && conversation.hasMessages && conversation.text.trim()) {
|
|
943
|
+
const truncated = truncateText(conversation.text.trim(), LEARNING_EXTRACTOR_MAX_CHARS, "end");
|
|
944
|
+
const messages: Message[] = [{
|
|
945
|
+
role: "user",
|
|
946
|
+
content: [{ type: "text", text: buildLearningExtractorPrompt(truncated.text, truncated.truncated, conversation.text.trim().length) }],
|
|
947
|
+
timestamp: Date.now(),
|
|
948
|
+
}];
|
|
949
|
+
try {
|
|
950
|
+
const response = await complete(ctx.model, { systemPrompt: LEARNING_EXTRACTOR_SYSTEM_PROMPT, messages }, { apiKey, reasoningEffort: "low" });
|
|
951
|
+
const raw = response.content.filter((part): part is { type: "text"; text: string } => part.type === "text").map((part) => part.text).join("\n");
|
|
952
|
+
candidates.push(...parseLearningExtractorResponse(raw, source));
|
|
953
|
+
} catch {
|
|
954
|
+
// Learning is best-effort and must not block session transitions.
|
|
955
|
+
}
|
|
745
956
|
}
|
|
746
|
-
return written;
|
|
747
|
-
} catch {
|
|
748
|
-
return 0;
|
|
749
957
|
}
|
|
958
|
+
return writeLearningCandidates(candidates);
|
|
750
959
|
}
|
|
751
960
|
|
|
752
961
|
async function generateExitSummary(ctx: ExtensionContext): Promise<ExitSummaryResult> {
|
|
@@ -1560,7 +1769,8 @@ async function runCurator(reason: string): Promise<string> {
|
|
|
1560
1769
|
});
|
|
1561
1770
|
const lifecycleResult = await applyReviewLifecycle(store);
|
|
1562
1771
|
const memoryResult = await proposeMemoryPromotions(store);
|
|
1563
|
-
const
|
|
1772
|
+
const skillDraftMode = getMemorySkillDraftsMode();
|
|
1773
|
+
const skillResult = skillDraftMode === "off" ? { created: 0, proposals: [] } : await proposeSkillDrafts(store, { draftsDir: SKILL_DRAFTS_DIR, seenThreshold: getMemorySkillSeenThreshold() });
|
|
1564
1774
|
let autoApprovedMemory = 0;
|
|
1565
1775
|
let autoApprovedSkills = 0;
|
|
1566
1776
|
if (getMemoryAutoApproveMemory()) {
|
|
@@ -1569,11 +1779,8 @@ async function runCurator(reason: string): Promise<string> {
|
|
|
1569
1779
|
autoApprovedMemory += 1;
|
|
1570
1780
|
}
|
|
1571
1781
|
}
|
|
1572
|
-
if (getMemoryAutoApproveSkillDrafts()) {
|
|
1573
|
-
|
|
1574
|
-
await approveSkillDraft(store, proposal.id);
|
|
1575
|
-
autoApprovedSkills += 1;
|
|
1576
|
-
}
|
|
1782
|
+
if (skillDraftMode === "auto-draft" || getMemoryAutoApproveSkillDrafts()) {
|
|
1783
|
+
autoApprovedSkills = (await approvePendingSkillDrafts(store, skillResult.proposals.map((proposal) => proposal.id))).length;
|
|
1577
1784
|
}
|
|
1578
1785
|
const learningChanges = lifecycleResult.changed + memoryResult.created + skillResult.created + autoApprovedMemory + autoApprovedSkills;
|
|
1579
1786
|
if (result.patches.length > 0 || learningChanges > 0) {
|
|
@@ -1622,6 +1829,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1622
1829
|
pi.on("session_start", async (_event, ctx) => {
|
|
1623
1830
|
refreshResolvedDirsFromEnv();
|
|
1624
1831
|
ensureDirs();
|
|
1832
|
+
await runAutoSyncPullBestEffort();
|
|
1625
1833
|
exitSummaryReason = null;
|
|
1626
1834
|
if (terminalInputUnsubscribe) {
|
|
1627
1835
|
terminalInputUnsubscribe();
|
|
@@ -1636,9 +1844,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
1636
1844
|
return undefined;
|
|
1637
1845
|
});
|
|
1638
1846
|
if (process.env.PI_MEMORY_REVIEW_STARTUP_HINT !== "0") {
|
|
1639
|
-
const
|
|
1640
|
-
|
|
1641
|
-
|
|
1847
|
+
const reviewText = readFileSafe(REVIEW_FILE) ?? "";
|
|
1848
|
+
const pending = countPendingReviewItems(reviewText);
|
|
1849
|
+
const candidates = countReviewCandidates(reviewText);
|
|
1850
|
+
const skills = listMemorySkills(process.env);
|
|
1851
|
+
const hasWork = pending.total > 0 || candidates.total > 0 || skills.drafts.length > 0 || skills.generated.length > 0 || skills.enabled.length > 0;
|
|
1852
|
+
if (hasWork) {
|
|
1853
|
+
ctx.ui.notify(`Memory review: ${candidates.total} candidate(s) (${candidates.skill} skill), ${pending.memory} memory proposal(s), ${pending.skill} skill proposal(s), ${skills.drafts.length} draft(s), ${skills.generated.length} generated, ${skills.enabled.length} enabled. Run /memory-review or /memory-skill.`, "info");
|
|
1642
1854
|
}
|
|
1643
1855
|
}
|
|
1644
1856
|
}
|
|
@@ -1678,8 +1890,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
1678
1890
|
try {
|
|
1679
1891
|
if (shouldWriteTransitionHandoffForReason(shutdownReason)) {
|
|
1680
1892
|
await writeTransitionHandoff(ctx, shutdownReason);
|
|
1893
|
+
await runSessionLearningExtractor(ctx, { source: `transition_${shutdownReason}`, includeModel: false, includeStructured: true });
|
|
1894
|
+
if (getMemorySkillDraftsMode() === "auto-draft") await runCurator(`transition_${shutdownReason}`);
|
|
1681
1895
|
}
|
|
1682
1896
|
} finally {
|
|
1897
|
+
await runAutoSyncUploadBestEffort();
|
|
1683
1898
|
exitSummaryReason = null;
|
|
1684
1899
|
if (updateTimer) {
|
|
1685
1900
|
clearTimeout(updateTimer);
|
|
@@ -1706,15 +1921,20 @@ export default function (pi: ExtensionAPI) {
|
|
|
1706
1921
|
const separator = existing.trim() ? "\n\n" : "";
|
|
1707
1922
|
fs.writeFileSync(filePath, existing + separator + entry, "utf-8");
|
|
1708
1923
|
const newCandidates = await runSessionLearningExtractor(ctx);
|
|
1924
|
+
if (getMemorySkillDraftsMode() === "auto-draft") await runCurator("session_learning");
|
|
1709
1925
|
if (ctx.hasUI && process.env.PI_MEMORY_REVIEW_SESSION_SUMMARY !== "0") {
|
|
1710
|
-
const
|
|
1711
|
-
|
|
1926
|
+
const reviewText = readFileSafe(REVIEW_FILE) ?? "";
|
|
1927
|
+
const pending = countPendingReviewItems(reviewText);
|
|
1928
|
+
const candidates = countReviewCandidates(reviewText);
|
|
1929
|
+
const skills = listMemorySkills(process.env);
|
|
1930
|
+
ctx.ui.notify(`Memory learning today: ${newCandidates} new candidate(s), ${candidates.skill} skill candidate(s), ${pending.memory} memory proposal(s), ${pending.skill} skill proposal(s), ${skills.drafts.length} draft(s).`, "info");
|
|
1712
1931
|
}
|
|
1713
1932
|
await ensureQmdAvailableForUpdate();
|
|
1714
1933
|
await runQmdUpdateNow();
|
|
1715
1934
|
}
|
|
1716
1935
|
}
|
|
1717
1936
|
} finally {
|
|
1937
|
+
await runAutoSyncUploadBestEffort();
|
|
1718
1938
|
if (updateTimer) {
|
|
1719
1939
|
clearTimeout(updateTimer);
|
|
1720
1940
|
updateTimer = null;
|
|
@@ -44,6 +44,7 @@ export { appendFeedbackEvent, buildFeedbackEvent } from "./sync/feedback.ts";
|
|
|
44
44
|
export { appendEvolutionCandidate } from "./sync/queue.ts";
|
|
45
45
|
export type { Delivery, EvolutionCandidate, FeedbackEvent, FeedbackEventType, EvolutionUnitType } from "./sync/schemas.ts";
|
|
46
46
|
export {
|
|
47
|
+
approvePendingSkillDrafts,
|
|
47
48
|
approveSkillDraft,
|
|
48
49
|
listSkillDraftProposals,
|
|
49
50
|
proposeSkillDrafts,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdirSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
import { parseEntry, renderEntry } from "../curator-core/metadata.ts";
|
|
4
4
|
import type { MemoryStore } from "../curator-store/types.ts";
|
|
@@ -37,7 +37,7 @@ export async function proposeSkillDrafts(
|
|
|
37
37
|
): Promise<SkillProposalResult> {
|
|
38
38
|
const entries = await memoryStore.readEntries("review");
|
|
39
39
|
const candidates = entries.map(parseReviewCandidate).filter((candidate): candidate is ParsedReviewCandidate => candidate !== null);
|
|
40
|
-
const threshold = options.seenThreshold ??
|
|
40
|
+
const threshold = options.seenThreshold ?? 2;
|
|
41
41
|
const proposals: SkillProposal[] = [];
|
|
42
42
|
const existingProposalSources = new Set(
|
|
43
43
|
entries
|
|
@@ -68,7 +68,12 @@ export async function approveSkillDraft(memoryStore: MemoryStore, proposalId: st
|
|
|
68
68
|
if (!path) throw new Error(`Skill proposal '${proposalId}' has no promotes_to path.`);
|
|
69
69
|
const content = buildSkillDraftFromProposal(parsed.body);
|
|
70
70
|
mkdirSync(dirname(path), { recursive: true });
|
|
71
|
-
|
|
71
|
+
if (existsSync(path)) {
|
|
72
|
+
const existing = readFileSync(path, "utf-8");
|
|
73
|
+
if (existing !== content) throw new Error(`Skill draft already exists at '${path}'.`);
|
|
74
|
+
} else {
|
|
75
|
+
writeFileSync(path, content, { encoding: "utf-8", flag: "wx" });
|
|
76
|
+
}
|
|
72
77
|
const approved = renderEntry({
|
|
73
78
|
...parsed,
|
|
74
79
|
metadata: { ...parsed.metadata, status: "approved", approved_at: new Date().toISOString() },
|
|
@@ -80,6 +85,21 @@ export async function approveSkillDraft(memoryStore: MemoryStore, proposalId: st
|
|
|
80
85
|
return { proposalId, path, content };
|
|
81
86
|
}
|
|
82
87
|
|
|
88
|
+
export async function approvePendingSkillDrafts(memoryStore: MemoryStore, proposalIds: string[] = []): Promise<SkillApprovalResult[]> {
|
|
89
|
+
const ids = new Set(proposalIds);
|
|
90
|
+
for (const entry of await memoryStore.readEntries("review")) {
|
|
91
|
+
const metadata = parseEntry(entry).metadata;
|
|
92
|
+
if (metadata.type === "review" && metadata.kind === "skill_promotion" && metadata.status === "proposed" && metadata.id) {
|
|
93
|
+
ids.add(metadata.id);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const results: SkillApprovalResult[] = [];
|
|
97
|
+
for (const id of ids) {
|
|
98
|
+
results.push(await approveSkillDraft(memoryStore, id));
|
|
99
|
+
}
|
|
100
|
+
return results;
|
|
101
|
+
}
|
|
102
|
+
|
|
83
103
|
export async function listSkillDraftProposals(memoryStore: MemoryStore): Promise<SkillProposal[]> {
|
|
84
104
|
const entries = await memoryStore.readEntries("review");
|
|
85
105
|
return entries
|
|
@@ -126,14 +146,21 @@ function createSkillProposal(candidate: ParsedReviewCandidate, draftsDir: string
|
|
|
126
146
|
"## When to use",
|
|
127
147
|
description,
|
|
128
148
|
"",
|
|
149
|
+
"## Trigger signals",
|
|
150
|
+
"- The task matches the repeated source evidence or error/fix pattern.",
|
|
151
|
+
"- The user wants a reusable method rather than a one-off fact.",
|
|
152
|
+
"",
|
|
129
153
|
"## Method",
|
|
130
154
|
candidate.summary || candidate.signature,
|
|
131
155
|
"",
|
|
156
|
+
"## Validation",
|
|
157
|
+
"Use the validation signal from the source evidence. If none is available, run the narrowest relevant check and report the result.",
|
|
158
|
+
"",
|
|
159
|
+
"## Stop / avoid",
|
|
160
|
+
"Stop after validation passes, or report the remaining blocker. Avoid applying this skill when the evidence is project-specific or the trigger does not match.",
|
|
161
|
+
"",
|
|
132
162
|
"## Evidence",
|
|
133
163
|
evidence,
|
|
134
|
-
"",
|
|
135
|
-
"## Stop condition",
|
|
136
|
-
"Stop after the validation signal from the source evidence passes, or report the remaining blocker.",
|
|
137
164
|
"```",
|
|
138
165
|
].join("\n");
|
|
139
166
|
return {
|
|
@@ -3,13 +3,13 @@ import { existsSync, mkdtempSync, readFileSync } from "node:fs";
|
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { test } from "node:test";
|
|
6
|
-
import { FileMemoryStore, approveSkillDraft, listSkillDraftProposals, proposeSkillDrafts, upsertReviewCandidate } from "../src/index.ts";
|
|
6
|
+
import { FileMemoryStore, approvePendingSkillDrafts, approveSkillDraft, listSkillDraftProposals, proposeSkillDrafts, upsertReviewCandidate } from "../src/index.ts";
|
|
7
7
|
|
|
8
8
|
test("proposes skill draft from repeated skill candidate", async () => {
|
|
9
9
|
const dir = mkdtempSync(join(tmpdir(), "pi-memory-skills-"));
|
|
10
10
|
const store = new FileMemoryStore(join(dir, "memory"));
|
|
11
11
|
const draftsDir = join(dir, "skill-drafts");
|
|
12
|
-
for (const evidence of ["first pass", "second pass"
|
|
12
|
+
for (const evidence of ["first pass", "second pass"]) {
|
|
13
13
|
await upsertReviewCandidate(store, {
|
|
14
14
|
kind: "skill_candidate",
|
|
15
15
|
confidence: "medium",
|
|
@@ -49,9 +49,32 @@ test("approves skill proposal into disabled draft and marks proposal approved",
|
|
|
49
49
|
|
|
50
50
|
const approved = await approveSkillDraft(store, proposed.proposals[0].id);
|
|
51
51
|
assert.equal(existsSync(approved.path), true);
|
|
52
|
-
|
|
52
|
+
const draftContent = readFileSync(approved.path, "utf-8");
|
|
53
|
+
assert.match(draftContent, /description: Use when fix non erasable typescript syntax in pi source\./);
|
|
54
|
+
assert.match(draftContent, /## Trigger signals/);
|
|
55
|
+
assert.match(draftContent, /## Validation/);
|
|
53
56
|
const proposals = await listSkillDraftProposals(store);
|
|
54
57
|
assert.equal(proposals.length, 1);
|
|
55
58
|
const review = await store.readEntries("review");
|
|
56
59
|
assert.match(review.join("\n"), /status:approved/);
|
|
57
60
|
});
|
|
61
|
+
|
|
62
|
+
test("auto-approves pending skill proposals into disabled drafts", async () => {
|
|
63
|
+
const dir = mkdtempSync(join(tmpdir(), "pi-memory-skills-"));
|
|
64
|
+
const store = new FileMemoryStore(join(dir, "memory"));
|
|
65
|
+
const draftsDir = join(dir, "skill-drafts");
|
|
66
|
+
await upsertReviewCandidate(store, {
|
|
67
|
+
kind: "skill_candidate",
|
|
68
|
+
confidence: "high",
|
|
69
|
+
signature: "restore pi session jsonl",
|
|
70
|
+
summary: "Resume a Pi session from a saved JSONL with pi --session.",
|
|
71
|
+
targetHints: ["skill"],
|
|
72
|
+
evidence: "User needed a repeatable session restore command.",
|
|
73
|
+
});
|
|
74
|
+
await proposeSkillDrafts(store, { draftsDir });
|
|
75
|
+
|
|
76
|
+
const approved = await approvePendingSkillDrafts(store);
|
|
77
|
+
assert.equal(approved.length, 1);
|
|
78
|
+
assert.equal(existsSync(approved[0].path), true);
|
|
79
|
+
assert.match((await store.readEntries("review")).join("\n"), /status:approved/);
|
|
80
|
+
});
|
|
@@ -2,6 +2,9 @@ import assert from "node:assert/strict";
|
|
|
2
2
|
import { test } from "node:test";
|
|
3
3
|
import {
|
|
4
4
|
buildTransitionHandoff,
|
|
5
|
+
extractStructuredToolEvidenceCandidates,
|
|
6
|
+
getMemoryAutoSyncPullOnStart,
|
|
7
|
+
getMemoryAutoSyncUploadOnShutdown,
|
|
5
8
|
getMemoryLearningMode,
|
|
6
9
|
getMemorySkillDraftsMode,
|
|
7
10
|
parseLearningExtractorResponse,
|
|
@@ -20,6 +23,31 @@ function message(role: "user" | "assistant", text: string) {
|
|
|
20
23
|
};
|
|
21
24
|
}
|
|
22
25
|
|
|
26
|
+
function assistantToolCall(id: string, name: string, args: Record<string, unknown>) {
|
|
27
|
+
return {
|
|
28
|
+
type: "message" as const,
|
|
29
|
+
message: {
|
|
30
|
+
role: "assistant" as const,
|
|
31
|
+
content: [{ type: "toolCall" as const, id, name, arguments: args }],
|
|
32
|
+
timestamp: 0,
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function toolResult(id: string, name: string, text: string, isError = false) {
|
|
38
|
+
return {
|
|
39
|
+
type: "message" as const,
|
|
40
|
+
message: {
|
|
41
|
+
role: "toolResult" as const,
|
|
42
|
+
toolCallId: id,
|
|
43
|
+
toolName: name,
|
|
44
|
+
content: [{ type: "text" as const, text }],
|
|
45
|
+
isError,
|
|
46
|
+
timestamp: 0,
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
23
51
|
function ctx(branch: ReturnType<typeof message>[]) {
|
|
24
52
|
return {
|
|
25
53
|
sessionManager: {
|
|
@@ -44,10 +72,21 @@ test("memory learning defaults to review mode", () => {
|
|
|
44
72
|
assert.equal(getMemoryLearningMode({}), "review");
|
|
45
73
|
assert.equal(getMemoryLearningMode({ PI_MEMORY_LEARNING: "off" }), "off");
|
|
46
74
|
assert.equal(getMemoryLearningMode({ PI_MEMORY_LEARNING: "auto-review" }), "auto-review");
|
|
47
|
-
assert.equal(getMemorySkillDraftsMode({}), "
|
|
75
|
+
assert.equal(getMemorySkillDraftsMode({}), "auto-draft");
|
|
76
|
+
assert.equal(getMemorySkillDraftsMode({ PI_MEMORY_SKILL_DRAFTS: "review" }), "propose");
|
|
77
|
+
assert.equal(getMemorySkillDraftsMode({ PI_MEMORY_SKILL_DRAFTS: "propose" }), "propose");
|
|
48
78
|
assert.equal(getMemorySkillDraftsMode({ PI_MEMORY_SKILL_DRAFTS: "off" }), "off");
|
|
49
79
|
});
|
|
50
80
|
|
|
81
|
+
test("auto sync env flags are opt-in and support aliases", () => {
|
|
82
|
+
assert.equal(getMemoryAutoSyncPullOnStart({}), false);
|
|
83
|
+
assert.equal(getMemoryAutoSyncUploadOnShutdown({}), false);
|
|
84
|
+
assert.equal(getMemoryAutoSyncPullOnStart({ PI_MEMORY_AUTO_SYNC: "1" }), true);
|
|
85
|
+
assert.equal(getMemoryAutoSyncUploadOnShutdown({ PI_MEMORY_AUTO_SYNC: "1" }), true);
|
|
86
|
+
assert.equal(getMemoryAutoSyncPullOnStart({ PI_MEMORY_AUTO_SYNC: "1", PI_MEMORY_AUTO_SYNC_PULL_ON_START: "0" }), false);
|
|
87
|
+
assert.equal(getMemoryAutoSyncUploadOnShutdown({ PI_MEMORY_AUTO_SYNC_UPLOAD: "yes" }), true);
|
|
88
|
+
});
|
|
89
|
+
|
|
51
90
|
test("learning extractor response accepts only valid review candidates", () => {
|
|
52
91
|
const candidates = parseLearningExtractorResponse(JSON.stringify({
|
|
53
92
|
candidates: [
|
|
@@ -68,6 +107,23 @@ test("learning extractor response accepts only valid review candidates", () => {
|
|
|
68
107
|
}]);
|
|
69
108
|
});
|
|
70
109
|
|
|
110
|
+
test("structured tool evidence creates high-confidence skill candidate", () => {
|
|
111
|
+
const candidates = extractStructuredToolEvidenceCandidates([
|
|
112
|
+
assistantToolCall("fail", "bash", { command: "npm run check" }),
|
|
113
|
+
toolResult("fail", "bash", "Command exited with code 1\nError: shrinkwrap is out of date", false),
|
|
114
|
+
assistantToolCall("edit", "edit", { path: "package-lock.json" }),
|
|
115
|
+
toolResult("edit", "edit", "Successfully replaced 1 block", false),
|
|
116
|
+
assistantToolCall("pass", "bash", { command: "npm run check" }),
|
|
117
|
+
toolResult("pass", "bash", "OK", false),
|
|
118
|
+
] as any, "tool_evidence");
|
|
119
|
+
|
|
120
|
+
assert.equal(candidates.length, 1);
|
|
121
|
+
assert.equal(candidates[0].kind, "skill_candidate");
|
|
122
|
+
assert.equal(candidates[0].confidence, "high");
|
|
123
|
+
assert.deepEqual(candidates[0].targetHints, ["skill"]);
|
|
124
|
+
assert.match(candidates[0].evidence || "", /Failure: Command exited with code 1/);
|
|
125
|
+
});
|
|
126
|
+
|
|
71
127
|
test("buildTransitionHandoff captures recent conversation without LLM summary", () => {
|
|
72
128
|
const handoff = buildTransitionHandoff(
|
|
73
129
|
ctx([
|