pi-hermes-memory 0.7.8 → 0.7.10

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.
@@ -2,6 +2,9 @@
2
2
  * Skills command — /memory-skills opens an interactive skills manager.
3
3
  */
4
4
 
5
+ import { createHash } from "node:crypto";
6
+ import * as os from "node:os";
7
+ import * as path from "node:path";
5
8
  import type { ExtensionAPI, ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
6
9
  import { SkillStore } from "../store/skill-store.js";
7
10
  import type { SkillIndex, SkillResult, SkillScope } from "../types.js";
@@ -24,43 +27,192 @@ export const MEMORY_SKILLS_KEYMAP = {
24
27
  selectAllFiltered: "a",
25
28
  clearSelection: "n",
26
29
  focusSearch: "/",
30
+ openFilters: "f",
27
31
  toggleSelection: "space",
28
32
  switchFocus: "tab",
29
33
  close: "esc",
30
34
  } as const;
31
35
 
36
+ export type SkillRowCategory = "G" | "P" | "E";
37
+
32
38
  export interface SkillModalRow {
33
39
  skillId: string;
34
- scope: SkillScope;
40
+ scope?: SkillScope;
41
+ category: SkillRowCategory;
42
+ mutable: boolean;
35
43
  name: string;
36
44
  displayName: string;
37
45
  description: string;
38
46
  path: string;
47
+ displayPath: string;
39
48
  projectName?: string;
40
49
  selected: boolean;
41
50
  searchText: string;
42
51
  }
43
52
 
44
- export interface SkillBatchActionResult {
45
- skills: SkillIndex[];
46
- summaryLines: string[];
47
- retainSelectedSkillIds?: string[];
48
- focusSkillId?: string;
53
+ interface LoadedSkillRow {
54
+ name: string;
55
+ displayName: string;
56
+ description: string;
57
+ path: string;
58
+ displayPath: string;
59
+ sourceScope?: string;
60
+ sourceOrigin?: string;
61
+ sourceLabel?: string;
62
+ }
63
+
64
+ interface SkillCommandInfo {
65
+ name: string;
66
+ description?: string;
67
+ source?: string;
68
+ sourceInfo?: {
69
+ path?: string;
70
+ scope?: string;
71
+ source?: string;
72
+ origin?: string;
73
+ baseDir?: string;
74
+ };
75
+ }
76
+
77
+ interface SkillCategoryFilters {
78
+ global: boolean;
79
+ project: boolean;
80
+ external: boolean;
81
+ }
82
+
83
+ function isRecord(value: unknown): value is Record<string, unknown> {
84
+ return typeof value === "object" && value !== null;
85
+ }
86
+
87
+ function getStringField(value: unknown): string | undefined {
88
+ return typeof value === "string" ? value : undefined;
89
+ }
90
+
91
+ const DEFAULT_SKILL_FILTERS: SkillCategoryFilters = {
92
+ global: true,
93
+ project: true,
94
+ external: true,
95
+ };
96
+
97
+ function cloneFilters(filters: SkillCategoryFilters): SkillCategoryFilters {
98
+ return {
99
+ global: filters.global,
100
+ project: filters.project,
101
+ external: filters.external,
102
+ };
103
+ }
104
+
105
+ function ensureValidFilters(filters: SkillCategoryFilters): SkillCategoryFilters {
106
+ if (filters.global || filters.project || filters.external) return filters;
107
+ return { ...DEFAULT_SKILL_FILTERS };
108
+ }
109
+
110
+ function filtersLabel(filters: SkillCategoryFilters): string {
111
+ const active: string[] = [];
112
+ if (filters.global) active.push("[G]");
113
+ if (filters.project) active.push("[P]");
114
+ if (filters.external) active.push("[E]");
115
+ return active.length > 0 ? active.join(" ") : "(none)";
116
+ }
117
+
118
+ function normalizePathForKey(inputPath: string): string {
119
+ const resolved = path.resolve(inputPath);
120
+ const normalized = resolved.replace(/\\/g, "/");
121
+ return process.platform === "win32" ? normalized.toLowerCase() : normalized;
122
+ }
123
+
124
+ export function formatSkillPath(inputPath: string): string {
125
+ const absolutePath = path.resolve(inputPath);
126
+ const home = os.homedir();
127
+ const relative = path.relative(home, absolutePath);
128
+ const underHome = relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
129
+
130
+ if (!underHome) return absolutePath;
131
+ if (relative === "") return "~";
132
+ return `~${path.sep}${relative}`;
133
+ }
134
+
135
+ function categoryForScope(scope: SkillScope): SkillRowCategory {
136
+ return scope === "global" ? "G" : "P";
137
+ }
138
+
139
+ function createExternalSkillId(name: string, filePath: string): string {
140
+ const safeName = (name || "skill")
141
+ .toLowerCase()
142
+ .replace(/[^a-z0-9-]+/g, "-")
143
+ .replace(/^-+|-+$/g, "") || "skill";
144
+ const hash = createHash("sha1").update(`${name}|${filePath}`).digest("hex").slice(0, 10);
145
+ return `external:${safeName}:${hash}`;
49
146
  }
50
147
 
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");
148
+ function matchesCategoryFilter(row: SkillModalRow, filters: SkillCategoryFilters): boolean {
149
+ if (row.category === "G") return filters.global;
150
+ if (row.category === "P") return filters.project;
151
+ return filters.external;
152
+ }
153
+
154
+ function categoryOrder(category: SkillRowCategory): number {
155
+ switch (category) {
156
+ case "G":
157
+ return 0;
158
+ case "P":
159
+ return 1;
160
+ case "E":
161
+ return 2;
162
+ }
163
+ }
164
+
165
+ export function collectLoadedSkillsFromCommands(commands: SkillCommandInfo[]): LoadedSkillRow[] {
166
+ const loaded: LoadedSkillRow[] = [];
167
+
168
+ for (const command of commands) {
169
+ if (!isRecord(command)) continue;
170
+ const source = getStringField(command.source);
171
+ if (source !== "skill") continue;
172
+
173
+ const commandName = getStringField(command.name)?.trim();
174
+ if (!commandName) continue;
175
+
176
+ const sourceInfo = isRecord(command.sourceInfo) ? command.sourceInfo : undefined;
177
+ const sourcePath = sourceInfo ? getStringField(sourceInfo.path)?.trim() : undefined;
178
+ if (!sourcePath) continue;
179
+
180
+ const rawName = commandName.startsWith("skill:")
181
+ ? commandName.slice("skill:".length)
182
+ : commandName;
183
+ const displayName = rawName || commandName;
184
+ const filePath = path.resolve(sourcePath);
185
+
186
+ loaded.push({
187
+ name: rawName || commandName,
188
+ displayName,
189
+ description: getStringField(command.description) || "",
190
+ path: filePath,
191
+ displayPath: formatSkillPath(filePath),
192
+ sourceScope: sourceInfo ? getStringField(sourceInfo.scope) : undefined,
193
+ sourceOrigin: sourceInfo ? getStringField(sourceInfo.origin) : undefined,
194
+ sourceLabel: sourceInfo ? getStringField(sourceInfo.source) : undefined,
195
+ });
196
+ }
197
+
198
+ return loaded.sort((a, b) => a.displayName.localeCompare(b.displayName));
199
+ }
200
+
201
+ export function formatSkillsList(rows: SkillModalRow[], projectName: string | null): string {
202
+ const globalSkills = rows.filter((row) => row.category === "G");
203
+ const projectSkills = rows.filter((row) => row.category === "P");
204
+ const externalSkills = rows.filter((row) => row.category === "E");
54
205
 
55
206
  const lines: string[] = [];
56
207
  lines.push("");
57
- lines.push(" ╔══════════════════════════════════════════════╗");
58
- lines.push(" ║ 🧠 Procedural Skills ║");
59
- lines.push(" ╚══════════════════════════════════════════════╝");
208
+ lines.push(" ╔═══════════════════════════════════════════════════════════╗");
209
+ lines.push(" ║ 🧠 Procedural Skills ║");
210
+ lines.push(" ╚═══════════════════════════════════════════════════════════╝");
211
+ lines.push(" Legend: [G] global · [P] project · [E] external (read-only)");
60
212
  lines.push("");
61
213
 
62
- if (skills.length === 0) {
63
- lines.push(" (no skills created yet)");
214
+ if (rows.length === 0) {
215
+ lines.push(" (no skills found in this session)");
64
216
  lines.push("");
65
217
  lines.push(" Skills are auto-created after complex tasks,");
66
218
  lines.push(" or you can ask the agent to create one.");
@@ -68,25 +220,34 @@ export function formatSkillsList(skills: SkillIndex[], projectName: string | nul
68
220
  }
69
221
 
70
222
  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}`);
223
+ lines.push(" [G] Global Skills");
224
+ lines.push(" ─────────────────");
225
+ for (const row of globalSkills) {
226
+ lines.push(` 📄 ${row.displayName} (${row.displayPath})`);
227
+ lines.push(` ${row.description || "(no description)"}`);
228
+ lines.push(` id: ${row.skillId}`);
78
229
  lines.push("");
79
230
  }
80
231
  }
81
232
 
82
233
  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}`);
234
+ lines.push(` [P] Project Skills${projectName ? ` (${projectName})` : ""}`);
235
+ lines.push(" ─────────────────────────────────");
236
+ for (const row of projectSkills) {
237
+ lines.push(` 📄 ${row.displayName} (${row.displayPath})`);
238
+ lines.push(` ${row.description || "(no description)"}`);
239
+ lines.push(` id: ${row.skillId}`);
240
+ lines.push("");
241
+ }
242
+ }
243
+
244
+ if (externalSkills.length > 0) {
245
+ lines.push(" [E] External Skills (read-only)");
246
+ lines.push(" ───────────────────────────────");
247
+ for (const row of externalSkills) {
248
+ lines.push(` 📄 ${row.displayName} (${row.displayPath})`);
249
+ lines.push(` ${row.description || "(no description)"}`);
250
+ lines.push(` id: ${row.skillId}`);
90
251
  lines.push("");
91
252
  }
92
253
  }
@@ -95,17 +256,64 @@ export function formatSkillsList(skills: SkillIndex[], projectName: string | nul
95
256
  }
96
257
 
97
258
  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
- }));
259
+ return skills.map((skill) => {
260
+ const displayName = skill.displayName || skill.name;
261
+ const displayPath = formatSkillPath(skill.path);
262
+ return {
263
+ skillId: skill.skillId,
264
+ scope: skill.scope,
265
+ category: categoryForScope(skill.scope),
266
+ mutable: true,
267
+ name: skill.name,
268
+ displayName,
269
+ description: skill.description,
270
+ path: skill.path,
271
+ displayPath,
272
+ projectName: skill.projectName,
273
+ selected: selectedSkillIds.has(skill.skillId),
274
+ searchText: `${displayName} ${skill.name} ${skill.description || ""} ${skill.path} ${displayPath}`.trim(),
275
+ };
276
+ });
277
+ }
278
+
279
+ export function buildUnifiedSkillRows(
280
+ managedSkills: SkillIndex[],
281
+ loadedSkills: LoadedSkillRow[],
282
+ selectedSkillIds = new Set<string>(),
283
+ ): SkillModalRow[] {
284
+ const managedRows = buildSkillRows(managedSkills, selectedSkillIds);
285
+ const managedPathKeys = new Set(managedRows.map((row) => normalizePathForKey(row.path)));
286
+ const externalPathKeys = new Set<string>();
287
+
288
+ const externalRows: SkillModalRow[] = [];
289
+ for (const loaded of loadedSkills) {
290
+ const loadedKey = normalizePathForKey(loaded.path);
291
+ if (managedPathKeys.has(loadedKey)) continue;
292
+ if (externalPathKeys.has(loadedKey)) continue;
293
+ externalPathKeys.add(loadedKey);
294
+
295
+ const externalSkillId = createExternalSkillId(loaded.name, loaded.path);
296
+ externalRows.push({
297
+ skillId: externalSkillId,
298
+ scope: undefined,
299
+ category: "E",
300
+ mutable: false,
301
+ name: loaded.name,
302
+ displayName: loaded.displayName,
303
+ description: loaded.description,
304
+ path: loaded.path,
305
+ displayPath: loaded.displayPath,
306
+ selected: selectedSkillIds.has(externalSkillId),
307
+ searchText: `${loaded.displayName} ${loaded.name} ${loaded.description || ""} ${loaded.path} ${loaded.displayPath}`.trim(),
308
+ });
309
+ }
310
+
311
+ return [...managedRows, ...externalRows]
312
+ .sort((a, b) => {
313
+ const byCategory = categoryOrder(a.category) - categoryOrder(b.category);
314
+ if (byCategory !== 0) return byCategory;
315
+ return a.displayName.localeCompare(b.displayName);
316
+ });
109
317
  }
110
318
 
111
319
  export function filterSkillRows(rows: SkillModalRow[], query: string): SkillModalRow[] {
@@ -157,6 +365,13 @@ type SkillMoveStore = Pick<SkillStore, "move" | "loadIndex" | "getProjectName">;
157
365
  type SkillDeleteStore = Pick<SkillStore, "delete" | "loadIndex">;
158
366
  export type ConfirmDialog = (title: string, message: string) => Promise<boolean>;
159
367
 
368
+ export interface SkillBatchActionResult {
369
+ skills: SkillIndex[];
370
+ summaryLines: string[];
371
+ retainSelectedSkillIds?: string[];
372
+ focusSkillId?: string;
373
+ }
374
+
160
375
  export async function moveSelectedSkills(
161
376
  store: SkillMoveStore,
162
377
  skillIds: string[],
@@ -307,15 +522,20 @@ export class SkillsManagerModal implements Focusable {
307
522
  }
308
523
 
309
524
  private readonly searchInput = new Input();
525
+ private managedSkills: SkillIndex[];
526
+ private readonly loadedSkills: LoadedSkillRow[];
310
527
  private rows: SkillModalRow[];
311
528
  private selectedIndex = 0;
312
529
  private query = "";
313
- private focusArea: "search" | "list" = "list";
530
+ private focusArea: "search" | "list" | "filters" = "list";
314
531
  private busy = false;
315
532
  private closed = false;
316
533
  private pendingDeleteConfirm: { skillIds: string[] } | null = null;
534
+ private activeFilters: SkillCategoryFilters = { ...DEFAULT_SKILL_FILTERS };
535
+ private pendingFilters: SkillCategoryFilters | null = null;
536
+ private filterCursor = 0;
317
537
  private summaryLines: string[] = [
318
- "Select skills with space, then move with g/p or delete with d.",
538
+ "Select skills with space, then move with g/p or delete with d. Press f for filters.",
319
539
  ];
320
540
 
321
541
  constructor(
@@ -323,8 +543,39 @@ export class SkillsManagerModal implements Focusable {
323
543
  private readonly theme: Theme,
324
544
  initialRows: SkillModalRow[],
325
545
  private readonly callbacks: SkillsManagerCallbacks,
546
+ options?: {
547
+ managedSkills?: SkillIndex[];
548
+ loadedSkills?: LoadedSkillRow[];
549
+ },
326
550
  ) {
327
- this.rows = initialRows;
551
+ const selectedSkillIds = new Set(initialRows.filter((row) => row.selected).map((row) => row.skillId));
552
+
553
+ this.loadedSkills = options?.loadedSkills
554
+ ?? initialRows
555
+ .filter((row) => row.category === "E")
556
+ .map((row) => ({
557
+ name: row.name,
558
+ displayName: row.displayName,
559
+ description: row.description,
560
+ path: row.path,
561
+ displayPath: row.displayPath,
562
+ }));
563
+
564
+ this.managedSkills = options?.managedSkills
565
+ ?? initialRows
566
+ .filter((row) => row.category !== "E" && row.scope)
567
+ .map((row) => ({
568
+ skillId: row.skillId,
569
+ scope: row.scope!,
570
+ fileName: path.basename(row.path),
571
+ path: row.path,
572
+ projectName: row.projectName,
573
+ name: row.name,
574
+ displayName: row.displayName,
575
+ description: row.description,
576
+ }));
577
+
578
+ this.rows = buildUnifiedSkillRows(this.managedSkills, this.loadedSkills, selectedSkillIds);
328
579
  this.syncSearchFocus();
329
580
  }
330
581
 
@@ -333,7 +584,8 @@ export class SkillsManagerModal implements Focusable {
333
584
  }
334
585
 
335
586
  private get filteredRows(): SkillModalRow[] {
336
- return filterSkillRows(this.rows, this.query);
587
+ const categoryFiltered = this.rows.filter((row) => matchesCategoryFilter(row, this.activeFilters));
588
+ return filterSkillRows(categoryFiltered, this.query);
337
589
  }
338
590
 
339
591
  private getCurrentRow(): SkillModalRow | null {
@@ -342,10 +594,22 @@ export class SkillsManagerModal implements Focusable {
342
594
  return rows[Math.min(this.selectedIndex, rows.length - 1)] ?? null;
343
595
  }
344
596
 
597
+ private getSelectedRows(): SkillModalRow[] {
598
+ return this.rows.filter((row) => row.selected);
599
+ }
600
+
345
601
  private getSelectedIds(): string[] {
346
602
  return getSelectedSkillIds(this.rows);
347
603
  }
348
604
 
605
+ private getFilterOptions(): Array<{ key: keyof SkillCategoryFilters; label: string }> {
606
+ return [
607
+ { key: "global", label: "Global [G]" },
608
+ { key: "project", label: "Project [P]" },
609
+ { key: "external", label: "External [E] (read-only)" },
610
+ ];
611
+ }
612
+
349
613
  private syncSearchFocus(): void {
350
614
  this.searchInput.focused = this.focused && this.focusArea === "search";
351
615
  }
@@ -360,14 +624,15 @@ export class SkillsManagerModal implements Focusable {
360
624
  }
361
625
  }
362
626
 
363
- private setFocusArea(area: "search" | "list"): void {
627
+ private setFocusArea(area: "search" | "list" | "filters"): void {
364
628
  this.focusArea = area;
365
629
  this.syncSearchFocus();
366
630
  this.tui.requestRender();
367
631
  }
368
632
 
369
- private setRows(skills: SkillIndex[], retainSelectedSkillIds: string[] = [], focusSkillId?: string): void {
370
- this.rows = buildSkillRows(skills, new Set(retainSelectedSkillIds));
633
+ private setRows(managedSkills: SkillIndex[], retainSelectedSkillIds: string[] = [], focusSkillId?: string): void {
634
+ this.managedSkills = managedSkills;
635
+ this.rows = buildUnifiedSkillRows(this.managedSkills, this.loadedSkills, new Set(retainSelectedSkillIds));
371
636
  this.syncQueryFromInput();
372
637
 
373
638
  const rows = this.filteredRows;
@@ -422,28 +687,84 @@ export class SkillsManagerModal implements Focusable {
422
687
  this.tui.requestRender();
423
688
  }
424
689
 
425
- private async runMove(targetScope: SkillScope): Promise<void> {
426
- const selectedIds = this.getSelectedIds();
427
- await this.runAsyncAction(this.callbacks.moveSelected(targetScope, selectedIds));
690
+ private appendExternalReadOnlySummary(
691
+ result: SkillBatchActionResult,
692
+ blockedExternalRows: SkillModalRow[],
693
+ verb: "move" | "delete",
694
+ ): SkillBatchActionResult {
695
+ if (blockedExternalRows.length === 0) return result;
696
+
697
+ const blockedIds = blockedExternalRows.map((row) => row.skillId);
698
+ const retainSet = new Set([...(result.retainSelectedSkillIds || []), ...blockedIds]);
699
+ const focusSkillId = result.focusSkillId || blockedIds[0];
700
+ const blockedLabel = blockedExternalRows.length === 1
701
+ ? `Blocked 1 external skill: ${blockedExternalRows[0]!.displayName} is read-only.`
702
+ : `Blocked ${blockedExternalRows.length} external skills: read-only (${verb} unavailable).`;
703
+
704
+ return {
705
+ ...result,
706
+ summaryLines: [...result.summaryLines, blockedLabel],
707
+ retainSelectedSkillIds: Array.from(retainSet),
708
+ focusSkillId,
709
+ };
428
710
  }
429
711
 
430
- private promptDelete(): void {
431
- const selectedIds = this.getSelectedIds();
432
- if (selectedIds.length === 0) {
712
+ private prepareMutableSelection(verb: "move" | "delete"):
713
+ | { proceed: false }
714
+ | { proceed: true; mutableIds: string[]; blockedExternalRows: SkillModalRow[] } {
715
+ const selectedRows = this.getSelectedRows();
716
+ if (selectedRows.length === 0) {
433
717
  this.summaryLines = ["Select one or more skills first."];
434
718
  this.tui.requestRender();
435
- return;
719
+ return { proceed: false };
720
+ }
721
+
722
+ const mutableRows = selectedRows.filter((row) => row.mutable);
723
+ const blockedExternalRows = selectedRows.filter((row) => !row.mutable);
724
+
725
+ if (mutableRows.length === 0 && blockedExternalRows.length > 0) {
726
+ this.summaryLines = [
727
+ `Blocked ${blockedExternalRows.length} external skill${blockedExternalRows.length === 1 ? "" : "s"}: read-only (${verb} unavailable).`,
728
+ ];
729
+ this.tui.requestRender();
730
+ return { proceed: false };
436
731
  }
437
732
 
438
- this.pendingDeleteConfirm = { skillIds: selectedIds };
733
+ return {
734
+ proceed: true,
735
+ mutableIds: mutableRows.map((row) => row.skillId),
736
+ blockedExternalRows,
737
+ };
738
+ }
739
+
740
+ private async runMove(targetScope: SkillScope): Promise<void> {
741
+ const selection = this.prepareMutableSelection("move");
742
+ if (!selection.proceed) return;
743
+
744
+ const action = this.callbacks.moveSelected(targetScope, selection.mutableIds)
745
+ .then((result) => this.appendExternalReadOnlySummary(result, selection.blockedExternalRows, "move"));
746
+
747
+ await this.runAsyncAction(action);
748
+ }
749
+
750
+ private promptDelete(): void {
751
+ const selection = this.prepareMutableSelection("delete");
752
+ if (!selection.proceed) return;
753
+
754
+ this.pendingDeleteConfirm = { skillIds: selection.mutableIds };
755
+ const blockedCount = selection.blockedExternalRows.length;
439
756
  this.summaryLines = [
440
- `Delete ${selectedIds.length} selected skill${selectedIds.length === 1 ? "" : "s"}? Press y to confirm or n to cancel.`,
757
+ `Delete ${selection.mutableIds.length} selected skill${selection.mutableIds.length === 1 ? "" : "s"}? Press y to confirm or n to cancel.${blockedCount > 0 ? ` (${blockedCount} external read-only item${blockedCount === 1 ? "" : "s"} will be skipped)` : ""}`,
441
758
  ];
442
759
  this.tui.requestRender();
443
760
  }
444
761
 
445
762
  private async runDeleteConfirmed(skillIds: string[]): Promise<void> {
446
- await this.runAsyncAction(this.callbacks.deleteSelected(skillIds));
763
+ const blockedExternalRows = this.rows.filter((row) => row.selected && !row.mutable);
764
+ const action = this.callbacks.deleteSelected(skillIds)
765
+ .then((result) => this.appendExternalReadOnlySummary(result, blockedExternalRows, "delete"));
766
+
767
+ await this.runAsyncAction(action);
447
768
  }
448
769
 
449
770
  private closeModal(): void {
@@ -452,6 +773,72 @@ export class SkillsManagerModal implements Focusable {
452
773
  this.callbacks.close();
453
774
  }
454
775
 
776
+ private openFilterPanel(): void {
777
+ this.pendingFilters = cloneFilters(this.activeFilters);
778
+ this.filterCursor = 0;
779
+ this.setFocusArea("filters");
780
+ this.summaryLines = ["Filter panel open: space toggle · enter apply · esc cancel."];
781
+ this.tui.requestRender();
782
+ }
783
+
784
+ private applyFilterPanel(): void {
785
+ const candidate = ensureValidFilters(this.pendingFilters ? cloneFilters(this.pendingFilters) : cloneFilters(this.activeFilters));
786
+ const wasAllOff = this.pendingFilters
787
+ && !this.pendingFilters.global
788
+ && !this.pendingFilters.project
789
+ && !this.pendingFilters.external;
790
+
791
+ this.activeFilters = candidate;
792
+ this.pendingFilters = null;
793
+ this.syncQueryFromInput();
794
+ this.setFocusArea("list");
795
+ this.summaryLines = [
796
+ wasAllOff
797
+ ? "All categories were disabled; restored filters to [G] [P] [E]."
798
+ : `Applied filters: ${filtersLabel(this.activeFilters)}`,
799
+ ];
800
+ this.tui.requestRender();
801
+ }
802
+
803
+ private cancelFilterPanel(): void {
804
+ this.pendingFilters = null;
805
+ this.setFocusArea("list");
806
+ this.summaryLines = ["Filter changes cancelled."];
807
+ this.tui.requestRender();
808
+ }
809
+
810
+ private handleFilterInput(data: string): void {
811
+ const options = this.getFilterOptions();
812
+ const draft = this.pendingFilters ?? cloneFilters(this.activeFilters);
813
+ this.pendingFilters = draft;
814
+
815
+ if (matchesKey(data, Key.escape)) {
816
+ this.cancelFilterPanel();
817
+ return;
818
+ }
819
+ if (matchesKey(data, Key.up)) {
820
+ this.filterCursor = Math.max(0, this.filterCursor - 1);
821
+ this.tui.requestRender();
822
+ return;
823
+ }
824
+ if (matchesKey(data, Key.down)) {
825
+ this.filterCursor = Math.min(options.length - 1, this.filterCursor + 1);
826
+ this.tui.requestRender();
827
+ return;
828
+ }
829
+ if (matchesKey(data, Key.space)) {
830
+ const option = options[this.filterCursor];
831
+ if (option) {
832
+ draft[option.key] = !draft[option.key];
833
+ }
834
+ this.tui.requestRender();
835
+ return;
836
+ }
837
+ if (matchesKey(data, Key.enter)) {
838
+ this.applyFilterPanel();
839
+ }
840
+ }
841
+
455
842
  private async runAsyncAction(action: Promise<SkillBatchActionResult>): Promise<void> {
456
843
  if (this.closed) return;
457
844
 
@@ -490,7 +877,7 @@ export class SkillsManagerModal implements Focusable {
490
877
  }
491
878
 
492
879
  private getMaxVisibleRows(): number {
493
- return Math.max(6, Math.min(14, this.tui.terminal.rows - 18));
880
+ return Math.max(6, Math.min(14, this.tui.terminal.rows - 22));
494
881
  }
495
882
 
496
883
  private focusSearchWithOptionalInput(data?: string): void {
@@ -530,6 +917,11 @@ export class SkillsManagerModal implements Focusable {
530
917
  return;
531
918
  }
532
919
 
920
+ if (this.focusArea === "filters") {
921
+ this.handleFilterInput(data);
922
+ return;
923
+ }
924
+
533
925
  if (matchesKey(data, Key.escape)) {
534
926
  this.closeModal();
535
927
  return;
@@ -547,6 +939,11 @@ export class SkillsManagerModal implements Focusable {
547
939
  return;
548
940
  }
549
941
 
942
+ if (data === MEMORY_SKILLS_KEYMAP.openFilters) {
943
+ this.openFilterPanel();
944
+ return;
945
+ }
946
+
550
947
  if (matchesKey(data, Key.tab) || matchesKey(data, Key.slash)) {
551
948
  this.focusSearchWithOptionalInput();
552
949
  return;
@@ -601,7 +998,7 @@ export class SkillsManagerModal implements Focusable {
601
998
  this.promptDelete();
602
999
  return;
603
1000
  }
604
- if (this.isPrintableInput(data) && !["g", "p", "d", "a", "n"].includes(data)) {
1001
+ if (this.isPrintableInput(data) && !["g", "p", "d", "a", "n", "f"].includes(data)) {
605
1002
  this.focusSearchWithOptionalInput(data);
606
1003
  }
607
1004
  }
@@ -629,6 +1026,35 @@ export class SkillsManagerModal implements Focusable {
629
1026
  return rendered;
630
1027
  }
631
1028
 
1029
+ private renderFilterPanel(width: number): string[] {
1030
+ const panelWidth = Math.max(34, Math.min(width - 10, 58));
1031
+ const top = this.theme.fg("borderAccent", `┌${"─".repeat(Math.max(1, panelWidth - 2))}┐`);
1032
+ const bottom = this.theme.fg("borderAccent", `└${"─".repeat(Math.max(1, panelWidth - 2))}┘`);
1033
+ const lines: string[] = [top];
1034
+
1035
+ lines.push(this.renderFramedLine(this.theme.fg("accent", this.theme.bold("Filters")), panelWidth));
1036
+ lines.push(this.renderFramedLine(this.theme.fg("dim", "Space toggle · Enter apply · Esc cancel"), panelWidth));
1037
+ lines.push(this.renderFramedLine("", panelWidth));
1038
+
1039
+ const draft = this.pendingFilters ?? this.activeFilters;
1040
+ const options = this.getFilterOptions();
1041
+ for (let i = 0; i < options.length; i++) {
1042
+ const option = options[i]!;
1043
+ const checked = draft[option.key] ? "[x]" : "[ ]";
1044
+ const cursor = i === this.filterCursor ? this.theme.fg("accent", "›") : " ";
1045
+ const text = `${cursor} ${checked} ${option.label}`;
1046
+ const rendered = i === this.filterCursor
1047
+ ? this.theme.bg("selectedBg", truncateToWidth(text, Math.max(10, panelWidth - 4), ""))
1048
+ : truncateToWidth(text, Math.max(10, panelWidth - 4), "");
1049
+ lines.push(this.renderFramedLine(rendered, panelWidth));
1050
+ }
1051
+
1052
+ lines.push(this.renderFramedLine("", panelWidth));
1053
+ lines.push(this.renderFramedLine(this.theme.fg("dim", `Draft: ${filtersLabel(draft)}`), panelWidth));
1054
+ lines.push(bottom);
1055
+ return lines;
1056
+ }
1057
+
632
1058
  render(width: number): string[] {
633
1059
  const safeWidth = Math.max(60, width);
634
1060
  const top = this.theme.fg("borderAccent", `┌${"─".repeat(Math.max(1, safeWidth - 2))}┐`);
@@ -655,10 +1081,11 @@ export class SkillsManagerModal implements Focusable {
655
1081
  safeWidth,
656
1082
  ));
657
1083
 
1084
+ lines.push(this.renderFramedLine(this.theme.fg("dim", `Legend: [G] global · [P] project · [E] external (read-only) · filters: ${filtersLabel(this.activeFilters)}`), safeWidth));
658
1085
  lines.push(this.renderFramedLine("", safeWidth));
659
1086
 
660
1087
  if (filteredRows.length === 0) {
661
- const emptyMessage = this.rows.length === 0 ? "No skills found yet." : "No skills match the current search.";
1088
+ const emptyMessage = this.rows.length === 0 ? "No skills found yet." : "No skills match the current filters/search.";
662
1089
  lines.push(this.renderFramedLine(this.theme.fg("warning", emptyMessage), safeWidth));
663
1090
  lines.push(this.renderFramedLine("", safeWidth));
664
1091
  } else {
@@ -672,10 +1099,13 @@ export class SkillsManagerModal implements Focusable {
672
1099
  const absoluteIndex = start + i;
673
1100
  const cursor = absoluteIndex === this.selectedIndex ? this.theme.fg("accent", "›") : " ";
674
1101
  const check = row.selected ? this.theme.fg("accent", "[x]") : this.theme.fg("dim", "[ ]");
675
- const scope = row.scope === "global"
1102
+ const category = row.category === "G"
676
1103
  ? this.theme.fg("accent", "[G]")
677
- : this.theme.fg("warning", "[P]");
678
- const baseText = `${cursor} ${check} ${scope} ${row.displayName}`;
1104
+ : row.category === "P"
1105
+ ? this.theme.fg("warning", "[P]")
1106
+ : this.theme.fg("dim", "[E]");
1107
+
1108
+ const baseText = `${cursor} ${check} ${category} ${row.displayName} (${row.displayPath})`;
679
1109
  const lineText = absoluteIndex === this.selectedIndex
680
1110
  ? this.theme.bg("selectedBg", truncateToWidth(baseText, Math.max(10, safeWidth - 4), ""))
681
1111
  : truncateToWidth(baseText, Math.max(10, safeWidth - 4), "");
@@ -689,10 +1119,16 @@ export class SkillsManagerModal implements Focusable {
689
1119
  lines.push(this.renderFramedLine("", safeWidth));
690
1120
  const currentRow = this.getCurrentRow();
691
1121
  if (currentRow) {
692
- lines.push(this.renderFramedLine(this.theme.fg("accent", `Focused: ${currentRow.displayName}`), safeWidth));
1122
+ const scopeLabel = currentRow.category === "E"
1123
+ ? "external (read-only)"
1124
+ : currentRow.scope === "project"
1125
+ ? "project"
1126
+ : "global";
1127
+ lines.push(this.renderFramedLine(this.theme.fg("accent", `Focused: ${currentRow.displayName} · ${scopeLabel}`), safeWidth));
693
1128
  lines.push(...this.renderWrappedSection([
694
1129
  currentRow.description || "(no description)",
695
1130
  this.theme.fg("dim", currentRow.skillId),
1131
+ this.theme.fg("dim", currentRow.displayPath),
696
1132
  ], safeWidth));
697
1133
  }
698
1134
  }
@@ -705,9 +1141,17 @@ export class SkillsManagerModal implements Focusable {
705
1141
  const help = this.pendingDeleteConfirm
706
1142
  ? "Confirm delete: y yes · n no · esc cancel"
707
1143
  : 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";
1144
+ ? "↑↓ move · space select · / search · f filters · tab switch · g global · p project · d delete · a all · n none · esc close"
1145
+ : "↑↓ move · space select · / search · f filters · tab switch · g global · p project (disabled) · d delete · a all · n none · esc close";
710
1146
  lines.push(this.renderFramedLine(this.theme.fg("dim", help), safeWidth));
1147
+
1148
+ if (this.focusArea === "filters") {
1149
+ lines.push(this.renderFramedLine("", safeWidth));
1150
+ for (const panelLine of this.renderFilterPanel(Math.min(64, safeWidth - 6))) {
1151
+ lines.push(this.renderFramedLine(panelLine, safeWidth));
1152
+ }
1153
+ }
1154
+
711
1155
  lines.push(bottom);
712
1156
  return lines;
713
1157
  }
@@ -715,18 +1159,35 @@ export class SkillsManagerModal implements Focusable {
715
1159
 
716
1160
  export function registerSkillsCommand(pi: ExtensionAPI, store: SkillStore): void {
717
1161
  pi.registerCommand("memory-skills", {
718
- description: "Manage global and active-project procedural skills",
1162
+ description: "Manage global, active-project, and loaded external procedural skills",
719
1163
  handler: async (_args, ctx: ExtensionCommandContext) => {
720
- const skills = await store.loadIndex();
1164
+ const getSkillCommands = (): SkillCommandInfo[] => {
1165
+ const readCommands = (owner: unknown): SkillCommandInfo[] | null => {
1166
+ try {
1167
+ const getter = (owner as { getCommands?: () => unknown })?.getCommands;
1168
+ if (typeof getter !== "function") return null;
1169
+ const commands = getter.call(owner);
1170
+ return Array.isArray(commands) ? commands as SkillCommandInfo[] : [];
1171
+ } catch {
1172
+ return null;
1173
+ }
1174
+ };
1175
+
1176
+ return readCommands(pi)
1177
+ ?? readCommands(ctx)
1178
+ ?? [];
1179
+ };
1180
+
1181
+ const managedSkills = await store.loadIndex();
1182
+ const loadedSkills = collectLoadedSkillsFromCommands(getSkillCommands());
1183
+ const initialRows = buildUnifiedSkillRows(managedSkills, loadedSkills);
721
1184
  const projectName = store.getProjectName();
722
1185
 
723
1186
  if (!ctx.hasUI || typeof ctx.ui.custom !== "function") {
724
- ctx.ui.notify(formatSkillsList(skills, projectName), "info");
1187
+ ctx.ui.notify(formatSkillsList(initialRows, projectName), "info");
725
1188
  return;
726
1189
  }
727
1190
 
728
- const initialRows = buildSkillRows(skills);
729
-
730
1191
  try {
731
1192
  await ctx.ui.custom<void>(
732
1193
  (tui, theme, _keybindings, done) => new SkillsManagerModal(
@@ -739,25 +1200,33 @@ export function registerSkillsCommand(pi: ExtensionAPI, store: SkillStore): void
739
1200
  close: () => done(undefined),
740
1201
  projectName,
741
1202
  },
1203
+ {
1204
+ managedSkills,
1205
+ loadedSkills,
1206
+ },
742
1207
  ),
743
1208
  {
744
1209
  overlay: true,
745
1210
  overlayOptions: {
746
1211
  anchor: "center",
747
- width: "88%",
748
- minWidth: 72,
749
- maxHeight: "85%",
1212
+ width: "92%",
1213
+ minWidth: 76,
1214
+ maxHeight: "88%",
750
1215
  margin: 1,
751
1216
  },
752
1217
  },
753
1218
  );
754
1219
  } catch {
755
- const latestSkills = await store.loadIndex();
1220
+ const latestManagedSkills = await store.loadIndex();
1221
+ const latestRows = buildUnifiedSkillRows(
1222
+ latestManagedSkills,
1223
+ collectLoadedSkillsFromCommands(getSkillCommands()),
1224
+ );
756
1225
  ctx.ui.notify(
757
1226
  "Interactive skills manager unavailable in this runtime; showing read-only list fallback.",
758
1227
  "warning",
759
1228
  );
760
- ctx.ui.notify(formatSkillsList(latestSkills, projectName), "info");
1229
+ ctx.ui.notify(formatSkillsList(latestRows, projectName), "info");
761
1230
  }
762
1231
  },
763
1232
  });