pi-hermes-memory 0.7.7 → 0.7.8

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
@@ -321,7 +321,7 @@ This means skills build up naturally over time without you having to ask.
321
321
  | Command | What it does |
322
322
  |---|---|
323
323
  | `/memory-insights` | Shows everything stored in memory and user profile |
324
- | `/memory-skills` | Lists global and active-project skills with their ids |
324
+ | `/memory-skills` | Opens an interactive skills manager for search, multi-select, move, and delete |
325
325
  | `/memory-consolidate` | Manually trigger memory consolidation to free space |
326
326
  | `/memory-interview` | Answer a few questions to pre-fill your user profile |
327
327
  | `/memory-switch-project` | List all project memories and their entry counts |
@@ -350,25 +350,34 @@ This means skills build up naturally over time without you having to ask.
350
350
  3. codes primarily in TypeScript
351
351
  ```
352
352
 
353
- ### `/memory-skills` Output
354
-
355
- ```
356
- ╔══════════════════════════════════════════════╗
357
- ║ 🧠 Procedural Skills ║
358
- ╚══════════════════════════════════════════════╝
359
-
360
- Global Skills
361
- ─────────────
362
- 📄 debug-typescript-errors
363
- Step-by-step approach to debugging TS errors in monorepos
364
- id: global:debug-typescript-errors
365
-
366
- Project Skills (pi-hermes-memory)
367
- ────────────────────────────────
368
- 📄 deploy-checklist
369
- Pre-deploy verification steps for this project
370
- id: project:pi-hermes-memory:deploy-checklist
371
- ```
353
+ ### `/memory-skills` Manager
354
+
355
+ `/memory-skills` now opens an interactive TUI modal for skill management.
356
+
357
+ Features:
358
+ - fuzzy search by skill name
359
+ - single-list view with scope badges (`[G]` global, `[P]` project)
360
+ - multi-select with spacebar
361
+ - batch move to global or current project
362
+ - batch delete with one confirmation
363
+ - inline action summaries for partial success/conflicts
364
+
365
+ Keybindings:
366
+ - `↑` / `↓` — move focus
367
+ - `space` — toggle selection
368
+ - `/` — focus search
369
+ - `tab` switch between search and list
370
+ - `g` — move selected skills to global
371
+ - `p` — move selected skills to project
372
+ - `d` — delete selected skills
373
+ - `a` — select all filtered skills
374
+ - `n` — clear selection
375
+ - `esc` — close the modal
376
+
377
+ Move behavior:
378
+ - moves are **conflict-safe**
379
+ - if the destination already contains the same slug, the conflicting skill stays in place
380
+ - batch moves use partial-success semantics: non-conflicting skills move, blocked skills are reported in the summary
372
381
 
373
382
  ## Configuration
374
383
 
@@ -471,8 +480,9 @@ The `sessions.db` SQLite database stores session history and extended memory ent
471
480
  - **Older Markdown memories may need backfill**: If you saved memories before the SQLite mirror existed or search looks stale, run `/memory-sync-markdown`.
472
481
  - **Core memory limits still apply**: SQLite search mirroring does not bypass the 5,000-char core Markdown limit. If consolidation cannot free space, the write fails instead of becoming SQLite-only memory invisibly.
473
482
  - **System prompts are invisible**: Pi's TUI does not display the system prompt. Use `/memory-preview-context` to inspect whether policy-only or legacy memory injection is active.
474
- - **Project skill visibility depends on Pi discovery cycles**: project skills are exposed through `resources_discover` using the active project's `skills/` path. If a new skill doesn't show up immediately in a running session, trigger a reload/new session so Pi refreshes discovered resources.
475
- - **Skills are agent-generated**: Skills are created by the agent based on its experience. They may not always be perfectly structured. You can edit or delete them in `~/.pi/agent/skills/` or the active project's `skills/` folder.
483
+ - **Project skill visibility depends on Pi discovery cycles**: project skills are exposed through `resources_discover` using the active project's `skills/` path. If a moved or newly created project skill doesn't show up immediately in a running session, trigger a reload/new session so Pi refreshes discovered resources.
484
+ - **Project move requires active project context**: in `/memory-skills`, the `p` hotkey is disabled when Pi is not currently in a detected project directory.
485
+ - **Skills are agent-generated**: Skills are created by the agent based on its experience. They may not always be perfectly structured. You can move, delete, or still edit them directly in `~/.pi/agent/skills/` or the active project's `skills/` folder.
476
486
 
477
487
  ## Architecture
478
488
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hermes-memory",
3
- "version": "0.7.7",
3
+ "version": "0.7.8",
4
4
  "description": "🧠 Persistent memory + 🔍 session search + 🛡️ secret scanning for Pi. Token-aware policy-only memory by default, SQLite FTS5 search, auto-consolidation, procedural skills. 368 tests. Ported from Hermes agent.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -56,6 +56,7 @@
56
56
  "typescript": "^6.0.3"
57
57
  },
58
58
  "dependencies": {
59
+ "@earendil-works/pi-tui": "^0.74.0",
59
60
  "better-sqlite3": "^12.9.0"
60
61
  }
61
62
  }
@@ -1,58 +1,764 @@
1
1
  /**
2
- * Skills command — /memory-skills lists all agent-created skills.
2
+ * Skills command — /memory-skills opens an interactive skills manager.
3
3
  */
4
4
 
5
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
5
+ import type { ExtensionAPI, ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
6
6
  import { SkillStore } from "../store/skill-store.js";
7
+ import type { SkillIndex, SkillResult, SkillScope } from "../types.js";
8
+ import {
9
+ Input,
10
+ Key,
11
+ fuzzyFilter,
12
+ matchesKey,
13
+ truncateToWidth,
14
+ visibleWidth,
15
+ wrapTextWithAnsi,
16
+ type Focusable,
17
+ type TUI,
18
+ } from "@earendil-works/pi-tui";
7
19
 
8
- export function registerSkillsCommand(pi: ExtensionAPI, store: SkillStore): void {
9
- pi.registerCommand("memory-skills", {
10
- description: "List all agent-created skills (procedural memory)",
11
- handler: async (_args, ctx) => {
12
- const skills = await store.loadIndex();
13
- const globalSkills = skills.filter((skill) => skill.scope === "global");
14
- const projectSkills = skills.filter((skill) => skill.scope === "project");
15
- const projectName = store.getProjectName();
20
+ export const MEMORY_SKILLS_KEYMAP = {
21
+ moveGlobal: "g",
22
+ moveProject: "p",
23
+ deleteSelected: "d",
24
+ selectAllFiltered: "a",
25
+ clearSelection: "n",
26
+ focusSearch: "/",
27
+ toggleSelection: "space",
28
+ switchFocus: "tab",
29
+ close: "esc",
30
+ } as const;
31
+
32
+ export interface SkillModalRow {
33
+ skillId: string;
34
+ scope: SkillScope;
35
+ name: string;
36
+ displayName: string;
37
+ description: string;
38
+ path: string;
39
+ projectName?: string;
40
+ selected: boolean;
41
+ searchText: string;
42
+ }
43
+
44
+ export interface SkillBatchActionResult {
45
+ skills: SkillIndex[];
46
+ summaryLines: string[];
47
+ retainSelectedSkillIds?: string[];
48
+ focusSkillId?: string;
49
+ }
16
50
 
17
- const lines: string[] = [];
51
+ export function formatSkillsList(skills: SkillIndex[], projectName: string | null): string {
52
+ const globalSkills = skills.filter((skill) => skill.scope === "global");
53
+ const projectSkills = skills.filter((skill) => skill.scope === "project");
54
+
55
+ const lines: string[] = [];
56
+ lines.push("");
57
+ lines.push(" ╔══════════════════════════════════════════════╗");
58
+ lines.push(" ║ 🧠 Procedural Skills ║");
59
+ lines.push(" ╚══════════════════════════════════════════════╝");
60
+ lines.push("");
61
+
62
+ if (skills.length === 0) {
63
+ lines.push(" (no skills created yet)");
64
+ lines.push("");
65
+ lines.push(" Skills are auto-created after complex tasks,");
66
+ lines.push(" or you can ask the agent to create one.");
67
+ return lines.join("\n");
68
+ }
69
+
70
+ if (globalSkills.length > 0) {
71
+ lines.push(" Global Skills");
72
+ lines.push(" ─────────────");
73
+ for (const skill of globalSkills) {
74
+ lines.push(` 📄 ${skill.displayName || skill.name}`);
75
+ lines.push(` ${skill.description}`);
76
+ lines.push(` id: ${skill.skillId}`);
77
+ lines.push(` path: ${skill.path}`);
18
78
  lines.push("");
19
- lines.push(" ╔══════════════════════════════════════════════╗");
20
- lines.push(" ║ 🧠 Procedural Skills ║");
21
- lines.push(" ╚══════════════════════════════════════════════╝");
79
+ }
80
+ }
81
+
82
+ if (projectSkills.length > 0) {
83
+ lines.push(` Project Skills${projectName ? ` (${projectName})` : ""}`);
84
+ lines.push(" ──────────────");
85
+ for (const skill of projectSkills) {
86
+ lines.push(` 📄 ${skill.displayName || skill.name}`);
87
+ lines.push(` ${skill.description}`);
88
+ lines.push(` id: ${skill.skillId}`);
89
+ lines.push(` path: ${skill.path}`);
22
90
  lines.push("");
91
+ }
92
+ }
23
93
 
24
- if (skills.length === 0) {
25
- lines.push(" (no skills created yet)");
26
- lines.push("");
27
- lines.push(" Skills are auto-created after complex tasks,");
28
- lines.push(" or you can ask the agent to create one.");
29
- } else {
30
- if (globalSkills.length > 0) {
31
- lines.push(" Global Skills");
32
- lines.push(" ─────────────");
33
- for (const skill of globalSkills) {
34
- lines.push(` 📄 ${skill.displayName || skill.name}`);
35
- lines.push(` ${skill.description}`);
36
- lines.push(` id: ${skill.skillId}`);
37
- lines.push(` path: ${skill.path}`);
38
- lines.push("");
39
- }
40
- }
94
+ return lines.join("\n");
95
+ }
96
+
97
+ export function buildSkillRows(skills: SkillIndex[], selectedSkillIds = new Set<string>()): SkillModalRow[] {
98
+ return skills.map((skill) => ({
99
+ skillId: skill.skillId,
100
+ scope: skill.scope,
101
+ name: skill.name,
102
+ displayName: skill.displayName || skill.name,
103
+ description: skill.description,
104
+ path: skill.path,
105
+ projectName: skill.projectName,
106
+ selected: selectedSkillIds.has(skill.skillId),
107
+ searchText: `${skill.displayName || skill.name} ${skill.name}`.trim(),
108
+ }));
109
+ }
110
+
111
+ export function filterSkillRows(rows: SkillModalRow[], query: string): SkillModalRow[] {
112
+ const trimmed = query.trim();
113
+ if (!trimmed) return rows;
114
+ return fuzzyFilter(rows, trimmed, (row) => row.searchText);
115
+ }
116
+
117
+ export function getSelectedSkillIds(rows: SkillModalRow[]): string[] {
118
+ return rows.filter((row) => row.selected).map((row) => row.skillId);
119
+ }
120
+
121
+ function summarizeAction(
122
+ actionVerb: string,
123
+ targetLabel: string,
124
+ successes: SkillResult[],
125
+ unchanged: SkillResult[],
126
+ blocked: Array<{ skillId: string; error: string }>,
127
+ ): string[] {
128
+ const lines: string[] = [];
129
+ const changed = successes.filter((result) => result.message?.includes(actionVerb) || result.skillId);
130
+
131
+ if (actionVerb === "moved") {
132
+ lines.push(`Moved ${successes.length} skill${successes.length === 1 ? "" : "s"} to ${targetLabel}.`);
133
+ } else if (actionVerb === "deleted") {
134
+ lines.push(`Deleted ${successes.length} skill${successes.length === 1 ? "" : "s"}.`);
135
+ } else {
136
+ lines.push(`${changed.length} skill action(s) completed.`);
137
+ }
138
+
139
+ if (unchanged.length > 0) {
140
+ lines.push(`${unchanged.length} already matched the target scope.`);
141
+ }
142
+
143
+ if (blocked.length > 0) {
144
+ lines.push(`Blocked ${blocked.length} skill${blocked.length === 1 ? "" : "s"}:`);
145
+ for (const item of blocked.slice(0, 4)) {
146
+ lines.push(`- ${item.skillId}: ${item.error}`);
147
+ }
148
+ if (blocked.length > 4) {
149
+ lines.push(`- …and ${blocked.length - 4} more`);
150
+ }
151
+ }
152
+
153
+ return lines;
154
+ }
155
+
156
+ type SkillMoveStore = Pick<SkillStore, "move" | "loadIndex" | "getProjectName">;
157
+ type SkillDeleteStore = Pick<SkillStore, "delete" | "loadIndex">;
158
+ export type ConfirmDialog = (title: string, message: string) => Promise<boolean>;
159
+
160
+ export async function moveSelectedSkills(
161
+ store: SkillMoveStore,
162
+ skillIds: string[],
163
+ targetScope: SkillScope,
164
+ ): Promise<SkillBatchActionResult> {
165
+ const dedupedSkillIds = Array.from(new Set(skillIds));
166
+ const currentSkills = await store.loadIndex();
167
+
168
+ if (dedupedSkillIds.length === 0) {
169
+ return {
170
+ skills: currentSkills,
171
+ summaryLines: ["Select one or more skills first."],
172
+ };
173
+ }
174
+
175
+ if (targetScope === "project" && !store.getProjectName()) {
176
+ return {
177
+ skills: currentSkills,
178
+ summaryLines: ["Move to project is unavailable: no active project detected."],
179
+ retainSelectedSkillIds: dedupedSkillIds,
180
+ };
181
+ }
182
+
183
+ const successes: SkillResult[] = [];
184
+ const unchanged: SkillResult[] = [];
185
+ const blocked: Array<{ skillId: string; error: string }> = [];
41
186
 
42
- if (projectSkills.length > 0) {
43
- lines.push(` Project Skills${projectName ? ` (${projectName})` : ""}`);
44
- lines.push(" ──────────────");
45
- for (const skill of projectSkills) {
46
- lines.push(` 📄 ${skill.displayName || skill.name}`);
47
- lines.push(` ${skill.description}`);
48
- lines.push(` id: ${skill.skillId}`);
49
- lines.push(` path: ${skill.path}`);
50
- lines.push("");
51
- }
187
+ for (const skillId of dedupedSkillIds) {
188
+ try {
189
+ const result = await store.move(skillId, targetScope);
190
+ if (result.success) {
191
+ if (result.skillId === skillId && result.scope === targetScope) {
192
+ unchanged.push(result);
193
+ } else {
194
+ successes.push(result);
52
195
  }
196
+ } else {
197
+ blocked.push({ skillId, error: result.error || "Unknown move failure." });
198
+ }
199
+ } catch (error) {
200
+ blocked.push({
201
+ skillId,
202
+ error: error instanceof Error ? error.message : String(error),
203
+ });
204
+ }
205
+ }
206
+
207
+ const refreshedSkills = await store.loadIndex();
208
+ const focusSkillId = blocked[0]?.skillId
209
+ ?? successes[0]?.skillId
210
+ ?? unchanged[0]?.skillId;
211
+
212
+ return {
213
+ skills: refreshedSkills,
214
+ summaryLines: summarizeAction("moved", targetScope, successes, unchanged, blocked),
215
+ retainSelectedSkillIds: blocked.map((item) => item.skillId),
216
+ focusSkillId,
217
+ };
218
+ }
219
+
220
+ export async function deleteSelectedSkills(
221
+ store: SkillDeleteStore,
222
+ skillIds: string[],
223
+ ): Promise<SkillBatchActionResult> {
224
+ const dedupedSkillIds = Array.from(new Set(skillIds));
225
+ const currentSkills = await store.loadIndex();
226
+
227
+ if (dedupedSkillIds.length === 0) {
228
+ return {
229
+ skills: currentSkills,
230
+ summaryLines: ["Select one or more skills first."],
231
+ };
232
+ }
233
+
234
+ const successes: SkillResult[] = [];
235
+ const blocked: Array<{ skillId: string; error: string }> = [];
236
+
237
+ for (const skillId of dedupedSkillIds) {
238
+ try {
239
+ const result = await store.delete(skillId);
240
+ if (result.success) {
241
+ successes.push(result);
242
+ } else {
243
+ blocked.push({ skillId, error: result.error || "Unknown delete failure." });
53
244
  }
245
+ } catch (error) {
246
+ blocked.push({
247
+ skillId,
248
+ error: error instanceof Error ? error.message : String(error),
249
+ });
250
+ }
251
+ }
252
+
253
+ const refreshedSkills = await store.loadIndex();
254
+
255
+ return {
256
+ skills: refreshedSkills,
257
+ summaryLines: summarizeAction("deleted", "delete", successes, [], blocked),
258
+ retainSelectedSkillIds: blocked.map((item) => item.skillId),
259
+ focusSkillId: blocked[0]?.skillId,
260
+ };
261
+ }
262
+
263
+ export async function confirmDeleteSelectedSkills(
264
+ confirm: ConfirmDialog,
265
+ store: SkillDeleteStore,
266
+ skillIds: string[],
267
+ ): Promise<SkillBatchActionResult> {
268
+ const currentSkills = await store.loadIndex();
269
+ if (skillIds.length === 0) {
270
+ return { skills: currentSkills, summaryLines: ["Select one or more skills first."] };
271
+ }
272
+
273
+ const confirmed = await confirm(
274
+ "Delete selected skills?",
275
+ `Delete ${skillIds.length} selected skill${skillIds.length === 1 ? "" : "s"}? This cannot be undone.`,
276
+ );
277
+
278
+ if (!confirmed) {
279
+ return {
280
+ skills: currentSkills,
281
+ summaryLines: ["Delete cancelled."],
282
+ retainSelectedSkillIds: skillIds,
283
+ focusSkillId: skillIds[0],
284
+ };
285
+ }
286
+
287
+ return deleteSelectedSkills(store, skillIds);
288
+ }
289
+
290
+ interface SkillsManagerCallbacks {
291
+ moveSelected: (scope: SkillScope, skillIds: string[]) => Promise<SkillBatchActionResult>;
292
+ deleteSelected: (skillIds: string[]) => Promise<SkillBatchActionResult>;
293
+ close: () => void;
294
+ projectName: string | null;
295
+ }
296
+
297
+ export class SkillsManagerModal implements Focusable {
298
+ private _focused = false;
299
+
300
+ get focused(): boolean {
301
+ return this._focused;
302
+ }
303
+
304
+ set focused(value: boolean) {
305
+ this._focused = value;
306
+ this.syncSearchFocus();
307
+ }
308
+
309
+ private readonly searchInput = new Input();
310
+ private rows: SkillModalRow[];
311
+ private selectedIndex = 0;
312
+ private query = "";
313
+ private focusArea: "search" | "list" = "list";
314
+ private busy = false;
315
+ private closed = false;
316
+ private pendingDeleteConfirm: { skillIds: string[] } | null = null;
317
+ private summaryLines: string[] = [
318
+ "Select skills with space, then move with g/p or delete with d.",
319
+ ];
320
+
321
+ constructor(
322
+ private readonly tui: TUI,
323
+ private readonly theme: Theme,
324
+ initialRows: SkillModalRow[],
325
+ private readonly callbacks: SkillsManagerCallbacks,
326
+ ) {
327
+ this.rows = initialRows;
328
+ this.syncSearchFocus();
329
+ }
330
+
331
+ invalidate(): void {
332
+ this.searchInput.invalidate();
333
+ }
334
+
335
+ private get filteredRows(): SkillModalRow[] {
336
+ return filterSkillRows(this.rows, this.query);
337
+ }
338
+
339
+ private getCurrentRow(): SkillModalRow | null {
340
+ const rows = this.filteredRows;
341
+ if (rows.length === 0) return null;
342
+ return rows[Math.min(this.selectedIndex, rows.length - 1)] ?? null;
343
+ }
344
+
345
+ private getSelectedIds(): string[] {
346
+ return getSelectedSkillIds(this.rows);
347
+ }
348
+
349
+ private syncSearchFocus(): void {
350
+ this.searchInput.focused = this.focused && this.focusArea === "search";
351
+ }
352
+
353
+ private syncQueryFromInput(): void {
354
+ this.query = this.searchInput.getValue();
355
+ const rows = this.filteredRows;
356
+ if (rows.length === 0) {
357
+ this.selectedIndex = 0;
358
+ } else {
359
+ this.selectedIndex = Math.min(this.selectedIndex, rows.length - 1);
360
+ }
361
+ }
362
+
363
+ private setFocusArea(area: "search" | "list"): void {
364
+ this.focusArea = area;
365
+ this.syncSearchFocus();
366
+ this.tui.requestRender();
367
+ }
54
368
 
55
- ctx.ui.notify(lines.join("\n"), "info");
369
+ private setRows(skills: SkillIndex[], retainSelectedSkillIds: string[] = [], focusSkillId?: string): void {
370
+ this.rows = buildSkillRows(skills, new Set(retainSelectedSkillIds));
371
+ this.syncQueryFromInput();
372
+
373
+ const rows = this.filteredRows;
374
+ if (rows.length === 0) {
375
+ this.selectedIndex = 0;
376
+ return;
377
+ }
378
+
379
+ if (focusSkillId) {
380
+ const focusIndex = rows.findIndex((row) => row.skillId === focusSkillId);
381
+ if (focusIndex >= 0) {
382
+ this.selectedIndex = focusIndex;
383
+ return;
384
+ }
385
+ }
386
+
387
+ this.selectedIndex = Math.min(this.selectedIndex, rows.length - 1);
388
+ }
389
+
390
+ private toggleSelected(skillId: string): void {
391
+ const row = this.rows.find((entry) => entry.skillId === skillId);
392
+ if (!row) return;
393
+ row.selected = !row.selected;
394
+ }
395
+
396
+ private toggleCurrentSelection(): void {
397
+ const row = this.getCurrentRow();
398
+ if (!row) return;
399
+ this.toggleSelected(row.skillId);
400
+ this.summaryLines = [
401
+ `${row.selected ? "Selected" : "Cleared"} ${row.displayName}.`,
402
+ ];
403
+ this.tui.requestRender();
404
+ }
405
+
406
+ private selectAllFiltered(): void {
407
+ const rows = this.filteredRows;
408
+ for (const row of rows) {
409
+ row.selected = true;
410
+ }
411
+ this.summaryLines = [
412
+ `Selected ${rows.length} visible skill${rows.length === 1 ? "" : "s"}.`,
413
+ ];
414
+ this.tui.requestRender();
415
+ }
416
+
417
+ private clearSelection(): void {
418
+ for (const row of this.rows) {
419
+ row.selected = false;
420
+ }
421
+ this.summaryLines = ["Cleared all selections."];
422
+ this.tui.requestRender();
423
+ }
424
+
425
+ private async runMove(targetScope: SkillScope): Promise<void> {
426
+ const selectedIds = this.getSelectedIds();
427
+ await this.runAsyncAction(this.callbacks.moveSelected(targetScope, selectedIds));
428
+ }
429
+
430
+ private promptDelete(): void {
431
+ const selectedIds = this.getSelectedIds();
432
+ if (selectedIds.length === 0) {
433
+ this.summaryLines = ["Select one or more skills first."];
434
+ this.tui.requestRender();
435
+ return;
436
+ }
437
+
438
+ this.pendingDeleteConfirm = { skillIds: selectedIds };
439
+ this.summaryLines = [
440
+ `Delete ${selectedIds.length} selected skill${selectedIds.length === 1 ? "" : "s"}? Press y to confirm or n to cancel.`,
441
+ ];
442
+ this.tui.requestRender();
443
+ }
444
+
445
+ private async runDeleteConfirmed(skillIds: string[]): Promise<void> {
446
+ await this.runAsyncAction(this.callbacks.deleteSelected(skillIds));
447
+ }
448
+
449
+ private closeModal(): void {
450
+ if (this.closed) return;
451
+ this.closed = true;
452
+ this.callbacks.close();
453
+ }
454
+
455
+ private async runAsyncAction(action: Promise<SkillBatchActionResult>): Promise<void> {
456
+ if (this.closed) return;
457
+
458
+ this.busy = true;
459
+ this.summaryLines = ["Applying skill changes…"];
460
+ this.tui.requestRender();
461
+
462
+ try {
463
+ const result = await action;
464
+ if (this.closed) return;
465
+ this.setRows(result.skills, result.retainSelectedSkillIds, result.focusSkillId);
466
+ this.summaryLines = result.summaryLines;
467
+ } catch (error) {
468
+ if (!this.closed) {
469
+ this.summaryLines = [error instanceof Error ? error.message : String(error)];
470
+ }
471
+ } finally {
472
+ this.busy = false;
473
+ if (!this.closed) {
474
+ this.tui.requestRender();
475
+ }
476
+ }
477
+ }
478
+
479
+ private moveSelection(delta: number): void {
480
+ const rows = this.filteredRows;
481
+ if (rows.length === 0) return;
482
+ const next = this.selectedIndex + delta;
483
+ this.selectedIndex = Math.max(0, Math.min(next, rows.length - 1));
484
+ this.tui.requestRender();
485
+ }
486
+
487
+ private pageSelection(delta: number): void {
488
+ const pageSize = Math.max(5, this.getMaxVisibleRows() - 1);
489
+ this.moveSelection(delta * pageSize);
490
+ }
491
+
492
+ private getMaxVisibleRows(): number {
493
+ return Math.max(6, Math.min(14, this.tui.terminal.rows - 18));
494
+ }
495
+
496
+ private focusSearchWithOptionalInput(data?: string): void {
497
+ this.setFocusArea("search");
498
+ if (data) {
499
+ this.searchInput.handleInput(data);
500
+ this.syncQueryFromInput();
501
+ this.tui.requestRender();
502
+ }
503
+ }
504
+
505
+ private isPrintableInput(data: string): boolean {
506
+ return data.length === 1 && data >= " " && data !== "\x7f";
507
+ }
508
+
509
+ handleInput(data: string): void {
510
+ if (this.closed) return;
511
+
512
+ if (this.busy) {
513
+ if (matchesKey(data, Key.escape)) this.closeModal();
514
+ return;
515
+ }
516
+
517
+ if (this.pendingDeleteConfirm) {
518
+ if (data === "y" || data === "Y") {
519
+ const pending = this.pendingDeleteConfirm;
520
+ this.pendingDeleteConfirm = null;
521
+ void this.runDeleteConfirmed(pending.skillIds);
522
+ return;
523
+ }
524
+
525
+ if (data === "n" || data === "N" || matchesKey(data, Key.escape)) {
526
+ this.pendingDeleteConfirm = null;
527
+ this.summaryLines = ["Delete cancelled."];
528
+ this.tui.requestRender();
529
+ }
530
+ return;
531
+ }
532
+
533
+ if (matchesKey(data, Key.escape)) {
534
+ this.closeModal();
535
+ return;
536
+ }
537
+
538
+ if (this.focusArea === "search") {
539
+ if (matchesKey(data, Key.tab) || matchesKey(data, Key.down)) {
540
+ this.setFocusArea("list");
541
+ return;
542
+ }
543
+
544
+ this.searchInput.handleInput(data);
545
+ this.syncQueryFromInput();
546
+ this.tui.requestRender();
547
+ return;
548
+ }
549
+
550
+ if (matchesKey(data, Key.tab) || matchesKey(data, Key.slash)) {
551
+ this.focusSearchWithOptionalInput();
552
+ return;
553
+ }
554
+ if (matchesKey(data, Key.up)) {
555
+ this.moveSelection(-1);
556
+ return;
557
+ }
558
+ if (matchesKey(data, Key.down)) {
559
+ this.moveSelection(1);
560
+ return;
561
+ }
562
+ if (matchesKey(data, Key.pageUp)) {
563
+ this.pageSelection(-1);
564
+ return;
565
+ }
566
+ if (matchesKey(data, Key.pageDown)) {
567
+ this.pageSelection(1);
568
+ return;
569
+ }
570
+ if (matchesKey(data, Key.home)) {
571
+ this.selectedIndex = 0;
572
+ this.tui.requestRender();
573
+ return;
574
+ }
575
+ if (matchesKey(data, Key.end)) {
576
+ this.selectedIndex = Math.max(0, this.filteredRows.length - 1);
577
+ this.tui.requestRender();
578
+ return;
579
+ }
580
+ if (matchesKey(data, Key.space)) {
581
+ this.toggleCurrentSelection();
582
+ return;
583
+ }
584
+ if (data === MEMORY_SKILLS_KEYMAP.selectAllFiltered) {
585
+ this.selectAllFiltered();
586
+ return;
587
+ }
588
+ if (data === MEMORY_SKILLS_KEYMAP.clearSelection) {
589
+ this.clearSelection();
590
+ return;
591
+ }
592
+ if (data === MEMORY_SKILLS_KEYMAP.moveGlobal) {
593
+ void this.runMove("global");
594
+ return;
595
+ }
596
+ if (data === MEMORY_SKILLS_KEYMAP.moveProject) {
597
+ void this.runMove("project");
598
+ return;
599
+ }
600
+ if (data === MEMORY_SKILLS_KEYMAP.deleteSelected) {
601
+ this.promptDelete();
602
+ return;
603
+ }
604
+ if (this.isPrintableInput(data) && !["g", "p", "d", "a", "n"].includes(data)) {
605
+ this.focusSearchWithOptionalInput(data);
606
+ }
607
+ }
608
+
609
+ private renderFramedLine(content: string, width: number): string {
610
+ const innerWidth = Math.max(10, width - 4);
611
+ const padded = truncateToWidth(content, innerWidth, "");
612
+ const spaces = Math.max(0, innerWidth - visibleWidth(padded));
613
+ return `${this.theme.fg("borderAccent", "│")} ${padded}${" ".repeat(spaces)} ${this.theme.fg("borderAccent", "│")}`;
614
+ }
615
+
616
+ private renderWrappedSection(lines: string[], width: number): string[] {
617
+ const rendered: string[] = [];
618
+ const innerWidth = Math.max(10, width - 4);
619
+ for (const line of lines) {
620
+ const wrapped = wrapTextWithAnsi(line, innerWidth);
621
+ if (wrapped.length === 0) {
622
+ rendered.push(this.renderFramedLine("", width));
623
+ continue;
624
+ }
625
+ for (const part of wrapped) {
626
+ rendered.push(this.renderFramedLine(part, width));
627
+ }
628
+ }
629
+ return rendered;
630
+ }
631
+
632
+ render(width: number): string[] {
633
+ const safeWidth = Math.max(60, width);
634
+ const top = this.theme.fg("borderAccent", `┌${"─".repeat(Math.max(1, safeWidth - 2))}┐`);
635
+ const bottom = this.theme.fg("borderAccent", `└${"─".repeat(Math.max(1, safeWidth - 2))}┘`);
636
+ const lines: string[] = [top];
637
+
638
+ const projectName = this.callbacks.projectName ? ` · project: ${this.callbacks.projectName}` : "";
639
+ const title = this.theme.fg("accent", this.theme.bold(`🧠 Procedural Skills${projectName}`));
640
+ lines.push(this.renderFramedLine(title, safeWidth));
641
+
642
+ const searchHint = this.focusArea === "search"
643
+ ? this.theme.fg("accent", "search")
644
+ : this.theme.fg("dim", "search");
645
+ const searchLine = this.searchInput.render(Math.max(10, safeWidth - 17))[0] ?? "";
646
+ lines.push(this.renderFramedLine(`${searchHint}: ${searchLine}`, safeWidth));
647
+
648
+ const filteredRows = this.filteredRows;
649
+ const selectedCount = this.getSelectedIds().length;
650
+ lines.push(this.renderFramedLine(
651
+ this.theme.fg(
652
+ "dim",
653
+ `${filteredRows.length} visible · ${this.rows.length} total · ${selectedCount} selected${this.busy ? " · working…" : ""}`,
654
+ ),
655
+ safeWidth,
656
+ ));
657
+
658
+ lines.push(this.renderFramedLine("", safeWidth));
659
+
660
+ if (filteredRows.length === 0) {
661
+ const emptyMessage = this.rows.length === 0 ? "No skills found yet." : "No skills match the current search.";
662
+ lines.push(this.renderFramedLine(this.theme.fg("warning", emptyMessage), safeWidth));
663
+ lines.push(this.renderFramedLine("", safeWidth));
664
+ } else {
665
+ const maxVisible = this.getMaxVisibleRows();
666
+ const start = Math.max(0, Math.min(this.selectedIndex - Math.floor(maxVisible / 2), filteredRows.length - maxVisible));
667
+ const end = Math.min(filteredRows.length, start + maxVisible);
668
+ const visibleRows = filteredRows.slice(start, end);
669
+
670
+ for (let i = 0; i < visibleRows.length; i++) {
671
+ const row = visibleRows[i]!;
672
+ const absoluteIndex = start + i;
673
+ const cursor = absoluteIndex === this.selectedIndex ? this.theme.fg("accent", "›") : " ";
674
+ const check = row.selected ? this.theme.fg("accent", "[x]") : this.theme.fg("dim", "[ ]");
675
+ const scope = row.scope === "global"
676
+ ? this.theme.fg("accent", "[G]")
677
+ : this.theme.fg("warning", "[P]");
678
+ const baseText = `${cursor} ${check} ${scope} ${row.displayName}`;
679
+ const lineText = absoluteIndex === this.selectedIndex
680
+ ? this.theme.bg("selectedBg", truncateToWidth(baseText, Math.max(10, safeWidth - 4), ""))
681
+ : truncateToWidth(baseText, Math.max(10, safeWidth - 4), "");
682
+ lines.push(this.renderFramedLine(lineText, safeWidth));
683
+ }
684
+
685
+ if (start > 0 || end < filteredRows.length) {
686
+ lines.push(this.renderFramedLine(this.theme.fg("dim", `Showing ${start + 1}-${end} of ${filteredRows.length}`), safeWidth));
687
+ }
688
+
689
+ lines.push(this.renderFramedLine("", safeWidth));
690
+ const currentRow = this.getCurrentRow();
691
+ if (currentRow) {
692
+ lines.push(this.renderFramedLine(this.theme.fg("accent", `Focused: ${currentRow.displayName}`), safeWidth));
693
+ lines.push(...this.renderWrappedSection([
694
+ currentRow.description || "(no description)",
695
+ this.theme.fg("dim", currentRow.skillId),
696
+ ], safeWidth));
697
+ }
698
+ }
699
+
700
+ lines.push(this.renderFramedLine("", safeWidth));
701
+ lines.push(this.renderFramedLine(this.theme.fg("accent", "Last action"), safeWidth));
702
+ lines.push(...this.renderWrappedSection(this.summaryLines, safeWidth));
703
+ lines.push(this.renderFramedLine("", safeWidth));
704
+
705
+ const help = this.pendingDeleteConfirm
706
+ ? "Confirm delete: y yes · n no · esc cancel"
707
+ : this.callbacks.projectName
708
+ ? "↑↓ move · space select · / search · tab switch · g global · p project · d delete · a all · n none · esc close"
709
+ : "↑↓ move · space select · / search · tab switch · g global · p project (disabled) · d delete · a all · n none · esc close";
710
+ lines.push(this.renderFramedLine(this.theme.fg("dim", help), safeWidth));
711
+ lines.push(bottom);
712
+ return lines;
713
+ }
714
+ }
715
+
716
+ export function registerSkillsCommand(pi: ExtensionAPI, store: SkillStore): void {
717
+ pi.registerCommand("memory-skills", {
718
+ description: "Manage global and active-project procedural skills",
719
+ handler: async (_args, ctx: ExtensionCommandContext) => {
720
+ const skills = await store.loadIndex();
721
+ const projectName = store.getProjectName();
722
+
723
+ if (!ctx.hasUI || typeof ctx.ui.custom !== "function") {
724
+ ctx.ui.notify(formatSkillsList(skills, projectName), "info");
725
+ return;
726
+ }
727
+
728
+ const initialRows = buildSkillRows(skills);
729
+
730
+ try {
731
+ await ctx.ui.custom<void>(
732
+ (tui, theme, _keybindings, done) => new SkillsManagerModal(
733
+ tui,
734
+ theme,
735
+ initialRows,
736
+ {
737
+ moveSelected: (scope, skillIds) => moveSelectedSkills(store, skillIds, scope),
738
+ deleteSelected: (skillIds) => deleteSelectedSkills(store, skillIds),
739
+ close: () => done(undefined),
740
+ projectName,
741
+ },
742
+ ),
743
+ {
744
+ overlay: true,
745
+ overlayOptions: {
746
+ anchor: "center",
747
+ width: "88%",
748
+ minWidth: 72,
749
+ maxHeight: "85%",
750
+ margin: 1,
751
+ },
752
+ },
753
+ );
754
+ } catch {
755
+ const latestSkills = await store.loadIndex();
756
+ ctx.ui.notify(
757
+ "Interactive skills manager unavailable in this runtime; showing read-only list fallback.",
758
+ "warning",
759
+ );
760
+ ctx.ui.notify(formatSkillsList(latestSkills, projectName), "info");
761
+ }
56
762
  },
57
763
  });
58
764
  }
package/src/index.ts CHANGED
@@ -179,25 +179,13 @@ export default function (pi: ExtensionAPI) {
179
179
  registerIndexSessionsCommand(pi);
180
180
 
181
181
  // ── 12. Auto-index session on shutdown ──
182
- pi.on("session_shutdown", async (_event, _ctx) => {
182
+ pi.on("session_shutdown", async (_event, ctx) => {
183
183
  try {
184
- const fs = require("node:fs");
185
- const sessionsDir = path.join(os.homedir(), ".pi", "agent", "sessions");
186
- const cwd = process.cwd();
187
- const encodedCwd = cwd.replace(/\//g, "-");
188
- const sessionDir = path.join(sessionsDir, encodedCwd);
189
-
190
- if (fs.existsSync(sessionDir)) {
191
- // Find the most recent JSONL file (the one we just finished)
192
- const files = fs.readdirSync(sessionDir)
193
- .filter((f: string) => f.endsWith(".jsonl"))
194
- .sort()
195
- .reverse();
196
- if (files.length > 0) {
197
- const sessionData = parseSessionFile(path.join(sessionDir, files[0]));
198
- if (sessionData) {
199
- indexSession(dbManager, sessionData);
200
- }
184
+ const sessionFile = ctx.sessionManager.getSessionFile();
185
+ if (sessionFile && require("node:fs").existsSync(sessionFile)) {
186
+ const sessionData = parseSessionFile(sessionFile);
187
+ if (sessionData) {
188
+ indexSession(dbManager, sessionData);
201
189
  }
202
190
  }
203
191
  } catch {
@@ -336,6 +336,143 @@ export class SkillStore {
336
336
  };
337
337
  }
338
338
 
339
+ async move(skillId: string, targetScope: SkillScope): Promise<SkillResult> {
340
+ const doc = await this.loadSkill(skillId);
341
+ if (!doc) return { success: false, error: `Skill '${skillId}' not found.` };
342
+
343
+ const parsed = parseSkillId(skillId);
344
+ if (!parsed) return { success: false, error: `Skill '${skillId}' is invalid.` };
345
+
346
+ if (doc.scope === targetScope) {
347
+ return {
348
+ success: true,
349
+ message: `Skill '${doc.displayName || doc.name}' is already ${targetScope}.`,
350
+ fileName: doc.fileName,
351
+ skillId: doc.skillId,
352
+ scope: doc.scope,
353
+ path: doc.path,
354
+ };
355
+ }
356
+
357
+ const targetRoot = this.getScopeRoot(targetScope);
358
+ if (!targetRoot) {
359
+ return { success: false, error: "Project skills require an active project." };
360
+ }
361
+
362
+ const targetSkillId = buildSkillId(targetScope, parsed.slug, this.projectName);
363
+ const targetPath = path.join(targetRoot, parsed.slug, "SKILL.md");
364
+ if (await exists(targetPath)) {
365
+ return {
366
+ success: false,
367
+ error: `Cannot move '${doc.displayName || doc.name}' to ${targetScope}: ${targetSkillId} already exists.`,
368
+ conflictType: "scope-conflict",
369
+ similarSkillIds: [targetSkillId],
370
+ suggestedAction: "rename",
371
+ };
372
+ }
373
+
374
+ if (targetScope === "global") {
375
+ const similarSkillIds = await this.findSimilarGlobalSkillIds(parsed.slug, doc.description);
376
+ if (similarSkillIds.length > 0) {
377
+ const targetId = similarSkillIds[0];
378
+ return {
379
+ success: false,
380
+ error: `Cannot move '${doc.displayName || doc.name}' to global: a similar global skill already exists (${targetId}).`,
381
+ conflictType: "similar",
382
+ similarSkillIds,
383
+ suggestedAction: "patch",
384
+ };
385
+ }
386
+
387
+ const collidingNameSkillIds = await this.findNameCollisionGlobalSkillIds(parsed.slug, doc.description);
388
+ if (collidingNameSkillIds.length > 0) {
389
+ const targetId = collidingNameSkillIds[0];
390
+ return {
391
+ success: false,
392
+ error: `Cannot move '${doc.displayName || doc.name}' to global: a near-name global skill already exists (${targetId}) with different intent.`,
393
+ conflictType: "name-collision",
394
+ similarSkillIds: collidingNameSkillIds,
395
+ suggestedAction: "rename",
396
+ };
397
+ }
398
+ }
399
+
400
+ await fs.mkdir(path.dirname(targetPath), { recursive: true });
401
+
402
+ // Same-filesystem move: use rename first for atomicity and to avoid duplicate windows.
403
+ try {
404
+ await fs.rename(doc.path, targetPath);
405
+
406
+ if (path.basename(doc.path) === "SKILL.md") {
407
+ await this.removeEmptyParents(path.dirname(doc.path), this.getScopeRoot(doc.scope));
408
+ }
409
+
410
+ return {
411
+ success: true,
412
+ message: `Skill '${doc.displayName || doc.name}' moved to ${targetScope}.`,
413
+ fileName: path.basename(targetPath),
414
+ skillId: targetSkillId,
415
+ scope: targetScope,
416
+ path: targetPath,
417
+ };
418
+ } catch (renameError) {
419
+ const code = (renameError as NodeJS.ErrnoException)?.code;
420
+ if (code !== "EXDEV") {
421
+ return {
422
+ success: false,
423
+ error: `Move to ${targetScope} failed before copy for skill '${skillId}'. Source path: ${doc.path}. Destination path: ${targetPath}. Error: ${renameError instanceof Error ? renameError.message : String(renameError)}`,
424
+ };
425
+ }
426
+ // Cross-device fallback below.
427
+ }
428
+
429
+ // Cross-device fallback: copy then remove source.
430
+ await this.atomicWrite(targetPath, formatFrontmatter({
431
+ name: parsed.slug,
432
+ displayName: doc.displayName,
433
+ description: doc.description,
434
+ version: doc.version,
435
+ created: doc.created,
436
+ updated: doc.updated,
437
+ body: doc.body,
438
+ }));
439
+
440
+ try {
441
+ await fs.unlink(doc.path);
442
+ if (path.basename(doc.path) === "SKILL.md") {
443
+ await this.removeEmptyParents(path.dirname(doc.path), this.getScopeRoot(doc.scope));
444
+ }
445
+ } catch (error) {
446
+ // Best-effort rollback: remove the destination copy if source removal fails,
447
+ // so we do not silently leave duplicate skills across scopes.
448
+ let rollbackFailed = false;
449
+ try {
450
+ await fs.unlink(targetPath);
451
+ if (path.basename(targetPath) === "SKILL.md") {
452
+ await this.removeEmptyParents(path.dirname(targetPath), this.getScopeRoot(targetScope));
453
+ }
454
+ } catch {
455
+ rollbackFailed = true;
456
+ }
457
+
458
+ return {
459
+ success: false,
460
+ error: rollbackFailed
461
+ ? `Move to ${targetScope} failed while removing source skill '${skillId}', and rollback also failed. Source path: ${doc.path}. Destination path: ${targetPath}. Error: ${error instanceof Error ? error.message : String(error)}`
462
+ : `Move to ${targetScope} failed while removing source skill '${skillId}'. Rolled back destination copy. Source path: ${doc.path}. Destination path: ${targetPath}. Error: ${error instanceof Error ? error.message : String(error)}`,
463
+ };
464
+ }
465
+
466
+ return {
467
+ success: true,
468
+ message: `Skill '${doc.displayName || doc.name}' moved to ${targetScope}.`,
469
+ fileName: path.basename(targetPath),
470
+ skillId: targetSkillId,
471
+ scope: targetScope,
472
+ path: targetPath,
473
+ };
474
+ }
475
+
339
476
  async delete(skillId: string): Promise<SkillResult> {
340
477
  const doc = await this.loadSkill(skillId);
341
478
  if (!doc) return { success: false, error: `Skill '${skillId}' not found.` };
package/src/types.ts CHANGED
@@ -142,7 +142,7 @@ export interface SkillResult {
142
142
  skillId?: string;
143
143
  scope?: SkillScope;
144
144
  path?: string;
145
- conflictType?: "duplicate" | "similar" | "name-collision";
145
+ conflictType?: "duplicate" | "similar" | "name-collision" | "scope-conflict";
146
146
  similarSkillIds?: string[];
147
147
  suggestedAction?: "patch" | "edit" | "rename";
148
148
  }