@groeponline/pi-wishcraft 1.4.12 → 1.4.14
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/CHANGELOG.md +24 -0
- package/package.json +1 -1
- package/src/studio/component.ts +211 -57
- package/src/studio/deepwiki/cache.ts +80 -6
- package/src/studio/open.ts +205 -11
package/CHANGELOG.md
CHANGED
|
@@ -2,8 +2,32 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [1.4.14] - 2026-09-05
|
|
6
|
+
|
|
7
|
+
## [1.4.13] - 2026-09-05
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- Skill Studio panes wired end-to-end (`/studio`): the fullscreen workbench now connects the registry-backed list, a detail pane (health, category override, usage count, frontmatter keys, model-invocation flag, and resolved local references with ✓/✗), actions (create from template, edit in-app with cache invalidation, doctor), and AI advice with explain/integrate/examples/improve modes — streamed through the advise engine with DeepWiki wiki context and insert-into-editor. The fail-closed placeholder gate is lifted; the entrypoint keeps its guards (closed without UI, warning + closed in RPC mode).
|
|
11
|
+
- DeepWiki disk cache gains bounded LRU eviction (`maxEntries`, default 64) on top of the existing TTL + stale-fallback behavior.
|
|
12
|
+
- Tests: studio pane render, key routing + refresh, entrypoint guards, and LRU eviction covered; 723 passing.
|
|
13
|
+
|
|
5
14
|
## [1.4.12] - 2026-09-05
|
|
6
15
|
|
|
16
|
+
### Added
|
|
17
|
+
- Skill Studio: browse the ChefGroep global skill registry alongside local skills (`loadSkillStudioCatalog`): registry-only skills surface with routing metadata and a "registry source not readable" warning when the file is unavailable on this host.
|
|
18
|
+
- Motion rail: impressive adaptive rail with motion-driven routing (#83).
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
- AGENTS.md rewritten to the current architecture (#80).
|
|
22
|
+
- Compound Engineering overlay example clarified: explainer archival and sweep state paths now reference `<docs_root>` (#68); devDependency `@earendil-works/pi-coding-agent` bumped to 0.84.3.
|
|
23
|
+
|
|
24
|
+
### Removed
|
|
25
|
+
- Dead agent-tools (patch, ripgrep) with no runtime consumers (#81).
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
- Rail/render fixes shipped with the studio registry work (#79).
|
|
29
|
+
- Tests: `session_compact_failed` regression coverage — clearing compacting state, settling the signal, and blocking post-compact queue items with the error (#68).
|
|
30
|
+
|
|
7
31
|
## [1.4.11] - 2026-08-30
|
|
8
32
|
|
|
9
33
|
## [1.4.10] - 2026-08-30
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@groeponline/pi-wishcraft",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.14",
|
|
4
4
|
"description": "Operator cockpit for Pi: live powerline status, searchable skills, idea queue, sticky Bash, hooks, policy controls, and session UX.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
package/src/studio/component.ts
CHANGED
|
@@ -1,21 +1,24 @@
|
|
|
1
|
-
/**
|
|
2
|
-
*
|
|
3
|
-
* the editor-replacing variant, unlike the Deck's centered overlay. Panes are
|
|
4
|
-
* placeholders here; U6-U10 fill list/detail/actions/advice content.
|
|
5
|
-
*/
|
|
1
|
+
/** Fullscreen Skill Studio workbench. The component owns presentation and input;
|
|
2
|
+
* the existing skills modules remain the only discovery and mutation backend. */
|
|
6
3
|
|
|
7
4
|
import { matchesKey } from "@earendil-works/pi-tui";
|
|
8
5
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import {
|
|
7
|
+
buildListRows,
|
|
8
|
+
filterListRows,
|
|
9
|
+
} from "./list.ts";
|
|
10
|
+
import { resolveReferences, type ResolvedReference } from "./inspect.ts";
|
|
11
|
+
import { readSkillBody, type SkillEntry, type SkillUsage } from "../extension/skills/skill-registry.ts";
|
|
12
|
+
import type { AdvicePane } from "./advice-pane.ts";
|
|
13
|
+
import type { AdviseMode } from "./advise/prompts.ts";
|
|
9
14
|
import {
|
|
10
15
|
createStudioState,
|
|
11
16
|
handleStudioKey,
|
|
12
17
|
STUDIO_PANES,
|
|
13
18
|
} from "./state.ts";
|
|
14
|
-
import type { StudioKeyEvent, StudioState } from "./types.ts";
|
|
15
|
-
import type { SkillEntry } from "../extension/skills/skill-registry.ts";
|
|
16
|
-
import { buildListRows, filterListRows } from "./list.ts";
|
|
19
|
+
import type { StudioKeyEvent, StudioPaneId, StudioState } from "./types.ts";
|
|
17
20
|
|
|
18
|
-
const PANE_LABELS: Record<
|
|
21
|
+
const PANE_LABELS: Record<StudioPaneId, string> = {
|
|
19
22
|
list: "Skills",
|
|
20
23
|
detail: "Detail",
|
|
21
24
|
actions: "Actions",
|
|
@@ -27,11 +30,31 @@ const HELP_LINES: readonly string[] = [
|
|
|
27
30
|
"",
|
|
28
31
|
" j / k or arrows Move selection",
|
|
29
32
|
" / Filter skills",
|
|
30
|
-
" Tab
|
|
33
|
+
" Tab or 1-4 Focus a pane",
|
|
34
|
+
" Enter Open the selected skill",
|
|
35
|
+
" n Create a skill",
|
|
36
|
+
" e Edit the selected skill",
|
|
37
|
+
" d Run doctor",
|
|
38
|
+
" a Focus AI advice",
|
|
39
|
+
" r Run advice · i insert answer",
|
|
31
40
|
" ? Toggle this help",
|
|
32
41
|
" q / Esc Exit studio",
|
|
33
42
|
];
|
|
34
43
|
|
|
44
|
+
export interface StudioComponentOptions {
|
|
45
|
+
entries?: readonly SkillEntry[];
|
|
46
|
+
usage?: ReadonlyMap<string, SkillUsage>;
|
|
47
|
+
advicePane?: AdvicePane;
|
|
48
|
+
adviceMode?: AdviseMode;
|
|
49
|
+
onRefresh?: () => readonly SkillEntry[];
|
|
50
|
+
onCreate?: () => Promise<void> | void;
|
|
51
|
+
onEdit?: (entry: SkillEntry) => Promise<void> | void;
|
|
52
|
+
onDoctor?: () => Promise<void> | void;
|
|
53
|
+
onAdvice?: (entry: SkillEntry, mode: AdviseMode, pane: AdvicePane) => Promise<void> | void;
|
|
54
|
+
onInsert?: (pane: AdvicePane) => Promise<void> | void;
|
|
55
|
+
onError?: (error: unknown) => void;
|
|
56
|
+
}
|
|
57
|
+
|
|
35
58
|
export function mapRawInput(data: string): StudioKeyEvent {
|
|
36
59
|
if (matchesKey(data, "escape")) return { key: "escape" };
|
|
37
60
|
if (matchesKey(data, "return")) return { key: "return" };
|
|
@@ -47,55 +70,132 @@ export function mapRawInput(data: string): StudioKeyEvent {
|
|
|
47
70
|
return { key: "other" };
|
|
48
71
|
}
|
|
49
72
|
|
|
50
|
-
|
|
73
|
+
function fit(text: string, width: number): string {
|
|
74
|
+
if (width <= 0) return "";
|
|
75
|
+
return text.length <= width ? text : `${text.slice(0, Math.max(0, width - 1))}…`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function safeReadReferences(entry: SkillEntry): ResolvedReference[] {
|
|
79
|
+
return resolveReferences(readSkillBody(entry.filePath), entry.baseDir);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function selectedEntry(
|
|
83
|
+
entries: readonly SkillEntry[],
|
|
84
|
+
query: string,
|
|
85
|
+
selectedIndex: number,
|
|
86
|
+
): SkillEntry | null {
|
|
87
|
+
const rows = filterListRows(buildListRows(entries), query);
|
|
88
|
+
const row = rows[selectedIndex] ?? rows[0];
|
|
89
|
+
return entries.find((entry) => entry.filePath === row?.filePath) ?? null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function renderHelp(theme: Theme): string[] {
|
|
93
|
+
return HELP_LINES.map((line, index) => {
|
|
94
|
+
if (index === 0) return theme.fg("accent", line);
|
|
95
|
+
return theme.fg(index === HELP_LINES.length - 1 ? "dim" : "muted", line);
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function renderList(
|
|
51
100
|
theme: Theme,
|
|
52
101
|
width: number,
|
|
102
|
+
entries: readonly SkillEntry[],
|
|
53
103
|
state: StudioState,
|
|
54
|
-
entries: readonly SkillEntry[] = [],
|
|
55
104
|
): string[] {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
105
|
+
const rows = filterListRows(buildListRows(entries), state.filterQuery);
|
|
106
|
+
const visible = rows.slice(Math.max(0, state.selectedIndex - 5), state.selectedIndex + 7);
|
|
107
|
+
const lines = [theme.fg("accent", `SKILLS · ${rows.length}/${entries.length}`)];
|
|
108
|
+
if (state.filterQuery) lines.push(theme.fg("muted", `filter: ${state.filterQuery}`));
|
|
109
|
+
if (rows.length === 0) {
|
|
110
|
+
lines.push(theme.fg("warning", "No skills match this filter"));
|
|
60
111
|
return lines;
|
|
61
112
|
}
|
|
113
|
+
for (const row of visible) {
|
|
114
|
+
const absolute = rows.indexOf(row);
|
|
115
|
+
const marker = absolute === state.selectedIndex ? "›" : " ";
|
|
116
|
+
const warning = row.warning ? theme.fg("warning", " ⚠") : "";
|
|
117
|
+
lines.push(
|
|
118
|
+
`${theme.fg(absolute === state.selectedIndex ? "accent" : "text", `${marker} ${fit(row.name, Math.max(8, width - 18))}`)} ${theme.fg("dim", `[${row.badge}]`)}${warning}`,
|
|
119
|
+
);
|
|
120
|
+
if (absolute === state.selectedIndex && row.description) {
|
|
121
|
+
lines.push(theme.fg("muted", ` ${fit(row.description, Math.max(8, width - 4))}`));
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return lines;
|
|
125
|
+
}
|
|
62
126
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
const
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
127
|
+
function renderDetail(theme: Theme, width: number, entry: SkillEntry | null, usage: ReadonlyMap<string, SkillUsage>): string[] {
|
|
128
|
+
if (!entry) return [theme.fg("accent", "DETAIL"), theme.fg("dim", "Select a skill from the list")];
|
|
129
|
+
const body = readSkillBody(entry.filePath);
|
|
130
|
+
const refs = safeReadReferences(entry);
|
|
131
|
+
const use = usage.get(entry.name);
|
|
132
|
+
const health = entry.warning ? theme.fg("warning", "warn") : theme.fg("success", "ok");
|
|
133
|
+
const override = entry.category === "project" ? "project override" : entry.category === "global" ? "global default" : "standalone";
|
|
134
|
+
const lines = [
|
|
135
|
+
theme.fg("accent", `DETAIL · ${fit(entry.name, Math.max(8, width - 10))}`),
|
|
136
|
+
theme.fg("muted", fit(entry.description || "No description", width)),
|
|
137
|
+
theme.fg("dim", `health: ${health} · ${override} · used: ${use?.count ?? 0}×`),
|
|
138
|
+
theme.fg("dim", `file: ${fit(entry.filePath, Math.max(8, width - 6))}`),
|
|
139
|
+
theme.fg("dim", `frontmatter: ${entry.frontmatterKeys.join(", ") || "none"}`),
|
|
140
|
+
theme.fg("dim", `model invocation: ${entry.disableModelInvocation ? "off" : "on"}`),
|
|
141
|
+
theme.fg("accent", `REFERENCES · ${refs.length}`),
|
|
142
|
+
];
|
|
143
|
+
if (refs.length === 0) lines.push(theme.fg("dim", " none detected"));
|
|
144
|
+
for (const ref of refs.slice(0, 5)) {
|
|
145
|
+
lines.push(theme.fg(ref.exists ? "muted" : "warning", ` ${ref.exists ? "✓" : "✗"} ${fit(ref.href, Math.max(8, width - 6))}`));
|
|
146
|
+
}
|
|
147
|
+
return lines;
|
|
148
|
+
}
|
|
69
149
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
const text = `${mark} [${row.badge}] ${row.name}${route}${drift}`;
|
|
88
|
-
lines.push(absolute === selectedIndex ? theme.fg("accent", text) : theme.fg("muted", text));
|
|
89
|
-
}
|
|
90
|
-
lines.push(theme.fg("dim", "─".repeat(Math.max(1, Math.min(width - 1, 78)))));
|
|
91
|
-
lines.push(theme.fg("accent", selected.name));
|
|
92
|
-
lines.push(theme.fg("muted", selected.description || "No description"));
|
|
93
|
-
const ownership = [selected.role && `role=${selected.role}`, selected.routerParent && `parent=${selected.routerParent}`].filter(Boolean).join(" · ");
|
|
94
|
-
if (ownership) lines.push(theme.fg("dim", ownership));
|
|
95
|
-
lines.push(theme.fg("dim", selected.filePath));
|
|
96
|
-
if (selected.warning) lines.push(theme.fg("warning", `warning: ${selected.warning}`));
|
|
150
|
+
function adviceModeLabel(mode: AdviseMode): string {
|
|
151
|
+
return mode === "explain" ? "explain" : mode === "integrate" ? "integrate" : mode === "examples" ? "examples" : "improve";
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function renderAdvice(theme: Theme, width: number, entry: SkillEntry | null, pane: AdvicePane, mode: AdviseMode): string[] {
|
|
155
|
+
const lines = [
|
|
156
|
+
theme.fg("accent", `AI ADVICE · ${adviceModeLabel(mode)}`),
|
|
157
|
+
entry ? theme.fg("muted", `skill: ${entry.name}`) : theme.fg("dim", "Select a skill first"),
|
|
158
|
+
theme.fg("dim", "r run · e explain · g integrate · x examples · m improve · i insert"),
|
|
159
|
+
];
|
|
160
|
+
if (pane.state === "running") lines.push(theme.fg("accent", "Thinking…"));
|
|
161
|
+
if (pane.state === "unavailable") lines.push(theme.fg("warning", `Advice unavailable: ${pane.error ?? "unknown error"}`));
|
|
162
|
+
const text = pane.text.trim();
|
|
163
|
+
if (text) {
|
|
164
|
+
lines.push(theme.fg("text", fit(text.replace(/\n/g, " "), Math.max(8, width - 2))));
|
|
165
|
+
} else if (pane.state === "idle") {
|
|
166
|
+
lines.push(theme.fg("dim", "Advice uses the selected skill and its local references."));
|
|
97
167
|
}
|
|
98
|
-
lines
|
|
168
|
+
return lines;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function renderStudioFrame(
|
|
172
|
+
theme: Theme,
|
|
173
|
+
width: number,
|
|
174
|
+
state: StudioState,
|
|
175
|
+
options: Pick<StudioComponentOptions, "entries" | "usage" | "advicePane" | "adviceMode"> = {},
|
|
176
|
+
): string[] {
|
|
177
|
+
if (state.mode === "help") return renderHelp(theme);
|
|
178
|
+
const entries = options.entries ?? [];
|
|
179
|
+
const usage = options.usage ?? new Map<string, SkillUsage>();
|
|
180
|
+
const pane = options.advicePane ?? { state: "idle", text: "", error: null } as AdvicePane;
|
|
181
|
+
const entry = selectedEntry(entries, state.filterQuery, state.selectedIndex);
|
|
182
|
+
const mode: AdviseMode = options.adviceMode ?? "explain";
|
|
183
|
+
const innerWidth = Math.max(20, width - 2);
|
|
184
|
+
const lines = [
|
|
185
|
+
theme.fg("accent", "Skill Studio"),
|
|
186
|
+
STUDIO_PANES.map((paneId) => state.focus === paneId ? theme.fg("accent", `[${PANE_LABELS[paneId]}]`) : theme.fg("dim", ` ${PANE_LABELS[paneId]} `)).join(" "),
|
|
187
|
+
theme.fg("dim", "─".repeat(Math.max(1, Math.min(innerWidth, 72)))),
|
|
188
|
+
];
|
|
189
|
+
lines.push(...renderList(theme, innerWidth, entries, state));
|
|
190
|
+
lines.push(theme.fg("dim", "─".repeat(Math.max(1, Math.min(innerWidth, 72)))));
|
|
191
|
+
lines.push(...renderDetail(theme, innerWidth, entry, usage));
|
|
192
|
+
lines.push(theme.fg("dim", "─".repeat(Math.max(1, Math.min(innerWidth, 72)))));
|
|
193
|
+
lines.push(theme.fg("accent", "ACTIONS"));
|
|
194
|
+
lines.push(theme.fg("muted", "n create · e edit · d doctor · Enter detail"));
|
|
195
|
+
lines.push(theme.fg("dim", "─".repeat(Math.max(1, Math.min(innerWidth, 72)))));
|
|
196
|
+
lines.push(...renderAdvice(theme, innerWidth, entry, pane, mode));
|
|
197
|
+
lines.push(theme.fg("dim", "─".repeat(Math.max(1, Math.min(innerWidth, 72)))));
|
|
198
|
+
lines.push(theme.fg("dim", "q/Esc exit · / filter · Tab focus · ? help"));
|
|
99
199
|
return lines;
|
|
100
200
|
}
|
|
101
201
|
|
|
@@ -103,23 +203,77 @@ export function createStudioComponent(
|
|
|
103
203
|
theme: Theme,
|
|
104
204
|
done: (value: string | null) => void,
|
|
105
205
|
onStateChange?: (state: StudioState) => void,
|
|
106
|
-
|
|
206
|
+
options: StudioComponentOptions = {},
|
|
107
207
|
) {
|
|
108
208
|
let state = createStudioState();
|
|
209
|
+
let entries = [...(options.entries ?? [])];
|
|
210
|
+
let adviceMode: AdviseMode = "explain";
|
|
211
|
+
let finished = false;
|
|
212
|
+
const pane = options.advicePane ?? {
|
|
213
|
+
state: "idle",
|
|
214
|
+
text: "",
|
|
215
|
+
error: null,
|
|
216
|
+
run: async () => {},
|
|
217
|
+
reset: () => {},
|
|
218
|
+
} as AdvicePane;
|
|
219
|
+
|
|
220
|
+
const refresh = () => {
|
|
221
|
+
const next = options.onRefresh?.();
|
|
222
|
+
if (next) entries = [...next];
|
|
223
|
+
};
|
|
224
|
+
const current = () => selectedEntry(entries, state.filterQuery, state.selectedIndex);
|
|
225
|
+
const notifyChange = (next: StudioState) => {
|
|
226
|
+
state = next;
|
|
227
|
+
onStateChange?.(state);
|
|
228
|
+
};
|
|
229
|
+
const invoke = (work: () => Promise<void> | void) => {
|
|
230
|
+
void Promise.resolve(work()).then(() => {
|
|
231
|
+
refresh();
|
|
232
|
+
}).catch((error: unknown) => options.onError?.(error));
|
|
233
|
+
};
|
|
234
|
+
const focus = (paneId: StudioPaneId) => notifyChange({ ...state, focus: paneId });
|
|
235
|
+
const runAdvice = () => {
|
|
236
|
+
const entry = current();
|
|
237
|
+
if (!entry || !options.onAdvice) return;
|
|
238
|
+
invoke(() => options.onAdvice!(entry, adviceMode, pane));
|
|
239
|
+
};
|
|
109
240
|
|
|
110
241
|
return {
|
|
111
242
|
focused: true,
|
|
112
243
|
invalidate() {},
|
|
113
244
|
render(width: number) {
|
|
114
|
-
return renderStudioFrame(theme, width, state, entries);
|
|
245
|
+
return renderStudioFrame(theme, width, state, { entries, usage: options.usage, advicePane: pane, adviceMode });
|
|
115
246
|
},
|
|
116
247
|
handleInput(data: string) {
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
248
|
+
if (state.mode === "normal") {
|
|
249
|
+
if (data >= "1" && data <= "4") {
|
|
250
|
+
focus(STUDIO_PANES[Number(data) - 1] ?? "list");
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
if (data === "\r" || matchesKey(data, "return")) {
|
|
254
|
+
if (state.focus === "list") focus("detail");
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
if (state.focus === "advice") {
|
|
258
|
+
if (data === "e") adviceMode = "explain";
|
|
259
|
+
else if (data === "g") adviceMode = "integrate";
|
|
260
|
+
else if (data === "x") adviceMode = "examples";
|
|
261
|
+
else if (data === "m") adviceMode = "improve";
|
|
262
|
+
else if (data === "r") runAdvice();
|
|
263
|
+
else if (data === "i" && options.onInsert) invoke(() => options.onInsert!(pane));
|
|
264
|
+
} else if (state.focus === "actions" || state.focus === "detail" || state.focus === "list") {
|
|
265
|
+
if (data === "n" && options.onCreate) invoke(() => options.onCreate!());
|
|
266
|
+
else if (data === "e" && current() && options.onEdit) invoke(() => options.onEdit!(current()!));
|
|
267
|
+
else if (data === "d" && options.onDoctor) invoke(() => options.onDoctor!());
|
|
268
|
+
else if (data === "a") focus("advice");
|
|
269
|
+
else if (data === "i" && pane.state === "ok" && options.onInsert) invoke(() => options.onInsert!(pane));
|
|
270
|
+
else if (state.focus === "detail" && data === "r") runAdvice();
|
|
271
|
+
}
|
|
121
272
|
}
|
|
122
|
-
|
|
273
|
+
const next = handleStudioKey(state, mapRawInput(data));
|
|
274
|
+
if (next !== state) notifyChange(next);
|
|
275
|
+
if (state.exitRequested && !finished) {
|
|
276
|
+
finished = true;
|
|
123
277
|
done(null);
|
|
124
278
|
}
|
|
125
279
|
},
|
|
@@ -1,11 +1,21 @@
|
|
|
1
|
-
/** DeepWiki disk cache (U9). TTL + stale fallback
|
|
2
|
-
* the cache directory count actually grows). */
|
|
1
|
+
/** DeepWiki disk cache (U9). TTL + stale fallback + bounded LRU eviction. */
|
|
3
2
|
|
|
4
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
existsSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
readdirSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
statSync,
|
|
9
|
+
unlinkSync,
|
|
10
|
+
utimesSync,
|
|
11
|
+
writeFileSync,
|
|
12
|
+
} from "node:fs";
|
|
5
13
|
import { join } from "node:path";
|
|
6
14
|
import { getAgentPath } from "../../paths/agent-dirs.ts";
|
|
7
15
|
import type { RepoRef } from "./extract.ts";
|
|
8
16
|
|
|
17
|
+
const DEFAULT_MAX_ENTRIES = 64;
|
|
18
|
+
|
|
9
19
|
export function resolveCacheDir(override?: string): string {
|
|
10
20
|
return override ?? getAgentPath("wishcraft-cache", "deepwiki");
|
|
11
21
|
}
|
|
@@ -21,32 +31,96 @@ export interface CacheReadResult<T> {
|
|
|
21
31
|
entry: CacheFile<T> | null;
|
|
22
32
|
}
|
|
23
33
|
|
|
34
|
+
export interface CacheOptions {
|
|
35
|
+
ttlMs: number;
|
|
36
|
+
now?: number;
|
|
37
|
+
maxEntries?: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
24
40
|
function fileFor(dir: string, repo: RepoRef): string {
|
|
25
41
|
return join(dir, repo.owner, `${repo.repo}.json`);
|
|
26
42
|
}
|
|
27
43
|
|
|
44
|
+
function cacheFiles(dir: string): string[] {
|
|
45
|
+
const files: string[] = [];
|
|
46
|
+
let owners: string[];
|
|
47
|
+
try {
|
|
48
|
+
owners = readdirSync(dir);
|
|
49
|
+
} catch {
|
|
50
|
+
return files;
|
|
51
|
+
}
|
|
52
|
+
for (const owner of owners) {
|
|
53
|
+
const ownerDir = join(dir, owner);
|
|
54
|
+
let entries: string[];
|
|
55
|
+
try {
|
|
56
|
+
entries = readdirSync(ownerDir);
|
|
57
|
+
} catch {
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
for (const entry of entries) {
|
|
61
|
+
if (entry.endsWith(".json")) files.push(join(ownerDir, entry));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return files;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Remove least-recently-used entries until the cache is within its cap. */
|
|
68
|
+
export function evictCacheEntries(dir: string, maxEntries = DEFAULT_MAX_ENTRIES): void {
|
|
69
|
+
const cap = Math.max(1, Math.floor(maxEntries));
|
|
70
|
+
const files = cacheFiles(dir);
|
|
71
|
+
if (files.length <= cap) return;
|
|
72
|
+
const ordered = files
|
|
73
|
+
.map((file) => {
|
|
74
|
+
try {
|
|
75
|
+
return { file, atimeMs: statSync(file).atimeMs, mtimeMs: statSync(file).mtimeMs };
|
|
76
|
+
} catch {
|
|
77
|
+
return { file, atimeMs: 0, mtimeMs: 0 };
|
|
78
|
+
}
|
|
79
|
+
})
|
|
80
|
+
.sort((a, b) => (a.atimeMs - b.atimeMs) || (a.mtimeMs - b.mtimeMs));
|
|
81
|
+
for (const item of ordered.slice(0, files.length - cap)) {
|
|
82
|
+
try {
|
|
83
|
+
unlinkSync(item.file);
|
|
84
|
+
} catch {
|
|
85
|
+
// A concurrent cleanup must not break advice or cache reads.
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function touch(file: string, now: number): void {
|
|
91
|
+
try {
|
|
92
|
+
const date = new Date(now);
|
|
93
|
+
utimesSync(file, date, date);
|
|
94
|
+
} catch {
|
|
95
|
+
// Cache recency is best-effort metadata.
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
28
99
|
export async function writeCacheEntry<T>(
|
|
29
100
|
dir: string,
|
|
30
101
|
repo: RepoRef,
|
|
31
102
|
data: T,
|
|
32
|
-
options:
|
|
103
|
+
options: CacheOptions,
|
|
33
104
|
): Promise<void> {
|
|
34
105
|
const now = options.now ?? Date.now();
|
|
35
106
|
const file = fileFor(dir, repo);
|
|
36
107
|
mkdirSync(join(dir, repo.owner), { recursive: true });
|
|
37
108
|
writeFileSync(file, JSON.stringify({ savedAt: now, data }), "utf8");
|
|
109
|
+
touch(file, now);
|
|
110
|
+
evictCacheEntries(dir, options.maxEntries);
|
|
38
111
|
}
|
|
39
112
|
|
|
40
113
|
export async function readCacheEntry<T>(
|
|
41
114
|
dir: string,
|
|
42
115
|
repo: RepoRef,
|
|
43
|
-
options:
|
|
116
|
+
options: CacheOptions,
|
|
44
117
|
): Promise<CacheReadResult<T>> {
|
|
45
118
|
const now = options.now ?? Date.now();
|
|
46
119
|
const file = fileFor(dir, repo);
|
|
47
120
|
if (!existsSync(file)) return { status: "miss", stale: false, entry: null };
|
|
48
121
|
try {
|
|
49
122
|
const raw = JSON.parse(readFileSync(file, "utf8")) as CacheFile<T>;
|
|
123
|
+
touch(file, now);
|
|
50
124
|
const fresh = now - raw.savedAt <= options.ttlMs;
|
|
51
125
|
return { status: fresh ? "hit" : "miss", stale: !fresh, entry: raw };
|
|
52
126
|
} catch {
|
|
@@ -57,7 +131,7 @@ export async function readCacheEntry<T>(
|
|
|
57
131
|
export async function withCache<T>(
|
|
58
132
|
dir: string,
|
|
59
133
|
repo: RepoRef,
|
|
60
|
-
options:
|
|
134
|
+
options: CacheOptions,
|
|
61
135
|
networkFetch: () => Promise<T>,
|
|
62
136
|
): Promise<CacheReadResult<T>> {
|
|
63
137
|
const fresh = await readCacheEntry<T>(dir, repo, options);
|
package/src/studio/open.ts
CHANGED
|
@@ -4,20 +4,179 @@
|
|
|
4
4
|
* context arrives via parameters only.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { getAgentPath } from "../paths/agent-dirs.ts";
|
|
7
9
|
import type { RuntimeState } from "../extension/core/types.ts";
|
|
10
|
+
import {
|
|
11
|
+
getSkillUsage,
|
|
12
|
+
invalidateSkillCache,
|
|
13
|
+
loadSkillStudioCatalog,
|
|
14
|
+
readSkillBody,
|
|
15
|
+
type SkillEntry,
|
|
16
|
+
} from "../extension/skills/skill-registry.ts";
|
|
17
|
+
import { runStudioAction } from "./actions.ts";
|
|
18
|
+
import { createAdvicePane, advicePaneInsert } from "./advice-pane.ts";
|
|
8
19
|
import { createStudioComponent } from "./component.ts";
|
|
9
|
-
import {
|
|
20
|
+
import { extractRepos, type RepoRef } from "./deepwiki/extract.ts";
|
|
21
|
+
import { callTool } from "./deepwiki/client.ts";
|
|
22
|
+
import { withCache } from "./deepwiki/cache.ts";
|
|
23
|
+
import type { AdviseStreamProvider } from "./advise/engine.ts";
|
|
10
24
|
|
|
11
|
-
/**
|
|
12
|
-
* Keep the operator command fail-closed until list/detail/actions/advice are
|
|
13
|
-
* actually wired into the fullscreen component. The scaffold stays available
|
|
14
|
-
* to tests and follow-up implementation without exposing a misleading command.
|
|
15
|
-
*/
|
|
25
|
+
/** The fullscreen component is now connected to discovery, actions, and advice. */
|
|
16
26
|
export const SKILL_STUDIO_PANES_READY = true;
|
|
17
27
|
|
|
28
|
+
const DEEPWIKI_TTL_MS = 24 * 60 * 60 * 1000;
|
|
29
|
+
const DEEPWIKI_MAX_ENTRIES = 64;
|
|
30
|
+
const DEEPWIKI_ENDPOINT = "https://mcp.deepwiki.com/mcp";
|
|
31
|
+
|
|
32
|
+
type StudioContext = {
|
|
33
|
+
hasUI: boolean;
|
|
34
|
+
mode: string;
|
|
35
|
+
cwd?: string;
|
|
36
|
+
ui: {
|
|
37
|
+
notify(message: string, type?: "info" | "warning" | "error"): void;
|
|
38
|
+
custom<T>(factory: (...args: any[]) => any, options?: { overlay?: boolean }): Promise<T>;
|
|
39
|
+
input?(title: string, placeholder?: string): Promise<string | undefined>;
|
|
40
|
+
select?(title: string, options: string[]): Promise<string | undefined>;
|
|
41
|
+
confirm?(title: string, message: string): Promise<boolean>;
|
|
42
|
+
editor?(title: string, prefill?: string): Promise<string | undefined>;
|
|
43
|
+
getEditorText?(): string;
|
|
44
|
+
setEditorText?(text: string): void;
|
|
45
|
+
};
|
|
46
|
+
model?: {
|
|
47
|
+
id: string;
|
|
48
|
+
contextWindow: number;
|
|
49
|
+
maxTokens: number;
|
|
50
|
+
provider: string;
|
|
51
|
+
baseUrl?: string;
|
|
52
|
+
};
|
|
53
|
+
modelRegistry?: {
|
|
54
|
+
getProvider(provider: string): {
|
|
55
|
+
stream(model: unknown, context: unknown, options: Record<string, unknown>): AsyncIterable<unknown>;
|
|
56
|
+
} | undefined;
|
|
57
|
+
getApiKeyAndHeaders(model: unknown): Promise<{
|
|
58
|
+
ok: boolean;
|
|
59
|
+
apiKey?: string;
|
|
60
|
+
headers?: Record<string, string | null>;
|
|
61
|
+
env?: Record<string, string>;
|
|
62
|
+
baseUrl?: string;
|
|
63
|
+
}>;
|
|
64
|
+
};
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
interface WikiPayload {
|
|
68
|
+
content: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function referencesFor(entry: SkillEntry): { name: string; content: string }[] {
|
|
72
|
+
const body = readSkillBody(entry.filePath);
|
|
73
|
+
const refs = body.matchAll(/\[[^\]]+\]\((references|scripts)\/[^)\s]+\)/g);
|
|
74
|
+
const sections: { name: string; content: string }[] = [];
|
|
75
|
+
for (const match of refs) {
|
|
76
|
+
const hrefMatch = /\(([^)\s]+)\)/.exec(match[0] ?? "");
|
|
77
|
+
const href = hrefMatch?.[1];
|
|
78
|
+
if (!href) continue;
|
|
79
|
+
try {
|
|
80
|
+
sections.push({ name: href, content: readFileSync(`${entry.baseDir}/${href}`, "utf8") });
|
|
81
|
+
} catch {
|
|
82
|
+
sections.push({ name: href, content: "(missing local reference)" });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return sections;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function fetchWiki(repo: RepoRef): Promise<WikiPayload> {
|
|
89
|
+
const structure = await callTool<{ content?: { type: string; text?: string }[] }>(
|
|
90
|
+
"read_wiki_structure",
|
|
91
|
+
{ repo: `${repo.owner}/${repo.repo}` },
|
|
92
|
+
DEEPWIKI_ENDPOINT,
|
|
93
|
+
);
|
|
94
|
+
const structureText = structure.content?.map((part) => part.text ?? "").join("\n") ?? "";
|
|
95
|
+
const contents = await callTool<{ content?: { type: string; text?: string }[] }>(
|
|
96
|
+
"read_wiki_contents",
|
|
97
|
+
{ repo: `${repo.owner}/${repo.repo}` },
|
|
98
|
+
DEEPWIKI_ENDPOINT,
|
|
99
|
+
);
|
|
100
|
+
const contentText = contents.content?.map((part) => part.text ?? "").join("\n") ?? "";
|
|
101
|
+
return { content: `${structureText}\n\n${contentText}`.trim() };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function localWikiContext(entry: SkillEntry, notify: (message: string, type?: "info" | "warning" | "error") => void) {
|
|
105
|
+
const sections: { name: string; content: string }[] = [];
|
|
106
|
+
for (const repo of extractRepos(readSkillBody(entry.filePath))) {
|
|
107
|
+
const cached = await withCache(
|
|
108
|
+
getAgentPath("wishcraft-cache", "deepwiki"),
|
|
109
|
+
repo,
|
|
110
|
+
{ ttlMs: DEEPWIKI_TTL_MS, maxEntries: DEEPWIKI_MAX_ENTRIES },
|
|
111
|
+
() => fetchWiki(repo),
|
|
112
|
+
);
|
|
113
|
+
if (cached.entry?.data.content) {
|
|
114
|
+
sections.push({ name: `${repo.owner}/${repo.repo}`, content: cached.entry.data.content });
|
|
115
|
+
}
|
|
116
|
+
if (cached.stale || !cached.entry) {
|
|
117
|
+
notify(`DeepWiki unavailable for ${repo.owner}/${repo}; using local skill context`, "warning");
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return sections;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function runAdviceFor(
|
|
124
|
+
ctx: StudioContext,
|
|
125
|
+
entry: SkillEntry,
|
|
126
|
+
mode: "explain" | "integrate" | "examples" | "improve",
|
|
127
|
+
pane: ReturnType<typeof createAdvicePane>,
|
|
128
|
+
): Promise<void> {
|
|
129
|
+
const model = ctx.model;
|
|
130
|
+
const registry = ctx.modelRegistry;
|
|
131
|
+
const provider = model && registry ? registry.getProvider(model.provider) : undefined;
|
|
132
|
+
if (!model || !registry || !provider) {
|
|
133
|
+
await pane.run({ mode, skillName: entry.name, body: readSkillBody(entry.filePath), references: referencesFor(entry), wiki: [], provider: null, signal: new AbortController().signal });
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
const auth = await registry.getApiKeyAndHeaders(model);
|
|
137
|
+
if (!auth.ok) {
|
|
138
|
+
await pane.run({ mode, skillName: entry.name, body: readSkillBody(entry.filePath), references: referencesFor(entry), wiki: [], provider: null, signal: new AbortController().signal });
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
const requestModel = auth.baseUrl ? { ...model, baseUrl: auth.baseUrl } : model;
|
|
142
|
+
const streamProvider: AdviseStreamProvider = {
|
|
143
|
+
stream: (_advisorModel, context, options) => provider.stream(requestModel, {
|
|
144
|
+
systemPrompt: context.prompt?.system,
|
|
145
|
+
messages: [{ role: "user", content: [{ type: "text", text: context.prompt?.user ?? "" }], timestamp: Date.now() }],
|
|
146
|
+
}, {
|
|
147
|
+
apiKey: auth.apiKey,
|
|
148
|
+
headers: auth.headers,
|
|
149
|
+
env: auth.env,
|
|
150
|
+
signal: options.signal,
|
|
151
|
+
maxTokens: options.maxTokens,
|
|
152
|
+
}),
|
|
153
|
+
};
|
|
154
|
+
const references = referencesFor(entry);
|
|
155
|
+
const wiki = await localWikiContext(entry, (message, type) => ctx.ui.notify(message, type));
|
|
156
|
+
await pane.run({
|
|
157
|
+
mode,
|
|
158
|
+
skillName: entry.name,
|
|
159
|
+
body: readSkillBody(entry.filePath),
|
|
160
|
+
references,
|
|
161
|
+
wiki,
|
|
162
|
+
provider: streamProvider,
|
|
163
|
+
model: { id: model.id, contextWindow: model.contextWindow, maxTokens: model.maxTokens },
|
|
164
|
+
signal: new AbortController().signal,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function editSkill(ctx: StudioContext, entry: SkillEntry): Promise<void> {
|
|
169
|
+
if (!ctx.ui.editor) return;
|
|
170
|
+
const next = await ctx.ui.editor(`Edit skill: ${entry.name}`, readFileSync(entry.filePath, "utf8"));
|
|
171
|
+
if (next === undefined) return;
|
|
172
|
+
writeFileSync(entry.filePath, next, "utf8");
|
|
173
|
+
invalidateSkillCache();
|
|
174
|
+
ctx.ui.notify(`Updated ${entry.name}`, "info");
|
|
175
|
+
}
|
|
176
|
+
|
|
18
177
|
export async function openSkillStudio(
|
|
19
178
|
rt: RuntimeState,
|
|
20
|
-
ctx:
|
|
179
|
+
ctx: StudioContext,
|
|
21
180
|
): Promise<void> {
|
|
22
181
|
if (!rt.enabled) {
|
|
23
182
|
if (ctx.hasUI) ctx.ui.notify("Skill Studio requires the Signal UI to be enabled", "info");
|
|
@@ -33,10 +192,45 @@ export async function openSkillStudio(
|
|
|
33
192
|
}
|
|
34
193
|
rt.currentCtx = ctx;
|
|
35
194
|
invalidateSkillCache();
|
|
36
|
-
const
|
|
37
|
-
|
|
195
|
+
const cwd = ctx.cwd ?? process.cwd();
|
|
196
|
+
const pane = createAdvicePane();
|
|
197
|
+
let entries = loadSkillStudioCatalog(cwd);
|
|
38
198
|
await ctx.ui.custom(
|
|
39
|
-
(_tui:
|
|
40
|
-
createStudioComponent(theme, done, undefined,
|
|
199
|
+
(_tui: unknown, theme: any, _keybindings: unknown, done: (value: string | null) => void) =>
|
|
200
|
+
createStudioComponent(theme, done, undefined, {
|
|
201
|
+
entries,
|
|
202
|
+
usage: getSkillUsage(),
|
|
203
|
+
advicePane: pane,
|
|
204
|
+
onRefresh: () => {
|
|
205
|
+
entries = loadSkillStudioCatalog(cwd);
|
|
206
|
+
return entries;
|
|
207
|
+
},
|
|
208
|
+
onCreate: async () => {
|
|
209
|
+
const name = await ctx.ui.input?.("New skill", "skill-name");
|
|
210
|
+
if (!name) return;
|
|
211
|
+
const template = await ctx.ui.select?.("Template", ["standard", "browser-workflow", "cli-workflow", "review-checklist"]);
|
|
212
|
+
const result = await runStudioAction({ type: "create", name, template: (template ?? "standard") as "standard" | "browser-workflow" | "cli-workflow" | "review-checklist", skillsRoot: getAgentPath("skills") }, {
|
|
213
|
+
confirm: (message) => ctx.ui.confirm?.("Confirm", message) ?? Promise.resolve(false),
|
|
214
|
+
});
|
|
215
|
+
ctx.ui.notify(result.message, result.kind === "error" ? "error" : "info");
|
|
216
|
+
},
|
|
217
|
+
onEdit: (entry) => editSkill(ctx, entry),
|
|
218
|
+
onDoctor: async () => {
|
|
219
|
+
const result = await runStudioAction({ type: "doctor", cwd }, { confirm: async () => false });
|
|
220
|
+
ctx.ui.notify(result.message, result.kind === "error" ? "error" : "info");
|
|
221
|
+
},
|
|
222
|
+
onAdvice: (entry, mode, targetPane) => runAdviceFor(ctx, entry, mode, targetPane),
|
|
223
|
+
onInsert: (targetPane) => {
|
|
224
|
+
if (!ctx.ui.getEditorText || !ctx.ui.setEditorText) return;
|
|
225
|
+
advicePaneInsert(targetPane, {
|
|
226
|
+
appendUserMessage(text) {
|
|
227
|
+
const current = ctx.ui.getEditorText!();
|
|
228
|
+
ctx.ui.setEditorText!(`${current}${current ? "\n\n" : ""}${text}\n`);
|
|
229
|
+
},
|
|
230
|
+
});
|
|
231
|
+
ctx.ui.notify("Advice inserted into the editor", "info");
|
|
232
|
+
},
|
|
233
|
+
onError: (error) => ctx.ui.notify(error instanceof Error ? error.message : String(error), "error"),
|
|
234
|
+
}),
|
|
41
235
|
);
|
|
42
236
|
}
|