decorated-pi 0.7.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/README.md CHANGED
@@ -48,12 +48,33 @@ Drop‑in replacements for Pi's built‑in tools, with better UX and fewer waste
48
48
 
49
49
  #### Smarter `@` File Search
50
50
 
51
- | Aspect | Pi native `@` | `decorated-pi` |
51
+ `decorated-pi` replaces pi's built-in `@` autocomplete with a high-speed file finder backed by **[`@ff-labs/fff-node`](https://github.com/dmtrKovalenko/fff)**— a Rust SIMD fuzzy file search engine with in-memory index, frecency ranking, and git status awareness. Pi's native provider shells out to `fd` on every keystroke.
52
+
53
+ | Aspect | Pi native `@` | `decorated-pi` (FFF) |
52
54
  | ------ | :---: | :---: |
53
- | **Speed** | ❌ re‑scans filesystem on every trigger | ✅ caches once per `@` trigger |
54
- | **Noise filtering** | ❌ no penalty system, shows hidden files | ✅ tiered penalty auto‑filters clutter |
55
- | **Default suggestions** | ❌ all files visible on empty query | ✅ only visible project files |
56
- | **Match precision** | ❌ case‑insensitive simple scoring | ✅ multi‑level case‑sensitive scoring |
55
+ | **Speed** | ❌ walks filesystem via `fd` subprocess on every keystroke | ✅ in‑memory index built once per session, ~0.1 ms / query |
56
+ | **Ranking** | ❌ 4‑bucket case‑sensitive score (exact/starts/contains/path) | ✅ fuzzy match + frecency + git status (boots from git log) |
57
+ | **Noise** | ❌ shows every file in the project, including `.git`, `node_modules`, `dist` | ✅ filters `git‑ignored` files; substring filter on path keeps short queries relevant |
58
+
59
+ ###### Benchmark of `@`
60
+
61
+ ```
62
+ ┌─ smart-at benchmark
63
+ ├─ generated 500,000 files in 3.9 s
64
+ ├─ FFF scan complete in 664 ms
65
+ │ RSS after FFF index: 408 MB (+330 MB over baseline)
66
+ ├─ accuracy (14 queries)
67
+ │ top‑1 top‑3 top‑5 false‑pos
68
+ │ smart-at 93% 93% 93% 0
69
+ │ native (fd) 86% 93% 93% 0
70
+ └─ corpus cleaned
71
+
72
+ name hz mean
73
+ · smart-at 8.10 123 ms
74
+ · native @ (fd subprocess) 3.12 320 ms
75
+
76
+ Summary: smart-at is 2.59x faster than native @ (fd subprocess)
77
+ ```
57
78
 
58
79
  #### LSP support
59
80
 
package/hooks/smart-at.ts CHANGED
@@ -1,318 +1,168 @@
1
1
  /**
2
2
  * smart-at — high-speed file search autocomplete.
3
3
  *
4
- * Triggered on session_start by registering an autocomplete provider.
5
- * Uses git ls-files (in git repos) or fd (elsewhere) for candidates,
6
- * then fuzzy-matches with path penalties.
4
+ * Uses @ff-labs/fff-node (FFF) for cross-platform fuzzy file search.
5
+ * FFF maintains an in-memory index, scores files by fuzzy match +
6
+ * frecency + git status, and returns ranked results. We create one
7
+ * FileFinder per session and query it directly for every @ prefix.
7
8
  */
8
9
 
9
- import { spawnSync } from "child_process";
10
- import { existsSync } from "node:fs";
11
- import { join } from "node:path";
12
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
10
+ import { FileFinder, type MixedItem } from "@ff-labs/fff-node";
11
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
13
12
  import type { Module, Skeleton } from "./skeleton.js";
14
13
 
15
- // ─── Types ────────────────────────────────────────────────────────────────
14
+ // Characters that, when preceding an "@", allow us to start a smart-at
15
+ // completion.
16
+ const AT_BOUNDARY = new Set<string>([" ", "\t", "(", "["]);
16
17
 
17
- type PenaltyTier = 0 | 1 | 2 | 3 | 4;
18
+ const AUTOCOMPLETE_LIMIT = 20;
19
+ const FFF_SUPERSET = 100;
20
+ const WIDGET_FOOTER = "\x1b[2mpowered by fff\x1b[0m";
18
21
 
19
- interface FileCandidate {
20
- path: string;
21
- name: string;
22
- isDir: boolean;
23
- tier: PenaltyTier;
24
- penalty: number;
22
+ interface AutocompleteItem {
23
+ value: string;
24
+ label: string;
25
+ description?: string;
25
26
  }
26
27
 
27
- // ─── Hard-exclude & penalty rules ────────────────────────────────────────
28
-
29
- const HARD_EXCLUDE_DIRS = new Set(["node_modules", ".git", ".pnpm", ".svn"]);
30
-
31
- const BAD_DIRS = new Set(["build", "dist", "coverage", "out", "target"]);
32
-
33
- const EXT_PENALTY: Record<string, number> = {
34
- o: -150, obj: -150, a: -150, so: -150, dll: -150, exe: -150,
35
- wasm: -150, class: -120, pyc: -120,
36
- bmp: -100, png: -100, jpg: -100, gif: -100, ico: -100, svg: -80,
37
- mp3: -100, wav: -100, mp4: -100, avi: -100,
38
- pdf: -100, zip: -100, tar: -100, gz: -100,
39
- lock: -80,
40
- };
41
-
42
- // ─── Penalty pre-compute ─────────────────────────────────────────────────
43
-
44
- function computePenaltyMeta(filePath: string, isDir: boolean, gitIgnored: boolean): { tier: PenaltyTier; penalty: number } {
45
- const parts = filePath.replace(/\/$/, "").split("/");
46
- const name = parts[parts.length - 1] || filePath;
47
- const ext = (!isDir && name.includes(".")) ? (name.split(".").pop()?.toLowerCase() || "") : "";
48
- const depth = parts.length;
49
- const dirSegments = isDir ? parts : parts.slice(0, -1);
50
-
51
- let tier: PenaltyTier = 0;
52
- let tierPenalty = 0;
53
-
54
- if (gitIgnored) { tier = 1; tierPenalty = -400; }
55
- else if (dirSegments.some(d => d.startsWith(".") || d.startsWith("__"))) { tier = 2; tierPenalty = -300; }
56
- else if (dirSegments.some(d => BAD_DIRS.has(d))) { tier = 3; tierPenalty = -200; }
57
- else if (!isDir && (EXT_PENALTY[ext] ?? 0) < 0) { tier = 4; tierPenalty = EXT_PENALTY[ext]!; }
58
-
59
- const basePenalty = -(depth * 30) - name.length;
60
- return { tier, penalty: tierPenalty + basePenalty };
61
- }
62
-
63
- function computePenalty(filePath: string, isDir: boolean, gitIgnored: boolean): number {
64
- return computePenaltyMeta(filePath, isDir, gitIgnored).penalty;
65
- }
66
-
67
- // ─── Candidate collection ────────────────────────────────────────────────
68
-
69
- const SPAWN_OPTS = { timeout: 5000, encoding: "utf-8" as const, maxBuffer: 10 * 1024 * 1024 };
70
-
71
- function isGitWorkTree(cwd: string): boolean {
72
- return spawnSync("git", ["rev-parse", "--is-inside-work-tree"], { ...SPAWN_OPTS, cwd }).status === 0;
73
- }
74
-
75
- function commandExists(command: string): boolean {
76
- const result = spawnSync(
77
- process.platform === "win32" ? "where" : (process.env.SHELL || "sh"),
78
- process.platform === "win32" ? [command] : ["-lc", `command -v '${command.replace(/'/g, `'"'"'`)}'`],
79
- SPAWN_OPTS,
80
- );
81
- return result.status === 0;
82
- }
83
-
84
- function collectCandidates(cwd: string): FileCandidate[] {
85
- const candidates: FileCandidate[] = [];
86
- const isChildOfHardExclude = (p: string): boolean => {
87
- const parts = p.replace(/\/$/, "").split("/");
88
- for (let i = 0; i < parts.length - 1; i++) {
89
- if (HARD_EXCLUDE_DIRS.has(parts[i]!)) return true;
90
- }
91
- return false;
92
- };
93
-
94
- const opts = { ...SPAWN_OPTS, cwd };
95
- const isGit = isGitWorkTree(cwd);
96
-
97
- if (isGit) {
98
- const r1 = spawnSync("git", ["ls-files", "--cached", "--others", "--exclude-standard"], opts);
99
- const visibleFiles = (r1.status === 0 && r1.stdout)
100
- ? r1.stdout.trim().split("\n").filter(Boolean) : [];
101
-
102
- const r2 = spawnSync("git", ["ls-files", "--others", "--ignored", "--exclude-standard", "--directory", "--no-empty-directory"], opts);
103
- const ignoredDirs = new Set<string>();
104
- const ignoredFiles: string[] = [];
105
- if (r2.status === 0 && r2.stdout) {
106
- for (const raw of r2.stdout.trim().split("\n").filter(Boolean)) {
107
- const entry = raw.replace(/^\.\//, "");
108
- if (isChildOfHardExclude(entry)) continue;
109
- if (entry.endsWith("/")) ignoredDirs.add(entry.replace(/\/$/, ""));
110
- else ignoredFiles.push(entry);
111
- }
112
- }
113
-
114
- const dirSet = new Set<string>();
115
- for (const f of visibleFiles) {
116
- if (isChildOfHardExclude(f)) continue;
117
- const name = f.split("/").pop() || f;
118
- const meta = computePenaltyMeta(f, false, false);
119
- candidates.push({ path: f, name, isDir: false, tier: meta.tier, penalty: meta.penalty });
120
- const parts = f.split("/");
121
- let current = "";
122
- for (let i = 0; i < parts.length - 1; i++) {
123
- current = current ? `${current}/${parts[i]}` : parts[i]!;
124
- dirSet.add(current);
125
- }
126
- }
127
- for (const f of ignoredFiles) {
128
- const name = f.split("/").pop() || f;
129
- const meta = computePenaltyMeta(f, false, true);
130
- candidates.push({ path: f, name, isDir: false, tier: meta.tier, penalty: meta.penalty });
131
- }
132
- for (const d of dirSet) {
133
- const name = d.split("/").pop() || d;
134
- const meta = computePenaltyMeta(d, true, false);
135
- candidates.push({ path: d + "/", name, isDir: true, tier: meta.tier, penalty: meta.penalty });
136
- }
137
- for (const d of ignoredDirs) {
138
- const name = d.split("/").pop() || d;
139
- const meta = computePenaltyMeta(d, true, true);
140
- candidates.push({ path: d + "/", name, isDir: true, tier: meta.tier, penalty: meta.penalty });
141
- }
142
- for (const hd of HARD_EXCLUDE_DIRS) {
143
- if (!existsSync(join(opts.cwd, hd))) continue;
144
- const meta = computePenaltyMeta(hd, true, false);
145
- candidates.push({ path: hd + "/", name: hd, isDir: true, tier: meta.tier, penalty: meta.penalty });
146
- }
147
- } else {
148
- const rel = (s: string) => {
149
- let r = s.startsWith(cwd + "/") ? s.slice(cwd.length + 1) : s;
150
- return r.startsWith("./") ? r.slice(2) : r;
151
- };
152
- const fdExcludes = [...HARD_EXCLUDE_DIRS].flatMap(d => ["--exclude", d]);
153
- const fdOpts = { ...SPAWN_OPTS, cwd: undefined as string | undefined };
154
-
155
- const r1 = spawnSync("fd", ["--type", "f", "--hidden", "--no-ignore", ...fdExcludes, ".", cwd], fdOpts);
156
- if (r1.status === 0 && r1.stdout) {
157
- for (const raw of r1.stdout.trim().split("\n").filter(Boolean)) {
158
- const f = rel(raw);
159
- const name = f.split("/").pop() || f;
160
- const meta = computePenaltyMeta(f, false, false);
161
- candidates.push({ path: f, name, isDir: false, tier: meta.tier, penalty: meta.penalty });
162
- }
163
- }
164
- const r2 = spawnSync("fd", ["--type", "d", "--hidden", "--no-ignore", ...fdExcludes, ".", cwd], fdOpts);
165
- if (r2.status === 0 && r2.stdout) {
166
- for (const raw of r2.stdout.trim().split("\n").filter(Boolean)) {
167
- const d = rel(raw).replace(/\/$/, "");
168
- const name = d.split("/").pop() || d;
169
- const meta = computePenaltyMeta(d, true, false);
170
- candidates.push({ path: d + "/", name, isDir: true, tier: meta.tier, penalty: meta.penalty });
171
- }
172
- }
173
- for (const hd of HARD_EXCLUDE_DIRS) {
174
- if (!existsSync(join(cwd, hd))) continue;
175
- const meta = computePenaltyMeta(hd, true, false);
176
- candidates.push({ path: hd + "/", name: hd, isDir: true, tier: meta.tier, penalty: meta.penalty });
28
+ function atPrefix(text: string): string | null {
29
+ for (let i = text.length - 1; i >= 0; i--) {
30
+ if (text[i] !== "@") continue;
31
+ const b = text[i - 1];
32
+ if (i === 0 || AT_BOUNDARY.has(b)) {
33
+ return text.slice(i);
177
34
  }
35
+ return null;
178
36
  }
179
- return candidates;
37
+ return null;
180
38
  }
181
39
 
182
- // ─── Match scoring ───────────────────────────────────────────────────────
183
-
184
- function fuzzyScore(text: string, query: string): number {
185
- let qi = 0, firstMatch = -1, lastMatch = -1;
186
- for (let ti = 0; ti < text.length && qi < query.length; ti++) {
187
- if (text[ti] === query[qi]) {
188
- if (firstMatch < 0) firstMatch = ti;
189
- lastMatch = ti;
190
- qi++;
191
- }
192
- }
193
- if (qi < query.length) return 0;
194
- const span = lastMatch - firstMatch + 1;
195
- return Math.max(10, 200 - span * 3 - text.length);
40
+ function isGitIgnored(item: MixedItem): boolean {
41
+ return item.type === "file" && item.item.gitStatus === "ignored";
196
42
  }
197
43
 
198
- function computeMatchScore(candidate: FileCandidate, query: string): number {
199
- const { path: filePath, name, isDir } = candidate;
200
- const stem = name.replace(/\.[^.]+$/, "");
201
- const parts = filePath.replace(/\/$/, "").split("/");
202
- const inDir = parts.slice(0, -1).some(d => d.includes(query));
203
-
204
- let s = 0;
205
- if (stem === query) s = isDir ? 1500 : 1050;
206
- else if (name.startsWith(query + ".") || name.startsWith(query + "_")) s = 1000;
207
- else if (name.startsWith(query)) s = 900;
208
- else if (name.includes(query)) s = 600;
209
- else if (filePath.includes(query)) s = 300;
210
- else s = fuzzyScore(name, query);
211
-
212
- if (!s) return 0;
213
- if (isDir) s += 100;
214
- if (inDir) s += 50;
215
- return s;
44
+ function toAutocompleteItem(item: MixedItem): AutocompleteItem {
45
+ const path = item.item.relativePath;
46
+ const label = item.type === "file"
47
+ ? item.item.fileName
48
+ : path.replace(/\/+$/, "").split("/").pop() || path;
49
+ return {
50
+ value: "@" + path,
51
+ label,
52
+ description: path,
53
+ };
216
54
  }
217
55
 
218
- function smartSearch(candidates: FileCandidate[], query: string): string[] {
219
- if (!query) {
220
- const visible = candidates.filter(c => c.tier === 0 || c.tier === 3 || c.tier === 4);
221
- return visible
222
- .sort((a, b) => {
223
- if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
224
- return b.penalty - a.penalty || a.path.localeCompare(b.path);
225
- })
226
- .slice(0, 20)
227
- .map(c => c.path);
228
- }
229
- const tokens = query.split(/\s+/).filter(Boolean);
230
- if (tokens.length === 1) {
231
- const scored = candidates
232
- .map(c => ({ path: c.path, total: computeMatchScore(c, tokens[0]!) + c.penalty, matchScore: computeMatchScore(c, tokens[0]!) }))
233
- .filter(x => x.matchScore > 0);
234
- return scored
235
- .sort((a, b) => b.total - a.total || a.path.localeCompare(b.path))
236
- .slice(0, 20)
237
- .map(x => x.path);
238
- }
239
- const seen = new Set<string>();
240
- for (const t of tokens) {
241
- const scored = candidates
242
- .map(c => ({ path: c.path, total: computeMatchScore(c, t) + c.penalty, matchScore: computeMatchScore(c, t) }))
243
- .filter(x => x.matchScore > 0)
244
- .sort((a, b) => b.total - a.total)
245
- .slice(0, 20);
246
- for (const { path } of scored) seen.add(path);
247
- }
248
- return [...seen].slice(0, 20);
56
+ interface BuiltResult {
57
+ items: AutocompleteItem[];
249
58
  }
250
59
 
251
- function atPrefix(text: string): string | null {
252
- for (let i = text.length - 1; i >= 0; i--) {
253
- if (text[i] !== "@") continue;
254
- const b = text[i - 1];
255
- if (i === 0 || b === " " || b === "\t" || b === "(" || b === "[") return text.slice(i);
256
- return null;
257
- }
258
- return null;
60
+ /** Take FFF's ranked results, drop git-ignored files, and for non-empty
61
+ * queries keep only paths that contain the query as a substring. This
62
+ * stops FFF's loose fuzzy matching from ranking every file in a directory
63
+ * that happens to share a few letters (e.g. "mm/" for query "mmc"). */
64
+ function buildResult(items: MixedItem[], lowerQuery: string): BuiltResult | null {
65
+ const filtered = items
66
+ .filter((it) => !isGitIgnored(it))
67
+ .filter(
68
+ (it) =>
69
+ !lowerQuery || it.item.relativePath.toLowerCase().includes(lowerQuery),
70
+ )
71
+ .slice(0, AUTOCOMPLETE_LIMIT)
72
+ .map(toAutocompleteItem);
73
+ return filtered.length ? { items: filtered } : null;
259
74
  }
260
75
 
261
- export const __smartAtTest = {
262
- computePenaltyMeta, computePenalty, fuzzyScore, computeMatchScore, smartSearch, atPrefix,
263
- };
76
+ export const __smartAtTest = { atPrefix };
77
+
78
+ /** Active FileFinder for the current session; freed in session_shutdown
79
+ * to avoid leaking FFF's native handle + LMDB mmap regions. */
80
+ let currentFinder: FileFinder | null = null;
264
81
 
265
82
  export const smartAtModule: Module = {
266
83
  name: "smart-at",
267
84
  hooks: {
268
85
  session_start: [
269
- (_event: any, ctx: ExtensionContext) => {
86
+ async (_event: any, ctx: ExtensionContext) => {
270
87
  const cwd = String(ctx.cwd || "").trim();
271
- let cache: FileCandidate[] | null = null;
88
+ const created = FileFinder.create({ basePath: cwd || "." });
89
+ if (!created.ok) {
90
+ // FFF not available on this platform; silently skip.
91
+ return;
92
+ }
93
+
94
+ const finder = created.value;
95
+ currentFinder = finder;
272
96
 
273
- const getOrBuildCache = () => (cache ??= collectCandidates(cwd));
274
- const clearCache = () => { cache = null; };
97
+ // Start the scan in the background. We don't wait for it here so
98
+ // session_start returns immediately; the provider queries the
99
+ // (possibly partial) index and FFF returns whatever it has indexed
100
+ // so far. Full results appear as the scan progresses.
101
+ void finder.waitForScan(60_000);
275
102
 
276
103
  ctx.ui.addAutocompleteProvider((orig: any) => ({
277
- getSuggestions: (lines: any, cl: any, cc: any, opts: any) => {
104
+ getSuggestions: (
105
+ lines: string[],
106
+ cl: number,
107
+ cc: number,
108
+ opts?: { signal?: AbortSignal },
109
+ ) => {
278
110
  const prefix = atPrefix((lines[cl] || "").slice(0, cc));
279
111
  if (!prefix) {
280
- clearCache();
281
112
  ctx.ui.setWidget("smart-at", undefined);
282
113
  return orig.getSuggestions(lines, cl, cc, opts);
283
114
  }
284
- const candidates = getOrBuildCache();
285
- const results = smartSearch(candidates, prefix.slice(1));
286
- if (!results.length) {
115
+
116
+ if (currentFinder !== finder || finder.isDestroyed) {
287
117
  ctx.ui.setWidget("smart-at", undefined);
288
- return null;
118
+ return orig.getSuggestions(lines, cl, cc, opts);
289
119
  }
290
- ctx.ui.setWidget("smart-at", ["\x1b[2mpowered by decorated-pi\x1b[0m"]);
291
- return Promise.resolve({
292
- items: results.map((f: string) => ({
293
- value: "@" + f,
294
- label: f.replace(/\/$/, "").split("/").pop() || f,
295
- description: f,
296
- })),
297
- prefix,
120
+
121
+ // Respect upstream cancellation so fast typing doesn't pile up
122
+ // expensive FFF queries.
123
+ if (opts?.signal?.aborted) {
124
+ return orig.getSuggestions(lines, cl, cc, opts);
125
+ }
126
+
127
+ const query = prefix.slice(1);
128
+ const lowerQuery = query.toLowerCase();
129
+ const r = finder.mixedSearch(lowerQuery, {
130
+ pageSize: lowerQuery ? FFF_SUPERSET : AUTOCOMPLETE_LIMIT,
298
131
  });
132
+ if (!r.ok) {
133
+ ctx.ui.setWidget("smart-at", undefined);
134
+ return null;
135
+ }
136
+
137
+ const result = buildResult(r.value.items, lowerQuery);
138
+ if (!result) {
139
+ ctx.ui.setWidget("smart-at", undefined);
140
+ return null;
141
+ }
142
+
143
+ ctx.ui.setWidget("smart-at", [WIDGET_FOOTER]);
144
+ return Promise.resolve({ ...result, prefix });
299
145
  },
300
146
  applyCompletion: (...args: any[]) => {
301
- clearCache();
302
147
  ctx.ui.setWidget("smart-at", undefined);
303
148
  return orig.applyCompletion.apply(orig, args);
304
149
  },
305
- shouldTriggerFileCompletion: orig.shouldTriggerFileCompletion?.bind(orig),
150
+ shouldTriggerFileCompletion:
151
+ orig.shouldTriggerFileCompletion?.bind(orig),
306
152
  }));
307
153
  },
308
154
  ],
155
+ session_shutdown: [
156
+ (_event: any, _ctx: ExtensionContext) => {
157
+ if (currentFinder && !currentFinder.isDestroyed) {
158
+ currentFinder.destroy();
159
+ }
160
+ currentFinder = null;
161
+ },
162
+ ],
309
163
  },
310
164
  };
311
165
 
312
166
  export function setupSmartAt(sk: Skeleton): void {
313
- sk.declareDependency({
314
- label: "fd",
315
- check: () => commandExists("fd") || isGitWorkTree(process.cwd()),
316
- });
317
167
  sk.register(smartAtModule);
318
168
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "decorated-pi",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "description": "decorated-pi is a practical enhancement pack for pi coding agent — token-efficient workflow, cache-friendly design, and smarter tools.",
5
5
  "keywords": [
6
6
  "pi",
@@ -12,7 +12,11 @@
12
12
  "lsp",
13
13
  "lsp-client",
14
14
  "secret-redaction",
15
- "codegraph"
15
+ "codegraph",
16
+ "fff",
17
+ "smart-at",
18
+ "fuzzy-search",
19
+ "file-finder"
16
20
  ],
17
21
  "license": "MIT",
18
22
  "repository": {
@@ -22,6 +26,7 @@
22
26
  "homepage": "https://github.com/lcwecker/decorated-pi#readme",
23
27
  "bugs": "https://github.com/lcwecker/decorated-pi/issues",
24
28
  "dependencies": {
29
+ "@ff-labs/fff-node": "^0.9.4",
25
30
  "@modelcontextprotocol/sdk": "^1.29.0",
26
31
  "file-type": "^21.3.4",
27
32
  "openai": "^6.37.0"
@@ -39,13 +44,15 @@
39
44
  },
40
45
  "devDependencies": {
41
46
  "@types/node": "^22.15.3",
47
+ "@vitest/coverage-v8": "4.1.8",
42
48
  "depcheck": "^1.4.7",
43
49
  "publint": "^0.3.21",
44
- "vitest": "^4.1.6"
50
+ "vitest": "4.1.8"
45
51
  },
46
52
  "scripts": {
47
53
  "prepack": "depcheck --fail-on-missing",
48
- "test": "vitest run",
54
+ "test": "vitest run --coverage",
55
+ "bench": "vitest bench --run",
49
56
  "typecheck": "tsc --noEmit",
50
57
  "prepublishOnly": "npm install --no-audit --no-fund && npm test && npm run typecheck && depcheck --fail-on-missing && publint"
51
58
  }
@@ -1076,12 +1076,18 @@ interface LineRange {
1076
1076
  endLine: number;
1077
1077
  }
1078
1078
 
1079
- /** Build line offset table: offsets[i] = character offset of line i+1 (1-based) */
1079
+ /** Build line offset table: offsets[i] = character offset of line i+1 (1-based).
1080
+ * If the content does not end with a newline, the final line has no
1081
+ * trailing marker; push an extra offset at content.length so callers
1082
+ * like lineAtOffset / extractLineRange handle the last line correctly. */
1080
1083
  function buildLineOffsets(content: string): number[] {
1081
1084
  const offsets = [0];
1082
1085
  for (let i = 0; i < content.length; i++) {
1083
1086
  if (content[i] === "\n") offsets.push(i + 1);
1084
1087
  }
1088
+ if (content.length > 0 && content[content.length - 1] !== "\n") {
1089
+ offsets.push(content.length);
1090
+ }
1085
1091
  return offsets;
1086
1092
  }
1087
1093
 
@@ -215,8 +215,9 @@ export function registerPatchTool(pi: ExtensionAPI): void {
215
215
  ].join("\n"),
216
216
  promptSnippet: "Edits a file using exact string replacement, with anchor support.",
217
217
  promptGuidelines: [
218
- "Always prefer modifying files with patch tool over bash commands or python scripts.",
219
- "To prevent hallucinations: 1. Keep each edit batch 5 changes; 2. Process remaining revisions in sequential steps",
218
+ "The patch tool is the preferred way to modify files use it over the write tool (full overwrite) or the bash tool (sed/echo).",
219
+ "When editing an existing file, use patch instead of write to avoid overwriting changes made since the file was last read.",
220
+ "To prevent hallucinations: 1. Keep each edit batch ≤ 5 changes; 2. Process remaining revisions in sequential steps.",
220
221
  "On repeated failures: read the file first to confirm information accuracy.",
221
222
  ],
222
223
  parameters: PatchSchema,