aura-basetools 0.19.0 → 0.21.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/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # aura-basetools 0.19.0
1
+ # aura-basetools 0.21.0
2
2
 
3
3
  Byte-exact file tools for coding agents: read by intent, edit by address, and write whole files without corrupting bytes. One engine shared by a standalone CLI and an MCP server.
4
4
 
@@ -21,7 +21,7 @@ The engine is intentionally not a general file manager: it reads and edits UTF-8
21
21
  ### Install
22
22
 
23
23
  ```bash
24
- npm i -g aura-basetools@0.19.0
24
+ npm i -g aura-basetools@0.21.0
25
25
  aura --version
26
26
  ```
27
27
 
@@ -91,6 +91,7 @@ Compared with the DSH built-in `dsh-tool-fs` tools, the differences are delibera
91
91
  - **Edits refuse stale bases.** A region-level gate rejects an edit when the addressed region changed after the read that authorized it, and points at the lines to re-read; `expect` makes the intended text explicit.
92
92
  - **Writes are verified, not assumed.** Every write goes through a temp file, flush, rename, and a byte-for-byte read-back; multi-target edits plan first and write nothing if any target fails.
93
93
  - **Changes can prove themselves.** `edit` / `write` accept an explicit `verify` command; a non-zero exit or timeout rolls the transaction back and returns the command output plus the rollback state. No auto-discovery of repo scripts.
94
+ - **MCP clients get a server-side nudge.** `initialize.instructions` asks the client to prefer `aura_basetools_*` and to use `aura_basetools_tx` for multi-call changes; whether it is honored is the client's choice.
94
95
  - **Changes can span calls, and concurrent changes merge.** `tx begin` opens a transaction; `edit` / `write` with `--tx <id>` append to it; `tx commit` three-way-merges non-overlapping external edits, refuses overlapping ones (`TX_CONFLICT`) without writing, and can run `verify`; `tx rollback` restores all of them. MCP exposes the same lifecycle as `aura_basetools_tx` plus the `transaction` field on edit/write.
95
96
  - **Addressing is structural.** `target` / `lines` / `old` / `expect` / handles replace "copy the literal text exactly"; failure receipts include ready-to-use retry addresses.
96
97
  - **The core is DSH-free.** `aura-basetools` has no runtime dependencies and no DSH imports; the DSH adapter is a separate optional package.
@@ -136,7 +137,7 @@ MIT.
136
137
  ### 安装
137
138
 
138
139
  ```bash
139
- npm i -g aura-basetools@0.19.0
140
+ npm i -g aura-basetools@0.21.0
140
141
  aura --version
141
142
  ```
142
143
 
package/bin/aura-mcp.mjs CHANGED
@@ -21,6 +21,20 @@
21
21
  */
22
22
  import { readFileSync, realpathSync } from 'node:fs'
23
23
  import process from 'node:process'
24
+
25
+ /**
26
+ * MCP `initialize.instructions`: the only standard server-side nudge many MCP clients will
27
+ * prepend to their prompt. Keep it imperative and factual; it must not promise client behavior
28
+ * the client does not implement.
29
+ */
30
+ const SERVER_INSTRUCTIONS = [
31
+ 'Prefer the aura_basetools_* tools over built-in Read/Edit/Write for file work when they are available.',
32
+ 'Read with aura_basetools_read; edit with aura_basetools_edit; create or overwrite with aura_basetools_write.',
33
+ 'Keep tool arguments small — long, deeply nested JSON is what the provider\'s stream parser fails on (MALFORMED_RESPONSE). For a single change pass the hunk FLAT at the top level (`old`+`replace`, or `start_line`+`end_line`+`replace`, or a `handle` from a previous read + `replace`); use `hunks: [...]` only for several changes. If you already hold a `handle`, do not repeat the old text in `old`.',
34
+ 'For changes spanning multiple calls use aura_basetools_tx begin -> edit/write with transaction -> commit; rollback on failure.',
35
+ 'These tools preserve BOM and per-line endings, gate only the addressed region for staleness, verify writes, and keep multi-target edits atomic.',
36
+ 'If your client can enforce provider-side strict tool schemas (DeepSeek `/beta` + `strict: true` per function, OpenAI strict), enable it: strict decoding makes malformed tool-call JSON impossible instead of merely rarer. The schemas convert cleanly with `locator-lab/strict-schema.mjs` (toStrictSchema / strictToolList); `aura basetools doctor` prints the recipe.',
37
+ ].join(' ')
24
38
  import { fileURLToPath, pathToFileURL } from 'node:url'
25
39
  import {
26
40
  openProvider,
@@ -34,12 +48,15 @@ import {
34
48
  commitTransactionEnvelope,
35
49
  rollbackTransactionEnvelope,
36
50
  statusTransactionEnvelope,
51
+ MODES,
37
52
  assertReadTargets,
38
53
  assertEditTargets,
39
54
  assertTargetShape,
40
55
  assertKnownArgs,
41
56
  MT_READ_ELEMENT_KEYS,
42
57
  MT_EDIT_ELEMENT_KEYS,
58
+ // 平铺形状的拼装规则与 DSH 壳 / CLI 共用这一份(`locator-lab/args.mjs` 的 `flatHunk`)。
59
+ flatHunk,
43
60
  } from '../lib/ops.mjs'
44
61
 
45
62
  // 版本从包自己的 package.json 读:写死过一次,结果 0.4.0 的服务对外还报 0.2.1。
@@ -77,12 +94,19 @@ const TARGETS_SCHEMA = {
77
94
  '一次调用、逐项回执、输出按输入序;一个目标失败不连坐其余(写侧例外:先全部规划,任一规划失败 ⇒ 一个字都不写)。',
78
95
  }
79
96
 
97
+ /**
98
+ * 为什么 `lines` 用 `anyOf` 而不是 `oneOf`:
99
+ * 四个分支按 `type` 互斥,语义完全等价;但 `oneOf` 在部分 provider 的严格模式里不受支持
100
+ * (DeepSeek strict / OpenAI strict 只认 `anyOf`),而 MCP 这一面是**多 agent 共用**的。
101
+ * DSH 适配器保留 `oneOf` —— 它的参数校验器认这个、静默忽略 `anyOf`(见 `aura-edit/index.js`)。
102
+ */
103
+
80
104
  const HUNK_SCHEMA = {
81
105
  type: 'object',
82
106
  properties: {
83
107
  target: { type: 'string', description: '符号名,如 `toTodoList` 或 `function:apply`;跳过全部文本匹配' },
84
108
  lines: {
85
- oneOf: [
109
+ anyOf: [
86
110
  { type: 'array' },
87
111
  { type: 'string' },
88
112
  { type: 'integer' },
@@ -97,7 +121,7 @@ const HUNK_SCHEMA = {
97
121
  old: {
98
122
  type: 'string',
99
123
  description:
100
- '内容地址:你正在替换的**逐字原文**(+ 可选 context 消歧)。给了它,坐标/符号名降为提示;引擎按内容定位,不唯一时拒绝并给候选。',
124
+ '内容地址:你正在替换的**逐字原文**(+ 可选 context 消歧)。给了它,坐标/符号名降为提示;引擎按内容定位,不唯一时拒绝并给候选。手里已有上次回执的 `handle` 时优先用 handle + replace —— 不必把旧文本再抄一遍,参数更短。',
101
125
  },
102
126
  context: { type: 'string', description: '与 old 同用:紧邻被替换区域的原文,只在 old 多处命中时用来消歧。' },
103
127
  /**
@@ -149,19 +173,19 @@ const TOOLS = [
149
173
  {
150
174
  name: 'aura_basetools_read',
151
175
  description:
152
- 'Read a file the way you actually need it. Modes: map (symbol directory), digest (interface only), full, around, lines, search, changed. `auto` picks one for you. Prefer this over reading the whole file.',
176
+ 'Read a file the way you actually need it. Modes: probe (metadata only: lines, bytes, line endings, encoding, language, structure size — no content), map (symbol/section directory), digest (interface only), full (up to the content budget, not necessarily the whole file), around, lines, search, changed. `auto` picks one for you. Prefer this over reading the whole file.',
153
177
  inputSchema: {
154
178
  type: 'object',
155
179
  properties: {
156
180
  file_path: { type: 'string', description: '要读的文件路径(绝对或相对 cwd)' },
157
181
  mode: {
158
182
  type: 'string',
159
- enum: ['map', 'digest', 'full', 'around', 'lines', 'search', 'changed'],
183
+ enum: ['auto', ...MODES],
160
184
  description: '省略时按你给的参数自动判断:行区间→lines、around→around、search→search、since→changed',
161
185
  },
162
186
  around: { type: 'string', description: 'mode=around:以该符号或短语为中心开窗' },
163
187
  lines: {
164
- oneOf: [
188
+ anyOf: [
165
189
  { type: 'array' },
166
190
  { type: 'string' },
167
191
  { type: 'integer' },
@@ -175,6 +199,7 @@ const TOOLS = [
175
199
  context: { type: 'integer', description: 'mode=around:上下各留几行(默认 8)' },
176
200
  kind: { type: 'string', description: 'mode=map:只保留某一类符号(function / class / …)' },
177
201
  budget: { type: 'integer', description: '宿主剪枝阈值(字符);省略用默认 8192' },
202
+ encoding: { type: 'string', description: '源编码覆盖(默认自动探测):gb18030 / shift_jis / euc-kr / big5 / utf-16le / utf-16be / windows-1252。只对本次调用生效;显式指定过一次后会被记住(文件未被外部改动时)。' },
178
203
  since: { type: 'string', description: 'mode=changed:上次回执里的 version 令牌' },
179
204
  max_chars: { type: 'integer' },
180
205
  offset: { type: 'integer', description: '与 limit 同用:0-based 行偏移(内置 Read 的分页形状);与 lines 互斥' },
@@ -193,12 +218,16 @@ const TOOLS = [
193
218
  {
194
219
  name: 'aura_basetools_edit',
195
220
  description:
196
- 'Edit or create a UTF-8 text file with hunks in one call. Address each hunk by target (symbol), lines, line_cue, anchor, or old (verbatim content + optional context). With `old`, set `replace_all: true` to replace every non-overlapping occurrence. Writes back byte-for-byte (BOM and per-line endings preserved), refuses a hunk whose addressed region changed since your last read, and reads the file back to verify. All hunks must locate or nothing is written.',
221
+ 'Edit or create a text file in one call. PREFER THE FLAT FORM for a single change: put the hunk fields at the top level (`old`+`replace`, or `start_line`/`end_line`+`replace`, or `handle` from a previous read + `replace`) and omit `hunks` entirely — one JSON object instead of an array of objects, which is smaller and survives the provider\'s stream parser more reliably. Use `hunks: [...]` only when a call changes several places. Address each hunk by target (symbol), lines, line_cue, anchor, handle, or old (verbatim content + optional context). With `old`, set `replace_all: true` to replace every non-overlapping occurrence. Writes back byte-for-byte (BOM and per-line endings preserved), refuses a hunk whose addressed region changed since your last read, and reads the file back to verify. All hunks must locate or nothing is written. A single hunk may also be passed flat at the top level (`file_path` + the hunk fields) instead of wrapped in `hunks: [...]`. JSON/JSONC edits: prefer `lines`/`start_line`+`end_line` and put only the new text in `replace` — re-typing the old text doubles the escaped quotes/newlines the call must carry.',
197
222
  inputSchema: {
198
223
  type: 'object',
199
224
  properties: {
200
225
  file_path: { type: 'string' },
201
- hunks: { type: 'array', items: HUNK_SCHEMA },
226
+ hunks: {
227
+ type: 'array',
228
+ items: HUNK_SCHEMA,
229
+ description: '多次修改时用;**单处修改请用平铺形式**(把 hunk 字段直接放顶层、不传 hunks):一个对象比“对象数组”少一层嵌套,参数更短、更不容易在流式解码时坏掉。',
230
+ },
202
231
  dry_run: { type: 'boolean', description: '只定位不落盘' },
203
232
  require_observation: {
204
233
  type: 'boolean',
@@ -208,9 +237,18 @@ const TOOLS = [
208
237
  type: 'string',
209
238
  description: '写后验证命令(显式给出才执行;在 workspace 根下、受超时/输出上限约束)。非零退出 ⇒ 自动回滚并返回 PATCH_VERIFY_FAILED。不自动发现/执行仓库脚本。',
210
239
  },
211
- transaction: { type: 'string', description: '跨调用事务 id(tx begin 返回);本次修改追加到该事务,不立即 commit' },
240
+ transaction: { type: 'string', description: '跨调用事务 id(tx begin 返回);本次修改追加到该事务的回滚日志。**写入立即可见**(tx 不是隔离事务,pending 期间盘上已是新字节),commit 只做 afterVersion 校验与可选 verify' },
212
241
  create: { type: 'string', description: '文件不存在时用它创建(整份内容)' },
242
+ encoding: { type: 'string', description: '源编码覆盖(默认自动探测);猜不准时写会拒绝,用它显式指定。只对本次调用生效。' },
213
243
  targets: TARGETS_SCHEMA,
244
+ /**
245
+ * 平铺形状:单个 hunk 的键**也**声明在顶层(与 DSH 壳、CLI 同一份容错)。
246
+ *
247
+ * 此前只有 DSH 壳声明了这些键:同一份载荷 `{file_path, start_line, end_line, replace}`
248
+ * 在 DSH 面被执行、在 MCP 面被未知参数守卫拒掉——而 MCP 正是 Codex/Claude Code/Cursor/ZCode
249
+ * 共用的那一面。声明与实装现在同源(拼装仍走 `flatHunk`,不另写一份判据)。
250
+ */
251
+ ...HUNK_SCHEMA.properties,
214
252
  },
215
253
  /**
216
254
  * `hunks` **不在 required 里**:`create` 就是在建一个新文件,那时没有 hunk 可给。
@@ -235,6 +273,7 @@ const TOOLS = [
235
273
  properties: {
236
274
  file_path: { type: 'string', description: '要写的文件路径(绝对或相对 cwd);不存在则创建' },
237
275
  content: { type: 'string', description: '整份内容;覆盖已有文件时替换全部行' },
276
+ encoding: { type: 'string', description: '源编码覆盖(默认自动探测);写回沿用该编码。只对本次调用生效。' },
238
277
  dry_run: { type: 'boolean', description: '只规划不落盘:新建报 create,覆盖校验换行与字节数' },
239
278
  no_overwrite: { type: 'boolean', description: '文件已存在时拒绝(WRITE_EXISTS)' },
240
279
  allow_unobserved: {
@@ -248,9 +287,9 @@ const TOOLS = [
248
287
  },
249
288
  verify: {
250
289
  type: 'string',
251
- description: '写后验证命令(显式给出才执行;非零退出 ⇒ 自动回滚并返回 PATCH_VERIFY_FAILED)。',
290
+ description: '写后验证命令(显式给出才执行;在**工作区根目录**执行;无超时参数——MCP 传输上限约 30s,慢命令不要放进来;非零退出 ⇒ 自动回滚并返回 PATCH_VERIFY_FAILED)。',
252
291
  },
253
- transaction: { type: 'string', description: '跨调用事务 id(tx begin 返回);本次修改追加到该事务,不立即 commit' },
292
+ transaction: { type: 'string', description: '跨调用事务 id(tx begin 返回);本次修改追加到该事务的回滚日志。**写入立即可见**(tx 不是隔离事务),commit 只做 afterVersion 校验与可选 verify' },
254
293
  },
255
294
  required: ['file_path', 'content'],
256
295
  },
@@ -258,13 +297,13 @@ const TOOLS = [
258
297
  {
259
298
  name: 'aura_basetools_tx',
260
299
  description:
261
- 'Begin, commit, rollback, or inspect a cross-call transaction. Begin returns an id; pass it to edit/write `transaction` to append changes; commit checks each written file was not changed externally and can run `verify`; rollback restores all files.',
300
+ 'Begin, commit, rollback, or inspect a cross-call transaction. Begin returns an id; pass it to edit/write `transaction` to append changes. Writes are visible immediately: a transaction is a cross-file ROLLBACK JOURNAL, not isolation — while it is pending the file on disk already holds the new bytes. `commit` checks each written file was not changed externally and can run `verify`; `rollback` restores all files.',
262
301
  inputSchema: {
263
302
  type: 'object',
264
303
  properties: {
265
304
  action: { type: 'string', enum: ['begin', 'commit', 'rollback', 'status'] },
266
305
  transaction: { type: 'string', description: 'tx begin 返回的事务 id(begin 时不需要)' },
267
- verify: { type: 'string', description: 'commit 时的写后验证命令;失败 ⇒ 整个事务回滚' },
306
+ verify: { type: 'string', description: 'commit 时的写后验证命令(在**工作区根目录**执行、无超时参数;MCP 传输上限约 30s);失败 ⇒ 整个事务回滚' },
268
307
  },
269
308
  required: ['action'],
270
309
  },
@@ -404,6 +443,11 @@ async function callTool(provider, name, args = {}) {
404
443
  }
405
444
  }
406
445
  }
446
+ /**
447
+ * 逐调用编码覆盖(0.21):长驻进程里 provider 是同一个,环境变量没法按调用改。
448
+ * 不传时复位到环境变量——不能把上一次调用的 hint 泄漏给下一次。
449
+ */
450
+ if (typeof provider.setEncodingHint === 'function') provider.setEncodingHint(args.encoding)
407
451
  if (name === 'aura_basetools_tx') {
408
452
  const action = args.action
409
453
  if (action === 'begin') return beginTransactionEnvelope(provider)
@@ -433,7 +477,10 @@ async function callTool(provider, name, args = {}) {
433
477
  * 多目标形状不走这一条:每个 target 自带 hunks/create(契约 §2.2)。
434
478
  */
435
479
  const creating = typeof args.create === 'string'
436
- if (!Array.isArray(args.hunks) && !creating) {
480
+ // 平铺形状(单 hunk 直接写在顶层):拼装规则只认 `flatHunk` 那一份,不在 bin 里另写。
481
+ const flat = Array.isArray(args.hunks) ? null : flatHunk(args)
482
+ const hunks = Array.isArray(args.hunks) ? args.hunks : flat === null ? [] : [flat]
483
+ if (hunks.length === 0 && !creating) {
437
484
  return {
438
485
  ok: false,
439
486
  error: 'aura_basetools_edit 需要 hunks 数组,或 create(整份内容建文件)二选一;只建文件时直接给 create 即可',
@@ -442,7 +489,7 @@ async function callTool(provider, name, args = {}) {
442
489
  }
443
490
  return await editEnvelope(provider, {
444
491
  file: args.file_path,
445
- hunks: Array.isArray(args.hunks) ? args.hunks : [],
492
+ hunks,
446
493
  dryRun: args.dry_run === true,
447
494
  create: creating ? args.create : undefined,
448
495
  ...(args.require_observation === true ? { requireObservation: true } : {}),
@@ -504,6 +551,7 @@ export async function serve() {
504
551
  supportedProtocolVersions: SUPPORTED_PROTOCOLS,
505
552
  capabilities: { tools: { listChanged: false } },
506
553
  serverInfo: { name: 'aura-basetools', version: VERSION },
554
+ instructions: SERVER_INSTRUCTIONS,
507
555
  })
508
556
  }
509
557
  if (method === 'ping') return reply(id, {})
package/bin/aura.mjs CHANGED
@@ -87,15 +87,15 @@ const isBooleanFlag = (token) => typeof token === 'string' && BOOLEAN_FLAGS.has(
87
87
  */
88
88
  const COMMAND_FLAGS = {
89
89
  read: {
90
- value: ['cwd', 'file', 'targets', 'mode', 'around', 'lines', 'start-line', 'end-line', 'search', 'since', 'context', 'kind', 'budget', 'max-chars', 'offset', 'limit'],
90
+ value: ['cwd', 'file', 'targets', 'mode', 'around', 'lines', 'start-line', 'end-line', 'search', 'since', 'context', 'kind', 'budget', 'max-chars', 'offset', 'limit', 'encoding'],
91
91
  bool: ['pretty'],
92
92
  },
93
93
  edit: {
94
- value: ['cwd', 'file', 'targets', 'hunks', 'hunks-file', 'create', 'target', 'lines', 'start-line', 'end-line', 'line-cue', 'anchor', 'old', 'context', 'handle', 'line', 'position', 'replace', 'expect', 'near', 'eol', 'verify', 'tx'],
94
+ value: ['cwd', 'file', 'targets', 'hunks', 'hunks-file', 'create', 'target', 'lines', 'start-line', 'end-line', 'line-cue', 'anchor', 'old', 'context', 'handle', 'line', 'position', 'replace', 'expect', 'near', 'eol', 'verify', 'tx', 'encoding'],
95
95
  bool: ['pretty', 'dry-run', 'plan', 'replace-all', 'require-observation'],
96
96
  },
97
97
  write: {
98
- value: ['cwd', 'file', 'content', 'eol', 'verify', 'tx'],
98
+ value: ['cwd', 'file', 'content', 'eol', 'verify', 'tx', 'encoding'],
99
99
  bool: ['pretty', 'dry-run', 'plan', 'no-overwrite', 'allow-unobserved'],
100
100
  },
101
101
  tx: { value: ['cwd', 'id', 'verify'], bool: ['pretty', 'json'] },
@@ -339,7 +339,8 @@ if (command === 'mcp') {
339
339
  process.exit(0)
340
340
  }
341
341
 
342
- const provider = openProvider(typeof flag('cwd') === 'string' ? flag('cwd') : process.cwd())
342
+ const encodingFlag = typeof flag('encoding') === 'string' && flag('encoding').length > 0 ? flag('encoding') : undefined
343
+ const provider = openProvider(typeof flag('cwd') === 'string' ? flag('cwd') : process.cwd(), { encoding: encodingFlag })
343
344
 
344
345
  if (command === 'tx') {
345
346
  const action = argv[1]
@@ -441,11 +442,29 @@ if (command === 'doctor') {
441
442
  probeWritable(cacheDir, 'cache-writable')
442
443
  probeWritable(path.join(cacheDir, 'locks'), 'lock-writable')
443
444
  const ok = checks.every((check) => check.ok)
445
+ /**
446
+ * strict 提示(不是检查项):让“能不能把参数解析失败归零”这件事在装完就能看到。
447
+ *
448
+ * 工具侧不能替宿主开 strict(那是 provider/客户端配置),但能说清楚“我们的 schema 转得过去”
449
+ * 与“怎么开”。转换器导出在 `locator-lab/strict-schema.mjs`(`toStrictSchema` / `strictToolList`),
450
+ * 已随包发布(package.json 的 `./locator-lab/*`)。
451
+ *
452
+ * 为什么放在 doctor 而不是新开一个命令:doctor 是“装完跑一下”的那条路径,
453
+ * 而 strict 能不能开决定了“参数解析失败”是否还会中断任务。
454
+ */
455
+ const strict = {
456
+ compatible: true,
457
+ convert: 'aura-basetools/locator-lab/strict-schema.mjs',
458
+ deepseek: 'base_url=https://api.deepseek.com/beta + "strict": true on every function (server validates the schema)',
459
+ openai: 'strict: true (all properties required + additionalProperties:false)',
460
+ note: 'strict 让模型不可能输出非法 JSON;不开时工具侧只能降低概率(P1 发生在工具被调用之前)',
461
+ }
444
462
  const report = {
445
463
  ok,
446
464
  version: VERSION,
447
465
  node: process.versions.node,
448
466
  checks,
467
+ strict,
449
468
  ...(process.env.AURA_NO_LOCK === '1' ? { notes: ['AURA_NO_LOCK=1: workspace lock is disabled'] } : {}),
450
469
  }
451
470
  if (has('json')) {
@@ -454,6 +473,10 @@ if (command === 'doctor') {
454
473
  }
455
474
  process.stdout.write(`aura basetools doctor: ${ok ? 'ok' : 'FAILED'} (${VERSION}, node ${process.versions.node})\n`)
456
475
  for (const check of checks) process.stdout.write(` ${check.ok ? '✓' : '✗'} ${check.id}: ${check.detail}\n`)
476
+ process.stdout.write(` • strict: schemas 可转成 provider strict 变体(全 required + nullable + anyOf)——\n`)
477
+ process.stdout.write(` DeepSeek: ${strict.deepseek}\n`)
478
+ process.stdout.write(` OpenAI: ${strict.openai}\n`)
479
+ process.stdout.write(` 转换器: ${strict.convert}|不开 strict 时“参数不是合法 JSON”仍会中断任务(工具看不到那段字节)\n`)
457
480
  process.exit(ok ? 0 : 2)
458
481
  }
459
482
 
@@ -493,6 +516,7 @@ if (command === 'read' || command === 'outline') {
493
516
  limit: typeof flag('limit') === 'string' ? Number(flag('limit')) : undefined,
494
517
  // 光写 `--max-chars` 不带值时 flag() 回 true,Number(true)=1 会变成"预算 1 字符"这种怪值
495
518
  maxChars: typeof flag('max-chars') === 'string' ? Number(flag('max-chars')) : undefined,
519
+ encoding: encodingFlag,
496
520
  })
497
521
  emit(env, env.ok ? 0 : 2)
498
522
  }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * encoding-memory.mjs —— 记住“这个文件是什么编码”,设一次就够。
3
+ *
4
+ * ## 为什么需要它
5
+ *
6
+ * 探测在中/低置信度时会**拒绝写**(`ENCODING_AMBIGUOUS`)——这是对的:写错编码等于写坏字节。
7
+ * 但用户每次都要 `AURA_ENCODING=gb18030` 才能改同一个文件,体验很差。
8
+ *
9
+ * 所以:**显式指定过一次,就记住**。下次同一文件(大小与 mtime 都没变)自动沿用,
10
+ * 不需要再设环境变量。
11
+ *
12
+ * ## 为什么不无条件信任记忆
13
+ *
14
+ * 文件被别的工具重写后,编码可能变了(甚至从 GBK 改成 UTF-8)。所以记忆条目带
15
+ * `{size, mtimeMs}` 指纹:**对不上就忽略**,回到探测。宁可再问一次,不可写错。
16
+ *
17
+ * 存放位置:`AURA_CACHE_DIR`(默认 `~/.aura`)下的 `encodings.json`;目录不可写时静默跳过
18
+ * ——记忆是加速器,不是正确性依赖。
19
+ */
20
+ import fs from 'node:fs'
21
+ import os from 'node:os'
22
+ import path from 'node:path'
23
+
24
+ const MAX_ENTRIES = 500
25
+
26
+ function storePath() {
27
+ const dir = process.env.AURA_CACHE_DIR ?? path.join(os.homedir(), '.aura')
28
+ return path.join(dir, 'encodings.json')
29
+ }
30
+
31
+ function readAll() {
32
+ try {
33
+ const parsed = JSON.parse(fs.readFileSync(storePath(), 'utf8'))
34
+ return parsed !== null && typeof parsed === 'object' ? parsed : {}
35
+ } catch {
36
+ return {}
37
+ }
38
+ }
39
+
40
+ function writeAll(map) {
41
+ try {
42
+ const file = storePath()
43
+ fs.mkdirSync(path.dirname(file), { recursive: true })
44
+ const tmp = `${file}.${process.pid}.${Date.now().toString(36)}.tmp`
45
+ fs.writeFileSync(tmp, JSON.stringify(map))
46
+ fs.renameSync(tmp, file)
47
+ } catch {
48
+ /* 记忆是加速器,不是正确性依赖 */
49
+ }
50
+ }
51
+
52
+ /** 指纹:大小 + mtime;任一缺失就当作“不知道”,不冒险复用。 */
53
+ function stampOf(stat) {
54
+ if (stat === undefined || stat === null) return null
55
+ const size = Number(stat.size)
56
+ const mtimeMs = Number(stat.mtimeMs)
57
+ if (!Number.isFinite(size) || !Number.isFinite(mtimeMs)) return null
58
+ return { size, mtimeMs: Math.trunc(mtimeMs) }
59
+ }
60
+
61
+ /** 记住一个文件的编码(UTF-8 不记:它是默认值,记住只会让表变脏)。 */
62
+ export function rememberEncoding(displayPath, encoding, stat) {
63
+ if (typeof displayPath !== 'string' || typeof encoding !== 'string') return
64
+ if (encoding === 'utf-8') return
65
+ const stamp = stampOf(stat)
66
+ if (stamp === null) return
67
+ const all = readAll()
68
+ all[path.resolve(displayPath)] = { encoding, ...stamp }
69
+ // 简单的 LRU:超上限时按 mtime 丢掉最旧的一半。
70
+ const keys = Object.keys(all)
71
+ if (keys.length > MAX_ENTRIES) {
72
+ keys.sort((a, b) => (all[a].mtimeMs ?? 0) - (all[b].mtimeMs ?? 0))
73
+ for (const key of keys.slice(0, keys.length - MAX_ENTRIES)) delete all[key]
74
+ }
75
+ writeAll(all)
76
+ }
77
+
78
+ /** 取回记忆的编码;指纹对不上返回 undefined。 */
79
+ export function recallEncoding(displayPath, stat) {
80
+ if (typeof displayPath !== 'string') return undefined
81
+ const entry = readAll()[path.resolve(displayPath)]
82
+ if (entry === undefined || typeof entry.encoding !== 'string') return undefined
83
+ const stamp = stampOf(stat)
84
+ if (stamp === null) return undefined
85
+ if (entry.size !== stamp.size || entry.mtimeMs !== stamp.mtimeMs) return undefined
86
+ return entry.encoding
87
+ }