@trim21/personal-pi-extensions 0.0.386 → 0.0.389
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/package.json
CHANGED
|
@@ -18,6 +18,10 @@ import { Value } from "typebox/value";
|
|
|
18
18
|
const fileSnapshotSchema = Type.Object({
|
|
19
19
|
digest: Type.String(),
|
|
20
20
|
textEditable: Type.Boolean(),
|
|
21
|
+
// 以下字段仅 Read 写入,供同范围重复读取 dedup;Edit/Write 不写,
|
|
22
|
+
// 覆盖记录后 offset 缺省 → 不再 dedup,强制重新 Read(对齐 CC readFileState)
|
|
23
|
+
offset: Type.Optional(Type.Number()),
|
|
24
|
+
limit: Type.Optional(Type.Number()),
|
|
21
25
|
});
|
|
22
26
|
|
|
23
27
|
export type FileSnapshot = Static<typeof fileSnapshotSchema>;
|
|
@@ -114,3 +114,12 @@ export function preserveQuoteStyle(
|
|
|
114
114
|
if (hasSingleQuotes) result = applyCurlySingleQuotes(result);
|
|
115
115
|
return result;
|
|
116
116
|
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* patch 显示用:把行首 tab 转成 2 空格(对齐 Claude Code 的
|
|
120
|
+
* convertLeadingTabsToSpaces)。仅用于 details 里展示的 diff,不影响写盘内容。
|
|
121
|
+
*/
|
|
122
|
+
export function convertLeadingTabsToSpaces(content: string): string {
|
|
123
|
+
if (!content.includes("\t")) return content;
|
|
124
|
+
return content.replaceAll(/^\t+/gm, (tabs) => " ".repeat(tabs.length));
|
|
125
|
+
}
|
package/src/claude-code/files.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import { constants, readFileSync } from "node:fs";
|
|
2
|
+
import { constants, readFileSync, type Stats } from "node:fs";
|
|
3
3
|
import { access, mkdir, readFile, realpath, stat, writeFile } from "node:fs/promises";
|
|
4
4
|
import { dirname, extname } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
@@ -26,10 +26,14 @@ import {
|
|
|
26
26
|
snapshotsEqual,
|
|
27
27
|
throwIfAborted,
|
|
28
28
|
} from "./common.js";
|
|
29
|
-
import { findActualString, preserveQuoteStyle } from "./edit-utils.js";
|
|
29
|
+
import { convertLeadingTabsToSpaces, findActualString, preserveQuoteStyle } from "./edit-utils.js";
|
|
30
30
|
|
|
31
31
|
const SAMPLE_BYTES = 4096;
|
|
32
32
|
|
|
33
|
+
/** 同范围重复读取、文件未变时返回的 stub(对齐 Claude Code 的 file_unchanged)。 */
|
|
34
|
+
export const FILE_UNCHANGED_STUB =
|
|
35
|
+
"File unchanged since last read. The content from the earlier Read tool_result in this conversation is still current — refer to that instead of re-reading.";
|
|
36
|
+
|
|
33
37
|
/** Read 全读时的文件大小上限(对齐 Claude Code 的 256KB)。 */
|
|
34
38
|
const MAX_READ_SIZE_BYTES = 0.25 * 1024 * 1024;
|
|
35
39
|
/** Read 输出 token 粗估上限(对齐 Claude Code 的 25K tokens;无 tokenizer,按 4 字符/token 估算)。 */
|
|
@@ -75,8 +79,11 @@ function snapshotOf(content: Uint8Array | string, textEditable = true): FileSnap
|
|
|
75
79
|
return { digest: createHash("sha256").update(content).digest("hex"), textEditable };
|
|
76
80
|
}
|
|
77
81
|
|
|
78
|
-
async function assertReadableFile(filePath: string): Promise<
|
|
82
|
+
async function assertReadableFile(filePath: string): Promise<Stats> {
|
|
79
83
|
const value = await stat(filePath);
|
|
84
|
+
if (value.isDirectory()) {
|
|
85
|
+
throw new Error(`EISDIR: illegal operation on a directory, read '${filePath}'`);
|
|
86
|
+
}
|
|
80
87
|
if (!value.isFile()) throw new Error(`File not found: ${filePath}`);
|
|
81
88
|
await access(filePath, constants.R_OK);
|
|
82
89
|
return value;
|
|
@@ -130,8 +137,10 @@ export function formatReadOutput(
|
|
|
130
137
|
totalLines,
|
|
131
138
|
};
|
|
132
139
|
}
|
|
140
|
+
// offset=0 时从第一行开始、行号从 0 起(对齐 Claude Code 的 lineOffset 语义)
|
|
141
|
+
const startIndex = offset === 0 ? 0 : offset - 1;
|
|
133
142
|
const selected =
|
|
134
|
-
limit === undefined ? lines.slice(
|
|
143
|
+
limit === undefined ? lines.slice(startIndex) : lines.slice(startIndex, startIndex + limit);
|
|
135
144
|
const text = selected.map((line, index) => `${offset + index}\t${line}`).join("\n");
|
|
136
145
|
return { text, totalLines };
|
|
137
146
|
}
|
|
@@ -247,9 +256,9 @@ export function registerFileTools(
|
|
|
247
256
|
const filePath = requireAbsolutePath(params.file_path);
|
|
248
257
|
if (
|
|
249
258
|
params.offset !== undefined &&
|
|
250
|
-
(!Number.isSafeInteger(params.offset) || params.offset <
|
|
259
|
+
(!Number.isSafeInteger(params.offset) || params.offset < 0)
|
|
251
260
|
) {
|
|
252
|
-
throw new Error("offset must be a
|
|
261
|
+
throw new Error("offset must be a non-negative integer");
|
|
253
262
|
}
|
|
254
263
|
if (params.limit !== undefined && (!Number.isSafeInteger(params.limit) || params.limit < 1)) {
|
|
255
264
|
throw new Error("limit must be a positive integer");
|
|
@@ -289,7 +298,23 @@ export function registerFileTools(
|
|
|
289
298
|
return { content, details: { reads: { [key]: snapshot } } };
|
|
290
299
|
}
|
|
291
300
|
|
|
301
|
+
const offset = params.offset ?? 1;
|
|
302
|
+
const limit = params.limit;
|
|
303
|
+
const key = await readStateKey(filePath);
|
|
292
304
|
const buffer = await readFile(filePath);
|
|
305
|
+
|
|
306
|
+
// 同范围 + checksum 未变 → 返回 stub 而非重发内容(对齐 CC readFileState;
|
|
307
|
+
// 复用 reads 里的 sha256,比 mtime 可靠,无时间片粒度问题)
|
|
308
|
+
const previous = state.reads.get(key);
|
|
309
|
+
if (
|
|
310
|
+
previous !== undefined &&
|
|
311
|
+
previous.offset !== undefined &&
|
|
312
|
+
previous.offset === offset &&
|
|
313
|
+
previous.limit === limit &&
|
|
314
|
+
snapshotsEqual(previous, snapshotOf(buffer))
|
|
315
|
+
) {
|
|
316
|
+
return { content: [{ type: "text", text: FILE_UNCHANGED_STUB }], details: {} };
|
|
317
|
+
}
|
|
293
318
|
if (isBinary(buffer.subarray(0, SAMPLE_BYTES)))
|
|
294
319
|
throw new Error(`Cannot read binary file: ${filePath}`);
|
|
295
320
|
// 全读(limit 未传)时受字节上限约束(对齐 Claude Code)
|
|
@@ -299,7 +324,7 @@ export function registerFileTools(
|
|
|
299
324
|
);
|
|
300
325
|
}
|
|
301
326
|
const text = buffer.toString("utf8");
|
|
302
|
-
const formatted = formatReadOutput(text,
|
|
327
|
+
const formatted = formatReadOutput(text, offset, limit);
|
|
303
328
|
// 输出 token 粗估上限(无 tokenizer,4 字符/token),对读取范围生效
|
|
304
329
|
const estimatedTokens = Math.ceil(formatted.text.length / 4);
|
|
305
330
|
if (estimatedTokens > MAX_READ_TOKENS) {
|
|
@@ -307,8 +332,7 @@ export function registerFileTools(
|
|
|
307
332
|
`File content (${estimatedTokens} tokens) exceeds maximum allowed tokens (${MAX_READ_TOKENS}). Use offset and limit parameters to read specific portions of the file, or search for specific content instead of reading the whole file.`,
|
|
308
333
|
);
|
|
309
334
|
}
|
|
310
|
-
const snapshot = snapshotOf(buffer);
|
|
311
|
-
const key = await readStateKey(filePath);
|
|
335
|
+
const snapshot = { ...snapshotOf(buffer), offset, limit };
|
|
312
336
|
state.reads.set(key, snapshot);
|
|
313
337
|
// LSP warm-up 是后台任务,失败不影响读取
|
|
314
338
|
void service.touchFile(filePath, ctx.cwd).catch(() => {
|
|
@@ -388,7 +412,7 @@ export function registerFileTools(
|
|
|
388
412
|
const snapshot = snapshotOf(newString);
|
|
389
413
|
const key = await readStateKey(filePath);
|
|
390
414
|
state.reads.set(key, snapshot);
|
|
391
|
-
const diff = generateDiffString("", newString);
|
|
415
|
+
const diff = generateDiffString("", convertLeadingTabsToSpaces(newString));
|
|
392
416
|
throwIfAborted(signal);
|
|
393
417
|
const diagnosticText = await service.lspDiagnosticsForFile(filePath, ctx.cwd, {
|
|
394
418
|
notify: (message, level) => ctx.ui.notify(message, level),
|
|
@@ -398,7 +422,7 @@ export function registerFileTools(
|
|
|
398
422
|
`The file ${filePath} has been updated successfully.`,
|
|
399
423
|
{
|
|
400
424
|
diff: diff.diff,
|
|
401
|
-
patch: generateUnifiedPatch(filePath, "", newString),
|
|
425
|
+
patch: generateUnifiedPatch(filePath, "", convertLeadingTabsToSpaces(newString)),
|
|
402
426
|
firstChangedLine: diff.firstChangedLine,
|
|
403
427
|
reads: { [key]: snapshot },
|
|
404
428
|
},
|
|
@@ -459,15 +483,30 @@ export function registerFileTools(
|
|
|
459
483
|
);
|
|
460
484
|
}
|
|
461
485
|
const actualNewString = preserveQuoteStyle(oldString, actualOldString, newString);
|
|
486
|
+
// 删除场景(new_string 为空):old_string 不以换行结尾且文件里是
|
|
487
|
+
// "old_string\n" 时连换行一起删,避免留下空行(对齐 Claude Code
|
|
488
|
+
// applyEditToFile 的 stripTrailingNewline 语义)
|
|
489
|
+
let searchString = actualOldString;
|
|
490
|
+
if (
|
|
491
|
+
actualNewString === "" &&
|
|
492
|
+
!actualOldString.endsWith("\n") &&
|
|
493
|
+
normalized.includes(actualOldString + "\n")
|
|
494
|
+
) {
|
|
495
|
+
searchString = actualOldString + "\n";
|
|
496
|
+
}
|
|
462
497
|
// split/join 与函数替换:replacement 含 $ 时不会触发 $& 等特殊语义
|
|
463
498
|
const updated = replaceAll
|
|
464
|
-
? normalized.split(
|
|
465
|
-
: normalized.replace(
|
|
499
|
+
? normalized.split(searchString).join(actualNewString)
|
|
500
|
+
: normalized.replace(searchString, () => actualNewString);
|
|
466
501
|
const restored = lineEnding === "\r\n" ? updated.replaceAll("\n", "\r\n") : updated;
|
|
467
502
|
await writeFile(filePath, restored, "utf8");
|
|
468
503
|
const snapshot = snapshotOf(restored);
|
|
469
504
|
state.reads.set(key, snapshot);
|
|
470
|
-
|
|
505
|
+
// patch/diff 仅供显示:前导 tab 转空格,避免 UI 渲染错位(对齐 Claude Code)
|
|
506
|
+
const diff = generateDiffString(
|
|
507
|
+
convertLeadingTabsToSpaces(original),
|
|
508
|
+
convertLeadingTabsToSpaces(restored),
|
|
509
|
+
);
|
|
471
510
|
const text = replaceAll
|
|
472
511
|
? `The file ${filePath} has been updated. All occurrences were successfully replaced.`
|
|
473
512
|
: `The file ${filePath} has been updated successfully.`;
|
|
@@ -479,7 +518,11 @@ export function registerFileTools(
|
|
|
479
518
|
text,
|
|
480
519
|
{
|
|
481
520
|
diff: diff.diff,
|
|
482
|
-
patch: generateUnifiedPatch(
|
|
521
|
+
patch: generateUnifiedPatch(
|
|
522
|
+
filePath,
|
|
523
|
+
convertLeadingTabsToSpaces(original),
|
|
524
|
+
convertLeadingTabsToSpaces(restored),
|
|
525
|
+
),
|
|
483
526
|
firstChangedLine: diff.firstChangedLine,
|
|
484
527
|
reads: { [key]: snapshot },
|
|
485
528
|
},
|
package/src/claude-code/glob.ts
CHANGED
|
@@ -153,7 +153,7 @@ export function registerGlobTool(pi: ExtensionAPI): void {
|
|
|
153
153
|
: filenames;
|
|
154
154
|
return {
|
|
155
155
|
content: [{ type: "text", text: lines.length > 0 ? lines.join("\n") : "No files found" }],
|
|
156
|
-
details:
|
|
156
|
+
details: undefined,
|
|
157
157
|
};
|
|
158
158
|
},
|
|
159
159
|
});
|
package/src/claude-code/grep.ts
CHANGED
|
@@ -284,7 +284,7 @@ export function registerGrepTool(pi: ExtensionAPI): void {
|
|
|
284
284
|
|
|
285
285
|
if (mode === "files_with_matches") {
|
|
286
286
|
if (stdout === "") {
|
|
287
|
-
return { content: [{ type: "text", text: "No files found" }], details:
|
|
287
|
+
return { content: [{ type: "text", text: "No files found" }], details: undefined };
|
|
288
288
|
}
|
|
289
289
|
const sorted = await sortFilesByMtime(stdout);
|
|
290
290
|
const { lines, appliedLimit, appliedOffset } = pageGrepOutput(sorted, offset, headLimit);
|
|
@@ -293,7 +293,10 @@ export function registerGrepTool(pi: ExtensionAPI): void {
|
|
|
293
293
|
const text = truncateOutput(
|
|
294
294
|
`Found ${filenames.length} ${filenames.length === 1 ? "file" : "files"}${limitInfo ? ` ${limitInfo}` : ""}\n${filenames.join("\n")}`,
|
|
295
295
|
);
|
|
296
|
-
return {
|
|
296
|
+
return {
|
|
297
|
+
content: [{ type: "text", text }],
|
|
298
|
+
details: undefined,
|
|
299
|
+
};
|
|
297
300
|
}
|
|
298
301
|
|
|
299
302
|
if (mode === "count") {
|