@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 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.17.tgz | tar -xzO package/scripts/bootstrap.sh | bash
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lebronj/pi-suite",
3
- "version": "0.1.19",
3
+ "version": "0.1.21",
4
4
  "description": "JHP's Pi extension suite for team coding workflows",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -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`; they are not injected as normal memory and are not auto-enabled.
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
- - Approval is explicit by default: memory proposals write to memory stores; skill proposals write disabled drafts under the resolved skill draft root.
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 `review`.
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. They are not moved into enabled skill directories automatically.
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
- Current learning extraction is text-based: it reads user/assistant conversation messages and asks the active model for structured candidates. It does not yet inspect structured tool-call graphs directly. Curator patch audit remains in `audit/curator.jsonl`; learning approvals are tracked through `REVIEW.md` proposal metadata and status changes.
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` | `review` | Allow curator to propose disabled skill drafts |
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" | "review" {
589
- return env.PI_MEMORY_SKILL_DRAFTS?.toLowerCase() === "off" ? "off" : "review";
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: "session_shutdown",
771
+ source,
717
772
  });
718
773
  }
719
774
  return candidates;
720
775
  }
721
776
 
722
- async function runSessionLearningExtractor(ctx: ExtensionContext): Promise<number> {
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 || !ctx.model) return 0;
726
- const apiKey = await resolveExitSummaryApiKey(ctx);
727
- if (!apiKey) return 0;
728
- const conversation = serializeSessionConversation(branch);
729
- if (!conversation.hasMessages || !conversation.text.trim()) return 0;
730
- const truncated = truncateText(conversation.text.trim(), LEARNING_EXTRACTOR_MAX_CHARS, "end");
731
- const messages: Message[] = [{
732
- role: "user",
733
- content: [{ type: "text", text: buildLearningExtractorPrompt(truncated.text, truncated.truncated, conversation.text.trim().length) }],
734
- timestamp: Date.now(),
735
- }];
736
- try {
737
- const response = await complete(ctx.model, { systemPrompt: LEARNING_EXTRACTOR_SYSTEM_PROMPT, messages }, { apiKey, reasoningEffort: "low" });
738
- const raw = response.content.filter((part): part is { type: "text"; text: string } => part.type === "text").map((part) => part.text).join("\n");
739
- const candidates = parseLearningExtractorResponse(raw);
740
- let written = 0;
741
- const store = new FileMemoryStore(MEMORY_DIR);
742
- for (const candidate of candidates) {
743
- const result = await upsertReviewCandidate(store, candidate);
744
- if (result.changed) written += 1;
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 skillResult = getMemorySkillDraftsMode() === "off" ? { created: 0, proposals: [] } : await proposeSkillDrafts(store, { draftsDir: SKILL_DRAFTS_DIR });
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
- for (const proposal of skillResult.proposals) {
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 pending = countPendingReviewItems(readFileSafe(REVIEW_FILE) ?? "");
1640
- if (pending.total > 0) {
1641
- ctx.ui.notify(`Memory review: ${pending.memory} memory / ${pending.skill} skill proposals pending. Run /memory-review.`, "info");
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 pending = countPendingReviewItems(readFileSafe(REVIEW_FILE) ?? "");
1711
- ctx.ui.notify(`Memory learning today: ${newCandidates} new candidate(s), ${pending.memory} memory proposal(s) pending, ${pending.skill} skill proposal(s) pending.`, "info");
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;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jhp/pi-memory",
3
- "version": "0.3.14",
3
+ "version": "0.3.15",
4
4
  "description": "Pi coding agent extension for structured time-aware memory with qmd-powered search and curator support",
5
5
  "main": "index.ts",
6
6
  "bin": {
@@ -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 ?? 3;
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
- writeFileSync(path, content, { encoding: "utf-8", flag: "wx" });
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", "third 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
- assert.match(readFileSync(approved.path, "utf-8"), /description: Use when fix non erasable typescript syntax in pi source\./);
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({}), "review");
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([