pi-shorthand 0.6.0 → 0.7.1

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 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 ts: ShorthandGlobals["ts"];
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.timeoutMs || run.exitCode !== 0)) {
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.timeoutMs) return undefined;
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.timeoutMs / 1000}s` : "Failed";
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.timeoutMs / 1000}s`
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 command = formatterFor(file, root);
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 ts.rename({ file, symbol, to }) renames one resolved TypeScript symbol across the project without changing unrelated names.
47
- - await ts.renameFile({ from, to }) moves a TypeScript file and updates module paths that resolve to it.
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 common writes. For extraction, complex rewrites or other languages, read its advanced-refactors.md guide. The default timeout is two seconds; request more for longer programs.`;
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: "Make a change with one Bun editing program; run verification separately afterward",
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(Type.Number({ description: "Seconds before the program is killed (default 2)" })),
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.timeoutMs || run.exitCode !== 0))
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
- const truncated = truncateHead(diff);
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
- `[diff truncated at ${truncated.outputLines} of ${truncated.totalLines} lines; full diff: ${fullDiffPath}]`,
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/linux-observer.c CHANGED
@@ -94,17 +94,20 @@ static void capture(const char *file, char kind) {
94
94
  if (*relative == '/') relative++;
95
95
  request(kind, *relative ? relative : ".", "");
96
96
  }
97
- static void trace_string(pid_t pid, uint64_t address, char result[PATH_MAX]) {
97
+ /* Returns 0 when the stopped thread is gone (ESRCH): SIGKILL or a sibling's exec retired it, so the syscall it
98
+ * stopped in never executes and there is nothing to observe. */
99
+ static int trace_string(pid_t pid, uint64_t address, char result[PATH_MAX]) {
98
100
  for (size_t offset = 0; offset < PATH_MAX;) {
99
101
  uintptr_t location = address + offset;
100
102
  size_t skip = location % sizeof(long);
101
103
  errno = 0;
102
104
  long word = ptrace(PTRACE_PEEKDATA, pid, (void *)(location - skip), 0);
105
+ if (errno == ESRCH) return 0;
103
106
  if (errno) die("cannot inspect syscall path");
104
107
  size_t count = sizeof(word) - skip;
105
108
  if (count > PATH_MAX - offset) count = PATH_MAX - offset;
106
109
  memcpy(result + offset, (char *)&word + skip, count);
107
- if (memchr((char *)&word + skip, 0, count)) return;
110
+ if (memchr((char *)&word + skip, 0, count)) return 1;
108
111
  offset += count;
109
112
  }
110
113
  die("overlong syscall path");
@@ -267,8 +270,7 @@ static void resolve_name(pid_t pid, int fd, const char *input, int follow, char
267
270
  }
268
271
  static void resolve_path(pid_t pid, int fd, uint64_t address, int follow, char result[PATH_MAX]) {
269
272
  char input[PATH_MAX];
270
- if (!address) { result[0] = 0; return; }
271
- trace_string(pid, address, input);
273
+ if (!address || !trace_string(pid, address, input)) { result[0] = 0; return; }
272
274
  resolve_name(pid, fd, input, follow, result);
273
275
  }
274
276
 
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
+ }