@groeponline/pi-wishcraft 1.4.11 → 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.
@@ -13,6 +13,11 @@ export interface ListRow {
13
13
  category: SkillCategory;
14
14
  filePath: string;
15
15
  warning?: string;
16
+ routingCategory?: string;
17
+ routingFamily?: string;
18
+ role?: string | null;
19
+ routerParent?: string | null;
20
+ registryDrift?: boolean;
16
21
  }
17
22
 
18
23
  export function badgeForCategory(category: SkillCategory): string {
@@ -36,6 +41,11 @@ export function buildListRows(entries: readonly SkillEntry[]): ListRow[] {
36
41
  category: entry.category,
37
42
  filePath: entry.filePath,
38
43
  warning: entry.warning,
44
+ routingCategory: entry.routingCategory,
45
+ routingFamily: entry.routingFamily,
46
+ role: entry.role,
47
+ routerParent: entry.routerParent,
48
+ registryDrift: entry.registryDrift,
39
49
  }));
40
50
  }
41
51
 
@@ -43,6 +53,6 @@ export function filterListRows(rows: readonly ListRow[], query: string): ListRow
43
53
  const q = query.trim().toLowerCase();
44
54
  if (q.length === 0) return [...rows];
45
55
  return rows.filter(
46
- (row) => row.name.toLowerCase().includes(q) || row.description.toLowerCase().includes(q),
56
+ (row) => row.name.toLowerCase().includes(q) || row.description.toLowerCase().includes(q) || row.routingCategory?.toLowerCase().includes(q) || row.routingFamily?.toLowerCase().includes(q),
47
57
  );
48
58
  }
@@ -4,19 +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";
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";
9
24
 
10
- /**
11
- * Keep the operator command fail-closed until list/detail/actions/advice are
12
- * actually wired into the fullscreen component. The scaffold stays available
13
- * to tests and follow-up implementation without exposing a misleading command.
14
- */
15
- export const SKILL_STUDIO_PANES_READY = false;
25
+ /** The fullscreen component is now connected to discovery, actions, and advice. */
26
+ export const SKILL_STUDIO_PANES_READY = true;
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
+ }
16
176
 
17
177
  export async function openSkillStudio(
18
178
  rt: RuntimeState,
19
- ctx: any,
179
+ ctx: StudioContext,
20
180
  ): Promise<void> {
21
181
  if (!rt.enabled) {
22
182
  if (ctx.hasUI) ctx.ui.notify("Skill Studio requires the Signal UI to be enabled", "info");
@@ -30,15 +190,47 @@ export async function openSkillStudio(
30
190
  ctx.ui.notify("Skill Studio is not available in RPC mode", "warning");
31
191
  return;
32
192
  }
33
- if (!SKILL_STUDIO_PANES_READY) {
34
- ctx.ui.notify("Skill Studio is not available until its panes are connected", "warning");
35
- return;
36
- }
37
-
38
193
  rt.currentCtx = ctx;
39
-
194
+ invalidateSkillCache();
195
+ const cwd = ctx.cwd ?? process.cwd();
196
+ const pane = createAdvicePane();
197
+ let entries = loadSkillStudioCatalog(cwd);
40
198
  await ctx.ui.custom(
41
- (_tui: any, theme: any, _keybindings: any, done: (value: string | null) => void) =>
42
- createStudioComponent(theme, done),
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
+ }),
43
235
  );
44
236
  }
@@ -15,13 +15,9 @@ export const ansi: AnsiColors = {
15
15
  // ponytail: NO_COLOR (de-facto standard — present and non-empty) disables all
16
16
  // wishcraft color so the status bar stays plain text in no-color terminals
17
17
  // and color-blind pipelines. Computed lazily so test env changes take effect.
18
- let _colorEnabled: boolean | undefined;
19
18
  export function colorEnabled(): boolean {
20
- if (_colorEnabled === undefined) {
21
- const v = process.env.NO_COLOR;
22
- _colorEnabled = !(v != null && v !== "");
23
- }
24
- return _colorEnabled;
19
+ const v = process.env.NO_COLOR;
20
+ return !(v != null && v !== "");
25
21
  }
26
22
 
27
23
  function hexToRgb(hex: string): [number, number, number] {
@@ -1,179 +0,0 @@
1
- import { readFileSync, writeFileSync, copyFileSync, unlinkSync, existsSync, mkdirSync } from "node:fs";
2
- import { dirname, join } from "node:path";
3
- import { getAgentPath } from "../paths/agent-dirs.ts";
4
-
5
- export interface PatchHunk {
6
- oldStart: number;
7
- oldLines: number;
8
- newStart: number;
9
- newLines: number;
10
- lines: string[];
11
- }
12
-
13
- export interface FilePatch {
14
- targetFile: string;
15
- hunks: PatchHunk[];
16
- }
17
-
18
- export interface PatchResult {
19
- success: boolean;
20
- targetFile: string;
21
- appliedHunks: number;
22
- totalHunks: number;
23
- backupPath?: string;
24
- error?: string;
25
- }
26
-
27
- const undoStack: { targetFile: string; backupPath: string; timestamp: number }[] = [];
28
- const MAX_UNDO_STACK = 10;
29
-
30
- /**
31
- * Parse a unified diff string into structured FilePatch objects.
32
- */
33
- export function parseUnifiedDiff(diffText: string): FilePatch[] {
34
- const patches: FilePatch[] = [];
35
- const lines = diffText.split(/\r?\n/);
36
- let currentPatch: FilePatch | null = null;
37
- let currentHunk: PatchHunk | null = null;
38
-
39
- for (let i = 0; i < lines.length; i++) {
40
- const line = lines[i];
41
-
42
- if (line.startsWith("--- ")) {
43
- continue;
44
- }
45
-
46
- if (line.startsWith("+++ ")) {
47
- const rawPath = line.slice(4).trim();
48
- const targetFile = rawPath.replace(/^[ab]\//, "");
49
- currentPatch = { targetFile, hunks: [] };
50
- patches.push(currentPatch);
51
- continue;
52
- }
53
-
54
- const hunkHeaderMatch = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/);
55
- if (hunkHeaderMatch && currentPatch) {
56
- currentHunk = {
57
- oldStart: parseInt(hunkHeaderMatch[1], 10),
58
- oldLines: parseInt(hunkHeaderMatch[2] ?? "1", 10),
59
- newStart: parseInt(hunkHeaderMatch[3], 10),
60
- newLines: parseInt(hunkHeaderMatch[4] ?? "1", 10),
61
- lines: [],
62
- };
63
- currentPatch.hunks.push(currentHunk);
64
- continue;
65
- }
66
-
67
- if (currentHunk && (line.startsWith("+") || line.startsWith("-") || line.startsWith(" ") || line === "")) {
68
- currentHunk.lines.push(line);
69
- }
70
- }
71
-
72
- return patches;
73
- }
74
-
75
- /**
76
- * Apply a FilePatch to a file safely with an atomic backup.
77
- */
78
- export function applyFilePatch(patch: FilePatch, baseDir: string = process.cwd()): PatchResult {
79
- const fullPath = join(baseDir, patch.targetFile);
80
-
81
- if (!existsSync(fullPath)) {
82
- return {
83
- success: false,
84
- targetFile: patch.targetFile,
85
- appliedHunks: 0,
86
- totalHunks: patch.hunks.length,
87
- error: `Target file not found: ${fullPath}`,
88
- };
89
- }
90
-
91
- // 1. Create backup
92
- const backupDir = getAgentPath("patch-backups");
93
- if (!existsSync(backupDir)) {
94
- mkdirSync(backupDir, { recursive: true });
95
- }
96
- const backupPath = join(backupDir, `${Date.now()}-${patch.targetFile.replace(/\//g, "_")}.bak`);
97
- copyFileSync(fullPath, backupPath);
98
-
99
- try {
100
- const originalContent = readFileSync(fullPath, "utf-8");
101
- let fileLines = originalContent.split(/\r?\n/);
102
- let appliedHunks = 0;
103
-
104
- for (const hunk of patch.hunks) {
105
- const targetLineIdx = hunk.oldStart - 1;
106
- const expectedOldLines = hunk.lines.filter((l) => !l.startsWith("+"));
107
-
108
- // Match context check
109
- let matches = true;
110
- for (let j = 0; j < expectedOldLines.length; j++) {
111
- const expected = expectedOldLines[j].slice(1);
112
- const actual = fileLines[targetLineIdx + j];
113
- if (actual !== undefined && actual !== expected) {
114
- matches = false;
115
- break;
116
- }
117
- }
118
-
119
- if (matches) {
120
- const newHunkLines: string[] = [];
121
- for (const line of hunk.lines) {
122
- if (!line.startsWith("-")) {
123
- newHunkLines.push(line.startsWith("+") ? line.slice(1) : line.slice(1));
124
- }
125
- }
126
-
127
- fileLines.splice(targetLineIdx, hunk.oldLines, ...newHunkLines);
128
- appliedHunks++;
129
- }
130
- }
131
-
132
- writeFileSync(fullPath, fileLines.join("\n"), "utf-8");
133
-
134
- // Track undo stack
135
- undoStack.push({ targetFile: fullPath, backupPath, timestamp: Date.now() });
136
- if (undoStack.length > MAX_UNDO_STACK) {
137
- const oldest = undoStack.shift();
138
- if (oldest && existsSync(oldest.backupPath)) {
139
- try { unlinkSync(oldest.backupPath); } catch {}
140
- }
141
- }
142
-
143
- return {
144
- success: appliedHunks > 0,
145
- targetFile: patch.targetFile,
146
- appliedHunks,
147
- totalHunks: patch.hunks.length,
148
- backupPath,
149
- };
150
- } catch (err) {
151
- // Revert from backup
152
- copyFileSync(backupPath, fullPath);
153
- return {
154
- success: false,
155
- targetFile: patch.targetFile,
156
- appliedHunks: 0,
157
- totalHunks: patch.hunks.length,
158
- error: err instanceof Error ? err.message : String(err),
159
- };
160
- }
161
- }
162
-
163
- /**
164
- * Revert the last applied patch.
165
- */
166
- export function undoLastPatch(): { success: boolean; targetFile?: string; error?: string } {
167
- const last = undoStack.pop();
168
- if (!last || !existsSync(last.backupPath)) {
169
- return { success: false, error: "No undo backup available" };
170
- }
171
-
172
- try {
173
- copyFileSync(last.backupPath, last.targetFile);
174
- unlinkSync(last.backupPath);
175
- return { success: true, targetFile: last.targetFile };
176
- } catch (err) {
177
- return { success: false, error: err instanceof Error ? err.message : String(err) };
178
- }
179
- }
@@ -1,104 +0,0 @@
1
- import { spawnSync } from "node:child_process";
2
-
3
- export interface RipgrepMatch {
4
- file: string;
5
- lineNumber: number;
6
- content: string;
7
- }
8
-
9
- export interface RipgrepOptions {
10
- cwd?: string;
11
- typeFilter?: string;
12
- maxResults?: number;
13
- }
14
-
15
- export interface RipgrepResult {
16
- matches: RipgrepMatch[];
17
- totalCount: number;
18
- engine: "ripgrep" | "grep-fallback";
19
- error?: string;
20
- }
21
-
22
- /**
23
- * Execute a ripgrep (or fallback grep) search programmatically for subagent use.
24
- */
25
- export function searchRipgrep(pattern: string, options: RipgrepOptions = {}): RipgrepResult {
26
- const cwd = options.cwd ?? process.cwd();
27
- const maxResults = options.maxResults ?? 50;
28
-
29
- // Try rg first
30
- try {
31
- const rgArgs = ["--json", "-m", String(maxResults), "-i"];
32
- if (options.typeFilter) {
33
- rgArgs.push("-t", options.typeFilter);
34
- }
35
- rgArgs.push(pattern, ".");
36
-
37
- const proc = spawnSync("rg", rgArgs, { cwd, encoding: "utf-8", maxBuffer: 5 * 1024 * 1024 });
38
-
39
- if (proc.status === 0 || proc.status === 1) {
40
- const matches: RipgrepMatch[] = [];
41
- const lines = (proc.stdout ?? "").split("\n");
42
-
43
- for (const line of lines) {
44
- if (!line.trim()) continue;
45
- try {
46
- const parsed = JSON.parse(line);
47
- if (parsed.type === "match") {
48
- const data = parsed.data;
49
- matches.push({
50
- file: data.path.text,
51
- lineNumber: data.line_number,
52
- content: data.lines.text.trimEnd(),
53
- });
54
- }
55
- } catch {
56
- // ignore non-json lines
57
- }
58
- }
59
-
60
- return {
61
- matches: matches.slice(0, maxResults),
62
- totalCount: matches.length,
63
- engine: "ripgrep",
64
- };
65
- }
66
- } catch {
67
- // Fallback to standard grep
68
- }
69
-
70
- // Fallback: standard grep
71
- try {
72
- const grepArgs = ["-rn", "-m", String(maxResults), "--exclude-dir=node_modules", "--exclude-dir=.git", pattern, "."];
73
- const proc = spawnSync("grep", grepArgs, { cwd, encoding: "utf-8", maxBuffer: 5 * 1024 * 1024 });
74
-
75
- const matches: RipgrepMatch[] = [];
76
- const lines = (proc.stdout ?? "").split("\n");
77
-
78
- for (const line of lines) {
79
- if (!line.trim()) continue;
80
- const parts = line.split(":");
81
- if (parts.length >= 3) {
82
- const file = parts[0];
83
- const lineNumber = parseInt(parts[1], 10);
84
- const content = parts.slice(2).join(":").trimEnd();
85
- if (!isNaN(lineNumber)) {
86
- matches.push({ file, lineNumber, content });
87
- }
88
- }
89
- }
90
-
91
- return {
92
- matches: matches.slice(0, maxResults),
93
- totalCount: matches.length,
94
- engine: "grep-fallback",
95
- };
96
- } catch (err) {
97
- return {
98
- matches: [],
99
- totalCount: 0,
100
- engine: "grep-fallback",
101
- error: err instanceof Error ? err.message : String(err),
102
- };
103
- }
104
- }