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 +32 -22
- package/package.json +2 -1
- package/src/handlers/skills-command.ts +748 -42
- package/src/index.ts +6 -18
- package/src/store/skill-store.ts +137 -0
- package/src/types.ts +1 -1
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` |
|
|
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`
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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
|
|
475
|
-
- **
|
|
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.
|
|
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
|
|
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
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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,
|
|
182
|
+
pi.on("session_shutdown", async (_event, ctx) => {
|
|
183
183
|
try {
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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 {
|
package/src/store/skill-store.ts
CHANGED
|
@@ -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
|
}
|