@trim21/personal-pi-extensions 0.0.388 → 0.0.391

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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trim21/personal-pi-extensions",
3
- "version": "0.0.388",
3
+ "version": "0.0.391",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
@@ -82,7 +82,7 @@
82
82
  "prettier --write"
83
83
  ]
84
84
  },
85
- "packageManager": "pnpm@11.22.0",
85
+ "packageManager": "pnpm@11.23.0",
86
86
  "engines": {
87
87
  "node": ">=24"
88
88
  },
@@ -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>;
@@ -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";
@@ -30,6 +30,10 @@ import { convertLeadingTabsToSpaces, findActualString, preserveQuoteStyle } from
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<Awaited<ReturnType<typeof stat>>> {
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(offset - 1) : lines.slice(offset - 1, offset - 1 + limit);
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 < 1)
259
+ (!Number.isSafeInteger(params.offset) || params.offset < 0)
251
260
  ) {
252
- throw new Error("offset must be a positive integer");
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, params.offset ?? 1, params.limit);
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(() => {