@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.
- package/AGENTS.md +309 -97
- package/CHANGELOG.md +4 -0
- package/package.json +2 -2
- package/src/extension/skills/global-registry.ts +63 -0
- package/src/extension/skills/skill-registry.ts +56 -4
- package/src/motion/index.ts +1 -0
- package/src/motion/policy.ts +28 -1
- package/src/render/motion-rail.ts +70 -16
- package/src/render/v2-entry.ts +1 -1
- package/src/signal/controller.ts +10 -10
- package/src/studio/component.ts +213 -37
- package/src/studio/deepwiki/cache.ts +80 -6
- package/src/studio/list.ts +11 -1
- package/src/studio/open.ts +207 -15
- package/src/theme/colors.ts +2 -6
- package/src/tools/patch.ts +0 -179
- package/src/tools/ripgrep.ts +0 -104
package/src/studio/list.ts
CHANGED
|
@@ -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
|
}
|
package/src/studio/open.ts
CHANGED
|
@@ -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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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:
|
|
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:
|
|
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
|
}
|
package/src/theme/colors.ts
CHANGED
|
@@ -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
|
-
|
|
21
|
-
|
|
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] {
|
package/src/tools/patch.ts
DELETED
|
@@ -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
|
-
}
|
package/src/tools/ripgrep.ts
DELETED
|
@@ -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
|
-
}
|