@groeponline/pi-wishcraft 1.4.12 → 1.4.13

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 CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [1.4.13] - 2026-09-05
6
+
5
7
  ## [1.4.12] - 2026-09-05
6
8
 
7
9
  ## [1.4.11] - 2026-08-30
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groeponline/pi-wishcraft",
3
- "version": "1.4.12",
3
+ "version": "1.4.13",
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": [
@@ -1,21 +1,24 @@
1
- /**
2
- * Fullscreen studio component (U5, KTD3). Non-overlay `ctx.ui.custom()`
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<string, string> = {
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 Cycle pane focus",
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
- export function renderStudioFrame(
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
- if (state.mode === "help") {
57
- const lines = [theme.fg("accent", HELP_LINES[0] ?? ""), ""];
58
- for (const line of HELP_LINES.slice(2)) lines.push(theme.fg("muted", line));
59
- lines.push("", theme.fg("dim", "Press q, Esc, or Enter to close help"));
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
- const focusMark = (pane: string): string =>
64
- state.focus === pane ? theme.fg("accent", `[${PANE_LABELS[pane] ?? pane}]`) : theme.fg("dim", ` ${PANE_LABELS[pane] ?? pane} `);
65
- const header = STUDIO_PANES.map((pane) => focusMark(pane)).join(" ");
66
- const filterLine = state.mode === "filter"
67
- ? theme.fg("accent", `filter: ${state.filterQuery}_`)
68
- : state.filterQuery ? theme.fg("muted", `filter: ${state.filterQuery}`) : theme.fg("dim", "press / to filter, ? for help");
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
- const rows = filterListRows(buildListRows(entries), state.filterQuery);
71
- const selectedIndex = rows.length ? Math.min(state.selectedIndex, rows.length - 1) : 0;
72
- const selected = rows[selectedIndex];
73
- const radius = 4;
74
- const from = Math.max(0, selectedIndex - radius);
75
- const visible = rows.slice(from, from + 9);
76
- const lines: string[] = [theme.fg("accent", `Skill Studio · ${rows.length} skills`), header, filterLine, theme.fg("dim", "─".repeat(Math.max(1, Math.min(width - 1, 78))))];
77
-
78
- if (!selected) {
79
- lines.push(theme.fg("muted", "No skills match the current filter."));
80
- } else {
81
- for (let i = 0; i < visible.length; i += 1) {
82
- const row = visible[i]!;
83
- const absolute = from + i;
84
- const mark = absolute === selectedIndex ? ">" : " ";
85
- const route = row.routingCategory ? ` · ${row.routingCategory}/${row.routingFamily ?? "general"}` : "";
86
- const drift = row.registryDrift ? " · drift" : "";
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.push(theme.fg("dim", "q/Esc exit · / filter · j/k navigate · Tab focus · ? help"));
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
- entries: readonly SkillEntry[] = [],
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
- const next = handleStudioKey(state, mapRawInput(data));
118
- if (next !== state) {
119
- state = next;
120
- onStateChange?.(state);
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
- if (state.exitRequested) {
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, no LRU (ponytail: add when
2
- * the cache directory count actually grows). */
1
+ /** DeepWiki disk cache (U9). TTL + stale fallback + bounded LRU eviction. */
3
2
 
4
- import { mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
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: { ttlMs: number; now?: number },
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: { ttlMs: number; now?: number },
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: { ttlMs: number; now?: number },
134
+ options: CacheOptions,
61
135
  networkFetch: () => Promise<T>,
62
136
  ): Promise<CacheReadResult<T>> {
63
137
  const fresh = await readCacheEntry<T>(dir, repo, options);
@@ -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 { loadSkillStudioCatalog, invalidateSkillCache } from "../extension/skills/skill-registry.ts";
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: any,
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 entries = loadSkillStudioCatalog(process.cwd());
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: any, theme: any, _keybindings: any, done: (value: string | null) => void) =>
40
- createStudioComponent(theme, done, undefined, entries),
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
  }