pi-shorthand 0.6.0 → 0.7.0
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/api.d.ts +1 -1
- package/display.ts +15 -4
- package/format.ts +13 -1
- package/index.ts +22 -10
- package/lsp-client.ts +3 -0
- package/model-diff.ts +60 -0
- package/move-imports.ts +697 -0
- package/nfs-observer.ts +61 -8
- package/nfs-proxy.ts +2 -2
- package/package.json +1 -1
- package/placement.ts +73 -6
- package/prelude.ts +297 -53
- package/program-clock.ts +45 -0
- package/program-lint.ts +13 -0
- package/runner.ts +65 -10
- package/skills/shorthand/SKILL.md +25 -13
- package/skills/shorthand/advanced-refactors.md +12 -9
- package/typescript-refactors.ts +52 -9
- package/workspace-edit.ts +11 -8
package/api.d.ts
CHANGED
|
@@ -8,7 +8,7 @@ declare global {
|
|
|
8
8
|
const grep: ShorthandGlobals["grep"];
|
|
9
9
|
const sg: ShorthandGlobals["sg"];
|
|
10
10
|
const grit: ShorthandGlobals["grit"];
|
|
11
|
-
const
|
|
11
|
+
const refactor: ShorthandGlobals["refactor"];
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
export type { RewriteResult } from "./prelude.ts";
|
package/display.ts
CHANGED
|
@@ -93,7 +93,7 @@ export function resultLines(run: RunResult, expanded: boolean, theme: Theme): st
|
|
|
93
93
|
for (const section of sections) lines.push("", ...section);
|
|
94
94
|
const timing = timingBreakdown(run);
|
|
95
95
|
if (timing) lines.push("", theme.fg("muted", `timing: ${timing}`));
|
|
96
|
-
if (run.diagnostics && ((run.diagnostics.wallMs ?? run.durationMs) >= run
|
|
96
|
+
if (run.diagnostics && ((run.diagnostics.wallMs ?? run.durationMs) >= timeoutBudgetMs(run) || run.exitCode !== 0)) {
|
|
97
97
|
lines.push(...diagnosticLines(run.diagnostics).map((line) => theme.fg("muted", line)));
|
|
98
98
|
}
|
|
99
99
|
return lines;
|
|
@@ -115,7 +115,7 @@ const TIMING_LABELS: Record<keyof RunTimings, string> = {
|
|
|
115
115
|
|
|
116
116
|
/** Explain calls exceeding the configured program budget, even when no individual phase does. */
|
|
117
117
|
export function timingBreakdown(run: RunResult): string | undefined {
|
|
118
|
-
if (!run.timings || (run.diagnostics?.wallMs ?? run.durationMs) < run
|
|
118
|
+
if (!run.timings || (run.diagnostics?.wallMs ?? run.durationMs) < timeoutBudgetMs(run)) return undefined;
|
|
119
119
|
const significant = Object.entries(run.timings)
|
|
120
120
|
.map(([phase, milliseconds]) => ({
|
|
121
121
|
label: TIMING_LABELS[phase as keyof RunTimings],
|
|
@@ -131,16 +131,27 @@ function formatDuration(milliseconds: number): string {
|
|
|
131
131
|
return milliseconds < 1_000 ? `${milliseconds}ms` : `${(milliseconds / 1_000).toFixed(1)}s`;
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
+
/** e.g. "2s" or "2s plus 60.0s in helpers": helper time does not count toward the timeout. */
|
|
135
|
+
function timeoutText(run: RunResult): string {
|
|
136
|
+
const helpers = run.helperMs ? ` plus ${formatDuration(run.helperMs)} in helpers` : "";
|
|
137
|
+
return `${run.timeoutMs / 1000}s${helpers}`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** The longest a run can take before its timeout, including excluded helper time. */
|
|
141
|
+
export function timeoutBudgetMs(run: RunResult): number {
|
|
142
|
+
return run.timeoutMs + (run.helperMs ?? 0);
|
|
143
|
+
}
|
|
144
|
+
|
|
134
145
|
/** e.g. "✓ Applied 3 files · +6 −6 · 0.6s" or "✕ Failed · rolled back all changes · exit 1 · 0.2s" */
|
|
135
146
|
function verdict(run: RunResult, applied: FileChange[], theme: Theme): string {
|
|
136
147
|
const muted = (text: string) => theme.fg("muted", text);
|
|
137
148
|
const took = muted(` · ${(run.durationMs / 1000).toFixed(1)}s`);
|
|
138
|
-
const failure = run.timedOut ? `Program timed out after ${run
|
|
149
|
+
const failure = run.timedOut ? `Program timed out after ${timeoutText(run)}` : "Failed";
|
|
139
150
|
const exit = run.timedOut ? "" : muted(` · exit ${run.exitCode}`);
|
|
140
151
|
|
|
141
152
|
if (run.conflicts.length > 0) {
|
|
142
153
|
const program = run.timedOut
|
|
143
|
-
? ` · timed out after ${run
|
|
154
|
+
? ` · timed out after ${timeoutText(run)}`
|
|
144
155
|
: run.exitCode
|
|
145
156
|
? ` · exit ${run.exitCode}`
|
|
146
157
|
: "";
|
package/format.ts
CHANGED
|
@@ -117,14 +117,26 @@ export function formatterFor(file: string, root: string): Command | null {
|
|
|
117
117
|
return null;
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
+
/** Files of one kind share a formatter decision: JS-family files, Python, Go or Rust. */
|
|
121
|
+
function formatterKind(file: string): string {
|
|
122
|
+
const extension = extname(file);
|
|
123
|
+
if (/\.(?:[cm]?[jt]sx?|jsonc?|css|scss|less|html|vue|svelte|mdx?|ya?ml|graphql)$/i.test(extension)) return "js";
|
|
124
|
+
return extension === ".pyi" ? ".py" : extension;
|
|
125
|
+
}
|
|
126
|
+
|
|
120
127
|
export async function formatChanged(
|
|
121
128
|
files: string[],
|
|
122
129
|
root: string,
|
|
123
130
|
): Promise<{ messages: string[]; warnings: string[] }> {
|
|
124
131
|
const groups = new Map<string, { command: Command; files: string[] }>();
|
|
125
132
|
const result = { messages: [] as string[], warnings: [] as string[] };
|
|
133
|
+
// The choice depends only on a file's directory and kind of source, and each lookup checks several
|
|
134
|
+
// config files per ancestor directory, so decide once for files that share both.
|
|
135
|
+
const decided = new Map<string, Command | null>();
|
|
126
136
|
for (const file of files) {
|
|
127
|
-
const
|
|
137
|
+
const place = `${dirname(resolve(root, file))}\0${formatterKind(file)}`;
|
|
138
|
+
if (!decided.has(place)) decided.set(place, formatterFor(file, root));
|
|
139
|
+
const command = decided.get(place)!;
|
|
128
140
|
if (!command) continue;
|
|
129
141
|
const key = JSON.stringify(command);
|
|
130
142
|
const group = groups.get(key) ?? { command, files: [] };
|
package/index.ts
CHANGED
|
@@ -17,11 +17,13 @@ import {
|
|
|
17
17
|
countLines,
|
|
18
18
|
fileMetadataSummary,
|
|
19
19
|
resultLines,
|
|
20
|
+
timeoutBudgetMs,
|
|
20
21
|
timingBreakdown,
|
|
21
22
|
unstructuredResultText,
|
|
22
23
|
} from "./display.ts";
|
|
23
24
|
import type { FileChange, RunOptions, RunResult } from "./runner.ts";
|
|
24
25
|
import { type Diagnostics, completeDiagnostics, diagnosticLines } from "./diagnostics.ts";
|
|
26
|
+
import { modelDiff } from "./model-diff.ts";
|
|
25
27
|
|
|
26
28
|
class RunnerError extends Error {
|
|
27
29
|
constructor(
|
|
@@ -35,7 +37,7 @@ class RunnerError extends Error {
|
|
|
35
37
|
// Runs typically take well under a second. Longer transformations can request more time.
|
|
36
38
|
const DEFAULT_TIMEOUT_SECONDS = 2;
|
|
37
39
|
|
|
38
|
-
const DESCRIPTION = `Edit repository files with a TypeScript program run by Bun. Top-level await and ordinary Bun/Node APIs work. Use repository-relative paths. Set cwd to a checkout path when Pi's working directory is outside the repository, such as a child worktree in a bare worktree container. The program runs in an isolated workspace; changes apply on successful exit by default and the tool reports the diff. Run tests, type-checks and builds separately afterward with the shell tool.
|
|
40
|
+
const DESCRIPTION = `Edit repository files with a TypeScript program run by Bun. Best for changes across many files, repeated edits and semantic TypeScript renames or moves; a small change to one file is quicker as a direct edit. Top-level await and ordinary Bun/Node APIs work. Use repository-relative paths. Set cwd to a checkout path when Pi's working directory is outside the repository, such as a child worktree in a bare worktree container. The program runs in an isolated workspace; changes apply on successful exit by default and the tool reports the diff. Run tests, type-checks and builds separately afterward with the shell tool.
|
|
39
41
|
|
|
40
42
|
Common operations:
|
|
41
43
|
- edit({ path, oldText, newText }) replaces exactly one literal occurrence; missing or ambiguous text is an error. Use text edits for known source, structural matching when it saves enumerating occurrences or preserves varying syntax.
|
|
@@ -43,10 +45,11 @@ Common operations:
|
|
|
43
45
|
- sg.rewrite(pattern, replacement, files?) discovers and rewrites matching code; omit files for the working directory. $X captures one node; $$$X captures a sequence.
|
|
44
46
|
- sg.one(pattern, files?) selects exactly one match; sg.find returns an array. sg.rewrite also accepts a selected match or array without a file scope.
|
|
45
47
|
- A rewrite callback receives a match and returns text, a native node.replace(text) edit, or null to skip. Return native edits to apply them. Pass selected arrays together for independent edits; select again after changing their file.
|
|
46
|
-
- await
|
|
47
|
-
- await
|
|
48
|
+
- await refactor.rename({ file, symbol, to }) renames one resolved TypeScript symbol across the project without changing unrelated names.
|
|
49
|
+
- await refactor.renameFile({ from, to }) moves a TypeScript file and updates module paths that resolve to it.
|
|
50
|
+
- await refactor.move({ file, symbol, to }) moves a top-level declaration to another file and updates the imports that follow it.
|
|
48
51
|
|
|
49
|
-
See the shorthand skill for
|
|
52
|
+
See the shorthand skill for renames, moves and call-site migrations. Read its advanced-refactors.md guide only to extract code, move syntax, use GritQL or edit other languages. The default timeout is two seconds; request more for longer programs. Time spent inside the helpers above does not count toward it, up to 60 extra seconds.`;
|
|
50
53
|
|
|
51
54
|
export default function (pi: ExtensionAPI) {
|
|
52
55
|
// A failed run is an error, both for the model and for how Pi shows it. (execute() returns its details
|
|
@@ -65,7 +68,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
65
68
|
name: "code",
|
|
66
69
|
label: "Code",
|
|
67
70
|
description: DESCRIPTION,
|
|
68
|
-
promptSnippet:
|
|
71
|
+
promptSnippet:
|
|
72
|
+
"Make multi-file, repetitive or rename/move changes with one Bun program; a small change to one file is quicker as a direct edit. Run verification separately afterward",
|
|
69
73
|
|
|
70
74
|
parameters: Type.Object({
|
|
71
75
|
title: Type.String({ description: "A few words describing the change, shown to the user" }),
|
|
@@ -82,7 +86,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
82
86
|
'On failure: "file" (default) rolls back failed or interrupted file edits and retains the others; "all" applies nothing',
|
|
83
87
|
}),
|
|
84
88
|
),
|
|
85
|
-
timeout: Type.Optional(
|
|
89
|
+
timeout: Type.Optional(
|
|
90
|
+
Type.Number({
|
|
91
|
+
description: "Seconds of program time before it is killed (default 2); time inside helpers is excluded",
|
|
92
|
+
}),
|
|
93
|
+
),
|
|
86
94
|
}),
|
|
87
95
|
|
|
88
96
|
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
@@ -313,7 +321,7 @@ function textForModel(run: RunResult, toolCallId: string): string {
|
|
|
313
321
|
// On failure the error goes last, where it's easiest to find; on success, the diff does.
|
|
314
322
|
if (run.exitCode === 0 && run.conflicts.length === 0) lines.push(...output, ...diff);
|
|
315
323
|
else lines.push(...diff, ...output);
|
|
316
|
-
if (run.diagnostics && ((run.diagnostics.wallMs ?? run.durationMs) >= run
|
|
324
|
+
if (run.diagnostics && ((run.diagnostics.wallMs ?? run.durationMs) >= timeoutBudgetMs(run) || run.exitCode !== 0))
|
|
317
325
|
lines.push("", ...diagnosticLines(run.diagnostics));
|
|
318
326
|
return lines.join("\n");
|
|
319
327
|
}
|
|
@@ -336,13 +344,17 @@ function diffForModel(run: RunResult, toolCallId: string): string[] {
|
|
|
336
344
|
const diff = run.changes.map((change) => change.patch).join("\n");
|
|
337
345
|
if (!diff) return [];
|
|
338
346
|
|
|
339
|
-
|
|
347
|
+
// A large diff is shown as one example of each distinct change, then cut to Pi's usual limits.
|
|
348
|
+
const shown = modelDiff(run.changes);
|
|
349
|
+
const truncated = truncateHead(shown.text);
|
|
340
350
|
const lines = ["", truncated.content];
|
|
341
|
-
if (truncated.truncated) {
|
|
351
|
+
if (shown.summarized || truncated.truncated) {
|
|
342
352
|
const fullDiffPath = path.join(tmpdir(), `pi-shorthand-${toolCallId}.diff`);
|
|
343
353
|
writeFileSync(fullDiffPath, diff);
|
|
344
354
|
lines.push(
|
|
345
|
-
|
|
355
|
+
truncated.truncated
|
|
356
|
+
? `[diff truncated at ${truncated.outputLines} of ${truncated.totalLines} lines; full diff: ${fullDiffPath}]`
|
|
357
|
+
: `[full diff: ${fullDiffPath}]`,
|
|
346
358
|
);
|
|
347
359
|
}
|
|
348
360
|
return lines;
|
package/lsp-client.ts
CHANGED
|
@@ -87,6 +87,9 @@ async function startTypeScriptServer(root: string): Promise<TypeScriptServer> {
|
|
|
87
87
|
await connection.sendRequest("initialize", {
|
|
88
88
|
processId: process.pid,
|
|
89
89
|
rootUri: pathToFileURL(root).href,
|
|
90
|
+
// Rename without aliases: by default a renamed declaration is re-exported as `new as old`, so importers
|
|
91
|
+
// through a barrel keep the old name. typescript-refactors.ts keeps object literal keys unchanged instead.
|
|
92
|
+
initializationOptions: { userPreferences: { providePrefixAndSuffixTextForRename: false } },
|
|
90
93
|
capabilities: {
|
|
91
94
|
workspace: {
|
|
92
95
|
workspaceEdit: { documentChanges: true },
|
package/model-diff.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The diff the model reads after a run. Small diffs are shown whole. A large diff is usually one kind of
|
|
3
|
+
* change repeated across many files, so it is shown as one example of each kind, with how many files share
|
|
4
|
+
* it; the per-file list and a path to the whole diff come with it. What people see in Pi is rendered from
|
|
5
|
+
* the result's details, not from this text.
|
|
6
|
+
*/
|
|
7
|
+
import type { FileChange } from "./runner.ts";
|
|
8
|
+
|
|
9
|
+
/** Diffs up to this size are shown whole: about 4,000 tokens. */
|
|
10
|
+
export const WHOLE_DIFF_CHARS = 16_000;
|
|
11
|
+
|
|
12
|
+
/** Changed lines, ignoring numbers and string contents, which usually differ between repeated edits. */
|
|
13
|
+
export function changeKind(patch: string): string {
|
|
14
|
+
return patch
|
|
15
|
+
.split("\n")
|
|
16
|
+
.filter((line) => /^[+-]/.test(line) && !/^(?:\+\+\+|---) /.test(line))
|
|
17
|
+
.map((line) =>
|
|
18
|
+
line
|
|
19
|
+
.replace(/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`/g, '""')
|
|
20
|
+
.replace(/\d+/g, "0")
|
|
21
|
+
.trim(),
|
|
22
|
+
)
|
|
23
|
+
.join("\n");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ModelDiff {
|
|
27
|
+
text: string;
|
|
28
|
+
/** True when some files are represented by an example rather than shown. */
|
|
29
|
+
summarized: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function modelDiff(changes: FileChange[], budget = WHOLE_DIFF_CHARS): ModelDiff {
|
|
33
|
+
const whole = changes.map((change) => change.patch).join("\n");
|
|
34
|
+
if (whole.length <= budget) return { text: whole, summarized: false };
|
|
35
|
+
|
|
36
|
+
const kinds = new Map<string, FileChange[]>();
|
|
37
|
+
for (const change of changes) {
|
|
38
|
+
const kind = `${change.kind}\0${changeKind(change.patch)}`;
|
|
39
|
+
kinds.set(kind, [...(kinds.get(kind) ?? []), change]);
|
|
40
|
+
}
|
|
41
|
+
// Rarer kinds first: an unusual edit is more likely to be the mistake worth seeing.
|
|
42
|
+
const groups = [...kinds.values()].toSorted((a, b) => a.length - b.length);
|
|
43
|
+
const sections: string[] = [];
|
|
44
|
+
let used = 0;
|
|
45
|
+
let omitted = 0;
|
|
46
|
+
for (const [example, ...same] of groups) {
|
|
47
|
+
const section = [
|
|
48
|
+
example!.patch,
|
|
49
|
+
...(same.length ? [`(and ${same.length} more file${same.length === 1 ? "" : "s"} with the same change)`] : []),
|
|
50
|
+
].join("\n");
|
|
51
|
+
if (sections.length > 0 && used + section.length > budget) {
|
|
52
|
+
omitted += 1 + same.length;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
sections.push(section);
|
|
56
|
+
used += section.length;
|
|
57
|
+
}
|
|
58
|
+
const header = `[${changes.length} files changed in ${groups.length} distinct way${groups.length === 1 ? "" : "s"}; one example of each is shown${omitted ? `, except ${omitted} file${omitted === 1 ? "" : "s"} over the size limit` : ""}]`;
|
|
59
|
+
return { text: [header, ...sections].join("\n"), summarized: true };
|
|
60
|
+
}
|