aura-basetools 0.20.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.
@@ -0,0 +1,382 @@
1
+ /**
2
+ * doc-structure.mjs —— 文档 / 配置的结构目录(`map` 用)。
3
+ *
4
+ * ## 为什么需要它
5
+ *
6
+ * `map` 现在只认代码扩展名;`.md` / `.json` / `.yaml` / `.toml` 一律回一句
7
+ * “unsupported extension — no symbol index”。而实测最近 62 个会话里 **1,836 次 read**
8
+ * 有 330 次 `.md`(18%)、55 次 `.json`、13 次 `.yml`——约 22% 的目标是文档/配置,
9
+ * 它们拿到的地图是一句拒绝。
10
+ *
11
+ * ## 为什么不是“再写一个解析器”
12
+ *
13
+ * 这层的目标不是完整语法,而是**目录**:给出行号、层级与名字,让人能挑一段用
14
+ * `mode=lines` 取回。所以:
15
+ * · Markdown:ATX 标题(跳过围栏代码块里的 `#`);setext 标题不认(降级为正文)。
16
+ * · JSON:字符串/注释感知的轻量扫描,给对象键与数组元素;解析不完整时**降级**而不是抛。
17
+ * · YAML:缩进 + `key:` 行;锚点/多行块不展开(目录仍然可用)。
18
+ * · TOML:`[table]` 与 `key =` 行。
19
+ *
20
+ * 每条 entry 都有 `startLine` / `endLine`:目录本身要可执行(下一步是 `mode=lines`)。
21
+ */
22
+
23
+ /** 扩展名 → 文档种类。故意不含 `.txt`:纯文本没有结构,给了目录也是假的。 */
24
+ export const DOC_KINDS = {
25
+ md: 'markdown',
26
+ markdown: 'markdown',
27
+ mdx: 'markdown',
28
+ json: 'json',
29
+ jsonc: 'json',
30
+ json5: 'json',
31
+ yaml: 'yaml',
32
+ yml: 'yaml',
33
+ toml: 'toml',
34
+ }
35
+
36
+ /** `filePath` 的扩展名决定用哪套结构索引;不是文档类型返回 null。 */
37
+ export function docKindOf(filePath) {
38
+ if (typeof filePath !== 'string') return null
39
+ const dot = filePath.lastIndexOf('.')
40
+ if (dot < 0) return null
41
+ const ext = filePath.slice(dot + 1).toLowerCase()
42
+ return DOC_KINDS[ext] ?? null
43
+ }
44
+
45
+ /**
46
+ * 索引一篇文档。
47
+ *
48
+ * @returns {{entries: Array<{name:string, kind:string, depth:number, startLine:number, endLine:number}>, unit:string, note?:string}}
49
+ */
50
+ export function indexDocument(source, docKind) {
51
+ const lines = String(source).split(/\r?\n/)
52
+ if (docKind === 'markdown') return indexMarkdown(lines)
53
+ if (docKind === 'json') return indexJson(String(source), lines)
54
+ if (docKind === 'yaml') return indexYaml(lines)
55
+ if (docKind === 'toml') return indexToml(lines)
56
+ return { entries: [], unit: 'entries', note: `no structure index for ${docKind}` }
57
+ }
58
+
59
+ /* ── Markdown ─────────────────────────────────────────────────────────────── */
60
+
61
+ function indexMarkdown(lines) {
62
+ const entries = []
63
+ let fence = null
64
+ /** YAML front matter:只在第 1 行 `---` 起算,到下一个 `---` / `...` 结束。 */
65
+ let frontMatter = false
66
+ /**
67
+ * 上一行是否是一个“可以升级为 setext 标题的段落行”。
68
+ *
69
+ * 为什么不能只看“下一行是不是 `===`/`---`”:`---` 同时是**分隔线**、也是 YAML
70
+ * front matter 的边界。CommonMark 的消歧规则是:setext 下划线只能接在**段落**后面
71
+ * (列表项、引用、缩进代码、标题、分隔线都不行),而分隔线前面又必须是空行。
72
+ * 所以这里维护一个“上一行是段落行”的状态,而不是做简单的向前看。
73
+ */
74
+ let paragraph = -1
75
+ /** 当前段落的起始行:setext 标题取的是**整段**,不是最后一行(CommonMark)。 */
76
+ let paragraphStart = -1
77
+ const isParagraph = (line) => {
78
+ if (line.trim() === '') return false
79
+ if (/^\s{4,}/.test(line)) return false // 缩进代码块
80
+ if (/^\s*>/.test(line)) return false // 引用
81
+ if (/^\s*(?:[-*+]|\d+[.)])\s+/.test(line)) return false // 列表项
82
+ if (/^\s*(```+|~~~+)/.test(line)) return false
83
+ if (/^ {0,3}#{1,6}(?:\s|$)/.test(line)) return false // ATX 标题
84
+ if (/^\s*(?:(?:\*\s*){3,}|(?:-\s*){3,}|(?:_\s*){3,})$/.test(line)) return false // 分隔线
85
+ if (/^\s*<[^>]+>/.test(line)) return false // HTML 块(粗略)
86
+ return true
87
+ }
88
+ for (let i = 0; i < lines.length; i += 1) {
89
+ const line = lines[i]
90
+ if (i === 0 && /^---\s*$/.test(line)) {
91
+ frontMatter = true
92
+ continue
93
+ }
94
+ if (frontMatter) {
95
+ if (/^(?:---|\.\.\.)\s*$/.test(line)) frontMatter = false
96
+ continue
97
+ }
98
+ const fenceHit = /^\s*(```+|~~~+)/.exec(line)
99
+ if (fenceHit !== null) {
100
+ const mark = fenceHit[1][0]
101
+ if (fence === null) fence = mark
102
+ else if (fence === mark) fence = null
103
+ paragraph = -1
104
+ continue
105
+ }
106
+ if (fence !== null) continue
107
+ // setext:上一行是段落 ⇒ 本行是它的下划线(`=` 一级,`-` 二级)
108
+ const setext = /^ {0,3}(=+|-+)\s*$/.exec(line)
109
+ if (setext !== null && paragraph === i - 1) {
110
+ // 多行段落 → 标题文本是整段(行间用空格接起来),区间从段首算起。
111
+ const name = lines.slice(paragraphStart, paragraph + 1).map((row) => row.trim()).filter(Boolean).join(' ')
112
+ entries.push({
113
+ kind: 'heading',
114
+ name: name || '(untitled)',
115
+ depth: setext[1][0] === '=' ? 1 : 2,
116
+ startLine: paragraphStart + 1,
117
+ endLine: i + 1,
118
+ })
119
+ paragraph = -1
120
+ paragraphStart = -1
121
+ continue
122
+ }
123
+ const heading = /^(#{1,6})\s+(.*?)\s*#*\s*$/.exec(line)
124
+ if (heading !== null) {
125
+ entries.push({
126
+ kind: 'heading',
127
+ name: heading[2].length === 0 ? '(untitled)' : heading[2],
128
+ depth: heading[1].length,
129
+ startLine: i + 1,
130
+ endLine: i + 1,
131
+ })
132
+ paragraph = -1
133
+ paragraphStart = -1
134
+ continue
135
+ }
136
+ if (isParagraph(line)) {
137
+ // 连续段落行:保持 paragraphStart,直到段落被中断。
138
+ // 注意 `paragraph === -1` 与“上一行正好是 -1”会撞车(第 1 行时 i-1 也是 -1),
139
+ // 所以用 paragraphStart 的哨兵值把“段落还没开始”区分开。
140
+ if (paragraphStart === -1 || paragraph !== i - 1) paragraphStart = i
141
+ paragraph = i
142
+ } else {
143
+ paragraph = -1
144
+ paragraphStart = -1
145
+ }
146
+ }
147
+ // 每节的范围 = 到下一个标题(任意层级)之前;最后一节到文件尾。
148
+ for (let k = 0; k < entries.length; k += 1) entries[k].endLine = (entries[k + 1]?.startLine ?? lines.length + 1) - 1
149
+ return { entries, unit: 'sections' }
150
+ }
151
+
152
+ /* ── JSON / JSONC ─────────────────────────────────────────────────────────── */
153
+
154
+ /**
155
+ * 轻量扫描:不建 AST,只记录“键/元素在哪几行”。
156
+ *
157
+ * 关键取舍:**不抛**。配置里出现尾逗号、注释、甚至半份文件时,已经扫到的目录仍然有用,
158
+ * 比一句“解析失败”有用得多;扫不动的部分在 `note` 里说。
159
+ */
160
+ function indexJson(source, lines) {
161
+ const entries = []
162
+ /** 每层容器记自己的目录项(由父层创建);闭合时把它的 endLine 收到闭合行。 */
163
+ const stack = [] // { type:'object'|'array', path:string, entry:entry|null, index:number }
164
+ let i = 0
165
+ let line = 1
166
+ let note = null
167
+ let expectKey = false
168
+ let pendingKey = null
169
+ let pendingKeyLine = 0
170
+
171
+ /**
172
+ * 键里有 `.` / 空格 / `[` 时不能拼成 `a.b`(`exports."."` 会读成 `exports..`——实测就撞上了):
173
+ * 改用 `parent["key"]`,与 JS/JSONPath 的习惯一致。
174
+ */
175
+ const seg = (key) => (/^[A-Za-z0-9_$-]+$/.test(key) ? key : `[${JSON.stringify(key)}]`)
176
+ const pathOf = (parent, key) => (parent === '' ? seg(key) : `${parent}${seg(key).startsWith('[') ? seg(key) : `.${seg(key)}`}`)
177
+ const add = (entry) => {
178
+ entries.push(entry)
179
+ return entry
180
+ }
181
+ const skipTrivia = () => {
182
+ while (i < source.length) {
183
+ const c = source[i]
184
+ if (c === '\n') {
185
+ line += 1
186
+ i += 1
187
+ } else if (c === ' ' || c === '\t' || c === '\r') {
188
+ i += 1
189
+ } else if (c === '/' && source[i + 1] === '/') {
190
+ while (i < source.length && source[i] !== '\n') i += 1
191
+ } else if (c === '/' && source[i + 1] === '*') {
192
+ i += 2
193
+ while (i < source.length && !(source[i] === '*' && source[i + 1] === '/')) {
194
+ if (source[i] === '\n') line += 1
195
+ i += 1
196
+ }
197
+ i += 2
198
+ } else break
199
+ }
200
+ }
201
+ const readString = (quote = '"') => {
202
+ // 调用方已在引号上
203
+ const startLine = line
204
+ i += 1
205
+ let value = ''
206
+ while (i < source.length) {
207
+ const c = source[i]
208
+ if (c === '\\') {
209
+ value += source[i + 1] ?? ''
210
+ i += 2
211
+ continue
212
+ }
213
+ if (c === quote) {
214
+ i += 1
215
+ return { value, line: startLine }
216
+ }
217
+ if (c === '\n') line += 1
218
+ value += c
219
+ i += 1
220
+ }
221
+ note = note ?? 'unterminated string — the directory stops where the text does'
222
+ return { value, line: startLine }
223
+ }
224
+
225
+ while (i < source.length) {
226
+ skipTrivia()
227
+ if (i >= source.length) break
228
+ const c = source[i]
229
+ const top = stack[stack.length - 1]
230
+ if (c === '{' || c === '[') {
231
+ const isObject = c === '{'
232
+ const name = top === undefined
233
+ ? ''
234
+ : top.type === 'array'
235
+ ? `${top.path}[${top.index}]`
236
+ : pathOf(top.path, pendingKey ?? '?')
237
+ const ownEntry = top === undefined
238
+ ? null
239
+ : add({ kind: isObject ? 'object' : 'array', name, depth: stack.length + 1, startLine: line, endLine: line })
240
+ stack.push({ type: isObject ? 'object' : 'array', path: name, entry: ownEntry, index: 0 })
241
+ expectKey = isObject
242
+ pendingKey = null
243
+ i += 1
244
+ continue
245
+ }
246
+ if (c === '}' || c === ']') {
247
+ const closed = stack.pop()
248
+ if (closed !== undefined && closed.entry !== null && closed.entry !== undefined) closed.entry.endLine = line
249
+ expectKey = stack.length > 0 && stack[stack.length - 1].type === 'object'
250
+ i += 1
251
+ continue
252
+ }
253
+ if (c === ',') {
254
+ expectKey = top !== undefined && top.type === 'object'
255
+ pendingKey = null
256
+ if (top !== undefined && top.type === 'array') top.index += 1
257
+ i += 1
258
+ continue
259
+ }
260
+ if (c === ':') {
261
+ i += 1
262
+ continue
263
+ }
264
+ /**
265
+ * JSON5:无引号键(`{name: 1}`)。必须在“标量跳过”之前认,否则整个 `name: 1`
266
+ * 会被当成标量吞到下一个逗号,目录里就少了这个键。
267
+ */
268
+ if (expectKey && /[A-Za-z_$]/.test(c)) {
269
+ let key = ''
270
+ while (i < source.length && /[A-Za-z0-9_$]/.test(source[i])) {
271
+ key += source[i]
272
+ i += 1
273
+ }
274
+ pendingKey = key
275
+ pendingKeyLine = line
276
+ expectKey = false
277
+ continue
278
+ }
279
+ if (c === '"' || c === "'") {
280
+ const str = readString(c)
281
+ if (expectKey) {
282
+ pendingKey = str.value
283
+ pendingKeyLine = str.line
284
+ expectKey = false
285
+ } else if (top !== undefined && top.type === 'object' && pendingKey !== null) {
286
+ // 对象键的值是字符串:目录行就是键所在的那一行(与标量值同一条规则)。
287
+ add({ kind: 'key', name: pathOf(top.path, pendingKey), depth: stack.length, startLine: pendingKeyLine, endLine: str.line })
288
+ pendingKey = null
289
+ } else if (top !== undefined && top.type === 'array') {
290
+ // 数组里的字符串元素:只有对象/数组元素才值得进目录;标量元素跳过。
291
+ top.index += 1
292
+ }
293
+ continue
294
+ }
295
+ // 标量:跳过到下一个分隔符
296
+ while (i < source.length && !',}]'.includes(source[i])) {
297
+ if (source[i] === '\n') line += 1
298
+ i += 1
299
+ }
300
+ if (top !== undefined && top.type === 'object' && pendingKey !== null) {
301
+ // 对象键的值是标量:目录行就是键所在行
302
+ add({ kind: 'key', name: pathOf(top.path, pendingKey), depth: stack.length, startLine: pendingKeyLine, endLine: line })
303
+ pendingKey = null
304
+ } else if (top !== undefined && top.type === 'array') {
305
+ top.index += 1
306
+ }
307
+ }
308
+ if (stack.length > 0) note = note ?? 'the document ends inside an open container — the directory is partial'
309
+ return { entries, unit: 'keys', ...(note === null ? {} : { note }) }
310
+ }
311
+
312
+ /* ── YAML ─────────────────────────────────────────────────────────────────── */
313
+
314
+ function indexYaml(lines) {
315
+ const entries = []
316
+ const stack = [] // { indent, name }
317
+ let blockScalarIndent = null
318
+ for (let i = 0; i < lines.length; i += 1) {
319
+ const raw = lines[i]
320
+ if (raw.trim() === '' || /^\s*#/.test(raw)) continue
321
+ const indent = raw.length - raw.trimStart().length
322
+ if (blockScalarIndent !== null && indent > blockScalarIndent) continue
323
+ blockScalarIndent = null
324
+ if (/^---(\s|$)/.test(raw) || /^\.\.\.(\s|$)/.test(raw)) {
325
+ stack.length = 0
326
+ continue
327
+ }
328
+ const body = raw.trimStart()
329
+ const match = /^(-\s+)?([^#\s][^:]*?):(\s|$)/.exec(body)
330
+ if (match === null) continue
331
+ while (stack.length > 0 && stack[stack.length - 1].indent >= indent) stack.pop()
332
+ const key = match[2]
333
+ const path = stack.length === 0 ? key : `${stack[stack.length - 1].name}.${key}`
334
+ const entry = { kind: 'key', name: path, depth: stack.length + 1, startLine: i + 1, endLine: i + 1 }
335
+ entries.push(entry)
336
+ // 值是否是多行块标量:`key: |` / `key: >`
337
+ if (/\s[|>][-+]?\s*$/.test(body)) blockScalarIndent = indent
338
+ stack.push({ indent, name: path })
339
+ }
340
+ // 一节的范围 = 到**下一个同级或更高级**的键之前(中间的深一级键属于它)。
341
+ for (let k = 0; k < entries.length; k += 1) {
342
+ let j = k + 1
343
+ while (j < entries.length && entries[j].depth > entries[k].depth) j += 1
344
+ entries[k].endLine = j < entries.length ? Math.max(entries[k].startLine, entries[j].startLine - 1) : lines.length
345
+ }
346
+ return { entries, unit: 'keys' }
347
+ }
348
+
349
+ /* ── TOML ─────────────────────────────────────────────────────────────────── */
350
+
351
+ function indexToml(lines) {
352
+ const entries = []
353
+ let table = ''
354
+ for (let i = 0; i < lines.length; i += 1) {
355
+ const line = lines[i].trim()
356
+ if (line === '' || line.startsWith('#')) continue
357
+ const tableHit = /^\[\[?([^\]]+)\]\]?$/.exec(line)
358
+ if (tableHit !== null) {
359
+ table = tableHit[1].trim()
360
+ entries.push({ kind: 'table', name: table, depth: table.split('.').length, startLine: i + 1, endLine: i + 1 })
361
+ continue
362
+ }
363
+ const keyHit = /^([A-Za-z0-9_.-]+)\s*=/.exec(line)
364
+ if (keyHit !== null) {
365
+ const name = table === '' ? keyHit[1] : `${table}.${keyHit[1]}`
366
+ entries.push({ kind: 'key', name, depth: name.split('.').length, startLine: i + 1, endLine: i + 1 })
367
+ }
368
+ }
369
+ /**
370
+ * TOML 的范围规则与 YAML 不同:表(`[a.b]`)是扁平段,深度按点名数算,
371
+ * 但 `[db.pool]` 与 `[server]` 是**同级**——它们之间没有父子关系。
372
+ * 所以:表到下一张表之前;键只占自己那一行(多行值不进目录,mode=lines 拿)。
373
+ */
374
+ for (let k = 0; k < entries.length; k += 1) {
375
+ if (entries[k].kind !== 'table') continue
376
+ let j = k + 1
377
+ while (j < entries.length && entries[j].kind !== 'table') j += 1
378
+ entries[k].endLine = j < entries.length ? Math.max(entries[k].startLine, entries[j].startLine - 1) : lines.length
379
+ }
380
+ return { entries, unit: 'keys' }
381
+ }
382
+
@@ -22,6 +22,7 @@
22
22
  */
23
23
 
24
24
  import { indexStructure, langOf, listSymbols, rankSymbols, resolveTarget } from './struct-core.mjs'
25
+ import { docKindOf, indexDocument } from './doc-structure.mjs'
25
26
  /**
26
27
  * 区间句柄(HD1):本文件只**发**句柄,编辑侧怎么解析是另一张单的事。
27
28
  *
@@ -185,7 +186,7 @@ function serializedSize(envelope) {
185
186
  * 模式。刻意做成显式枚举而不是"有参数就切换"——后者会让"我到底要什么"变得含糊,
186
187
  * 而含糊正是信息浪费的源头。
187
188
  */
188
- export const MODES = ['map', 'digest', 'full', 'around', 'lines', 'search', 'changed']
189
+ export const MODES = ['map', 'digest', 'full', 'around', 'lines', 'search', 'changed', 'probe']
189
190
 
190
191
  /**
191
192
  * 摘要:**看懂一个文件**所需的最小信息集。
@@ -198,10 +199,26 @@ export const MODES = ['map', 'digest', 'full', 'around', 'lines', 'search', 'cha
198
199
  */
199
200
  function renderDigest(base, source, lines, lang, request, symbolsIndex = null) {
200
201
  if (lang === null) {
202
+ /**
203
+ * 文档没有签名,但有**结构**。`digest` 的承诺是“无正文的结构”,
204
+ * 所以文档的 digest = 结构目录(与 `map` 同源),而不是一句“没有结构”。
205
+ */
206
+ const docKind = docKindOf(request.filePath)
207
+ if (docKind !== null) {
208
+ return {
209
+ ...renderDocMap(base, lines, docKind, request),
210
+ mode: 'digest',
211
+ note: 'documents have no signatures; this is their structure directory (same as mode=map)',
212
+ }
213
+ }
214
+ /**
215
+ * 正文放 `text`(不是 `note`):`metrics.chars` 量的是 `text` 的长度(read-test 钉着这条等式),
216
+ * 只给 `note` 时 `chars` 会报 0,与 note 里的实际信息矛盾(反馈 D8)。
217
+ */
201
218
  return {
202
219
  ...base,
203
220
  mode: 'digest',
204
- note: `unsupported extension — no structure available; ${base.totalLines} lines`,
221
+ text: `unsupported extension — no structure available; ${base.totalLines} lines`,
205
222
  next: 'mode=lines lines=[a,b]',
206
223
  }
207
224
  }
@@ -1524,7 +1541,23 @@ function planIncremental(request, lines, version) {
1524
1541
  : { kind: 'skip' }
1525
1542
  }
1526
1543
  if (entry.version === version) {
1527
- return { kind: 'no-change', baseline: entry.version, asked, partial: entry.covered !== true, bomChanged: false }
1544
+ /**
1545
+ * 同一版重读:**只有账本里那份是完整交付时**,“不发正文”才成立。
1546
+ *
1547
+ * 上一次被预算截断(`covered = false`)时,调用方手里只有前半份。旧实现仍走
1548
+ * `no-change`,只回一句“去用 lines 拿剩下的”,于是**显式提高 budget 的请求被吞掉**:
1549
+ * 实测 401 行文件第二次 `full` + `budget: 60000` 只拿到 155 字符,且 `truncated: false`——
1550
+ * 程序化调用方会把它当成“已拿全”。(反馈 D4)
1551
+ *
1552
+ * 现在:没被显式要求增量 ⇒ `skip`(普通 full 渲染,按本次 budget 出正文);
1553
+ * 被显式要求增量 ⇒ `degrade`(全文 + 为何不能给差量)。两者都不会再谎报“没变化”。
1554
+ */
1555
+ if (entry.covered !== true) {
1556
+ return asked
1557
+ ? { kind: 'degrade', asked: true, reason: 'your earlier read of this file was cut at the content budget, so the ledger copy is not a complete baseline; the full text follows' }
1558
+ : { kind: 'skip' }
1559
+ }
1560
+ return { kind: 'no-change', baseline: entry.version, asked, bomChanged: false }
1528
1561
  }
1529
1562
  if (entry.covered !== true) {
1530
1563
  return asked
@@ -1563,15 +1596,9 @@ function planIncremental(request, lines, version) {
1563
1596
 
1564
1597
  /** 文件没变时的回执:**明写 no change、明写基准令牌,不给正文**。 */
1565
1598
  function noChangeEnvelope(base, plan, total) {
1566
- const partial = plan.partial === true
1567
1599
  const bom = plan.bomChanged === true ? ' (the byte-order mark changed; the text itself is unchanged)' : ''
1568
- const note = partial
1569
- ? `no change since baseline ${plan.baseline} — but your earlier read of this file was cut at the content budget, so it is not the whole file`
1570
- : `no change since baseline ${plan.baseline} — the file is byte-identical to the copy you already have${bom}`
1571
- const text = partial
1572
- ? `no change since baseline ${plan.baseline}; your earlier copy was truncated at the content budget — ask for the missing lines with mode=lines if you need them`
1573
- : `no change since baseline ${plan.baseline} — nothing to send${bom}`
1574
- return { ...base, mode: 'full', note, text, next: `mode=lines lines=[1,${total}] to get the whole text again` }
1600
+ const note = `no change since baseline ${plan.baseline} — the file is byte-identical to the copy you already have${bom}`
1601
+ return { ...base, mode: 'full', note, text: `no change since baseline ${plan.baseline} — nothing to send${bom}`, next: `mode=lines lines=[1,${total}] to get the whole text again` }
1575
1602
  }
1576
1603
 
1577
1604
  /**
@@ -2262,6 +2289,7 @@ export function readWith(source, request = {}) {
2262
2289
  // 符号索引只建一次:`map`/`digest`/`around` 本来就要用,而 `lines`/`search` 现在也要
2263
2290
  // (给裸行号附上可重新定位的引用)。没语言配置(未索引扩展名)时为 null,各处自行降级。
2264
2291
  const symbolsIndex = lang === null ? null : indexStructure(source, lang)
2292
+ if (mode === 'probe') return renderProbe(base, source, lang, docKindOf(request.filePath), request)
2265
2293
  if (mode === 'map') return renderMap(base, source, lines, lang, request, symbolsIndex)
2266
2294
  if (mode === 'digest') return renderDigest(base, source, lines, lang, request, symbolsIndex)
2267
2295
  /**
@@ -2427,6 +2455,94 @@ export function readWith(source, request = {}) {
2427
2455
  return result
2428
2456
  }
2429
2457
 
2458
+ /**
2459
+ * probe —— 只回答“这是什么文件”,不重复正文。
2460
+ *
2461
+ * 用途:调用方先判断“值不值得读”。行数、字节数、行尾风格、编码、语言/文档类型、
2462
+ * 结构规模(符号数或节/键数)都在这一行里;正文一律不发。
2463
+ *
2464
+ * 它不是 `map` 的替代:`map` 给的是**可追问的目录**,`probe` 给的是一句**元信息摘要**。
2465
+ * 代价也只有本地 IO——但对于“先看一眼再决定”这种意图,它省的是整份回执的 token。
2466
+ */
2467
+ /** 1 symbols / 1 sections 这种复数噪声一律收掉(回执是给人读的)。 */
2468
+ const unitWord = (n, unit) => (n === 1 && unit.endsWith('s') ? unit.slice(0, -1) : unit)
2469
+
2470
+ function renderProbe(base, source, lang, docKind, request) {
2471
+ const bytes = Number.isSafeInteger(request.fileSize) ? request.fileSize : new TextEncoder().encode(source).length
2472
+ const hasCrlf = source.includes('\r\n')
2473
+ const hasLf = source.replaceAll('\r\n', '').includes('\n')
2474
+ const lineEndings = hasCrlf && hasLf ? 'mixed' : hasCrlf ? 'crlf' : 'lf'
2475
+ const encoding = typeof request.sourceEncoding === 'string' ? request.sourceEncoding : null
2476
+ /**
2477
+ * `lang` 是**语法配置对象**(`langOf` 的返回值),不是名字;直接拼进摘要会得到
2478
+ * `[object Object]`(实测)。名字取扩展名,与 `langOf` 判定的是同一个东西。
2479
+ */
2480
+ const dot = typeof request.filePath === 'string' ? request.filePath.lastIndexOf('.') : -1
2481
+ const language = lang !== null && dot >= 0 ? request.filePath.slice(dot + 1).toLowerCase() : null
2482
+ let structure = null
2483
+ if (lang !== null) {
2484
+ const n = listSymbols(indexStructure(source, lang)).length
2485
+ structure = `${n} ${unitWord(n, 'symbols')}`
2486
+ } else if (docKind !== null) {
2487
+ const index = indexDocument(source, docKind)
2488
+ structure = `${index.entries.length} ${unitWord(index.entries.length, index.unit)}`
2489
+ }
2490
+ const parts = [`${base.totalLines} lines`, `${bytes} bytes`, lineEndings === 'mixed' ? 'mixed line endings' : lineEndings.toUpperCase()]
2491
+ if (encoding !== null) parts.push(encoding)
2492
+ if (language !== null) parts.push(language)
2493
+ else if (docKind !== null) parts.push(docKind)
2494
+ if (structure !== null) parts.push(structure)
2495
+ return {
2496
+ ...base,
2497
+ mode: 'probe',
2498
+ bytes,
2499
+ lineEndings,
2500
+ ...(encoding === null ? {} : { encoding }),
2501
+ ...(language === null ? {} : { language }),
2502
+ ...(docKind === null ? {} : { docKind }),
2503
+ ...(structure === null ? {} : { structure }),
2504
+ text: parts.join(' · '),
2505
+ next: 'mode=map for a directory; mode=lines lines=[a,b] for a section; mode=full for every line',
2506
+ }
2507
+ }
2508
+
2509
+ /**
2510
+ * 文档 / 配置的目录:行号 + 层级 + 名字,不复制正文。
2511
+ *
2512
+ * 与代码地图同一套预算与续读语义:超出预算时 `omitted` 点名区间,下一步是 `mode=lines`。
2513
+ */
2514
+ function renderDocMap(base, lines, docKind, request) {
2515
+ const index = indexDocument(lines.join('\n'), docKind)
2516
+ const kindFilter = typeof request.kind === 'string' ? request.kind : null
2517
+ const entries = kindFilter === null ? index.entries : index.entries.filter((entry) => entry.kind === kindFilter)
2518
+ if (entries.length === 0) {
2519
+ return {
2520
+ ...base,
2521
+ mode: 'map',
2522
+ text: `${base.totalLines} lines · no ${index.unit} found in this ${docKind} file`,
2523
+ ...(index.note === undefined ? {} : { note: index.note }),
2524
+ next: 'use mode=full or mode=lines to read content',
2525
+ }
2526
+ }
2527
+ const byKind = {}
2528
+ for (const entry of entries) byKind[entry.kind] = (byKind[entry.kind] ?? 0) + 1
2529
+ const header = `${base.totalLines} lines · ${index.entries.length} ${unitWord(index.entries.length, index.unit)} (${Object.entries(byKind).map(([kind, n]) => `${kind}:${n}`).join(' ')})`
2530
+ const rows = entries.map((entry) => `${' '.repeat(Math.max(0, entry.depth - 1))}${entry.name} ${entry.startLine}-${entry.endLine}`)
2531
+ const kept = fitWithin([header, ...rows].join('\n'), base.contentBudget)
2532
+ const shown = Math.max(0, kept.kept - 1)
2533
+ return {
2534
+ ...base,
2535
+ mode: 'map',
2536
+ symbols: index.entries.length,
2537
+ shown,
2538
+ byKind,
2539
+ text: kept.text,
2540
+ ...(index.note === undefined ? {} : { note: index.note }),
2541
+ ...(shown < rows.length ? { omitted: `${index.unit} ${shown + 1}..${rows.length} of ${rows.length} beyond the content budget; read them with mode=lines over their line range` } : {}),
2542
+ next: 'mode=lines lines=[a,b] to read a section; mode=map kind=<kind> to filter',
2543
+ }
2544
+ }
2545
+
2430
2546
  /**
2431
2547
  * map —— 先给地图。
2432
2548
  *
@@ -2437,10 +2553,18 @@ export function readWith(source, request = {}) {
2437
2553
  */
2438
2554
  function renderMap(base, source, lines, lang, request, symbolsIndex = null) {
2439
2555
  if (lang === null) {
2556
+ /**
2557
+ * 文档 / 配置(.md/.json/.yaml/.toml)也应有目录:实测 22% 的读取目标是这几类扩展名
2558
+ * (最近 62 个会话 / 1,836 次 read),而旧实现只回一句“unsupported extension”。
2559
+ * 结构索引在 `doc-structure.mjs`;这里只负责渲染与续读。
2560
+ */
2561
+ const docKind = docKindOf(request.filePath)
2562
+ if (docKind !== null) return renderDocMap(base, lines, docKind, request)
2563
+ // 同上:正文进 `text`,否则 `metrics.chars` 为 0 而同一条回执的说明里写着实际字符数。
2440
2564
  return {
2441
2565
  ...base,
2442
2566
  mode: 'map',
2443
- note: `unsupported extension — no symbol index; ${base.totalLines} lines, ${source.length} chars`,
2567
+ text: `unsupported extension — no symbol index; ${base.totalLines} lines, ${source.length} chars`,
2444
2568
  next: 'use mode=full or mode=lines to read content',
2445
2569
  }
2446
2570
  }
@@ -3099,7 +3099,13 @@ async function verifyWriteBack(service, target, written, displayPath, signal) {
3099
3099
  let actual
3100
3100
  try {
3101
3101
  if (level === 'bytes') {
3102
- expected = new TextEncoder().encode(written)
3102
+ /**
3103
+ * 期望字节由**提供方**回答(可选 `encodeFor`):非 UTF-8 存储下,引擎不能假设
3104
+ * `TextEncoder` 就是落盘字节。没有这个方法的提供方(DSH 壳)仍走 UTF-8,行为不变。
3105
+ */
3106
+ expected = typeof service.encodeFor === 'function'
3107
+ ? Buffer.from(await service.encodeFor(target, written))
3108
+ : new TextEncoder().encode(written)
3103
3109
  actual = await service.readByteRange(
3104
3110
  target,
3105
3111
  { offset: 0, length: expected.length + READBACK_PROBE_EXTRA_BYTES },
@@ -3190,14 +3196,25 @@ function errorCodeOf(error) {
3190
3196
  *
3191
3197
  * @throws PatchError('PATCH_UNOBSERVED')
3192
3198
  */
3193
- export function requireObservedRevision({ displayPath, identityKey, content, byteOrderMark = false }) {
3199
+ export function requireObservedRevision({ displayPath, identityKey, content, byteOrderMark = false, allowBlindHint = false }) {
3194
3200
  const ledgerKey = ledgerIdentityKey(displayPath, identityKey)
3195
3201
  const observation = latestObservation(ledgerKey, { source: content })
3196
3202
  const currentToken = contentToken(content, byteOrderMark)
3197
3203
  if (observation !== null && observation.version === currentToken) return
3198
3204
  const changed = observation !== null
3205
+ /**
3206
+ * 回执里直接给**下一次调用**,而不是只说“先读”:实测 5 天内 22 次 PATCH_UNOBSERVED,
3207
+ * 每次都要模型自己挑读法(多半选 full,5k+ 字符)。这里按当前内容的体量挑一个装得下预算
3208
+ * 的模式;整份覆写还可以走已有的显式逃生口 `allow_unobserved`,把“读一遍再写”折成
3209
+ * “一次带标记的写”——代价(unverified_region)写在回执里,而不是藏在 schema 描述里。
3210
+ */
3211
+ const readHint = content.length <= 6000 ? 'full' : 'map'
3199
3212
  throw new PatchError(
3200
- `cannot patch "${displayPath}": ${changed ? 'the file changed since your last read' : 'this file has not been read in this session'} — read it first (aura_basetools_read), then retry${changed ? ' against the current revision' : ''} — nothing was written`,
3213
+ `cannot patch "${displayPath}": ${changed ? 'the file changed since your last read' : 'this file has not been read in this session'} — read it first (aura_basetools_read), then retry${changed ? ' against the current revision' : ''} — nothing was written` +
3214
+ `\nnext: aura_basetools_read { file_path: "${displayPath}", mode: "${readHint}" } — then retry` +
3215
+ (allowBlindHint
3216
+ ? `\n or pass allow_unobserved: true if this is a deliberate whole-file overwrite; the receipt will be marked unverified_region`
3217
+ : ''),
3201
3218
  'PATCH_UNOBSERVED',
3202
3219
  {
3203
3220
  reason: changed ? 'changed-since-read' : 'never-read',
@@ -3273,6 +3290,8 @@ async function planEditToProvider(provider, request) {
3273
3290
  ...(request.identityKey === undefined ? {} : { identityKey: request.identityKey }),
3274
3291
  content,
3275
3292
  byteOrderMark: byteOrderMarks > 0,
3293
+ // 只有整份覆写才有显式逃生口(`allow_unobserved`);edit 的严格模式不提供它。
3294
+ allowBlindHint: typeof request.wholeFile === 'string',
3276
3295
  })
3277
3296
  }
3278
3297
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aura-basetools",
3
- "version": "0.20.0",
3
+ "version": "0.21.0",
4
4
  "description": "Byte-exact read, edit and write tools for coding agents. Standalone CLI and MCP server; no DSH required. 面向 coding agent 的逐字节读写工具;独立 CLI / MCP,可选 DSH 适配器。",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -49,6 +49,12 @@ say('=== A. 正常环境 ===')
49
49
  check('A4 所有检查项通过', (report?.checks ?? []).every((c) => c.ok === true), JSON.stringify((report?.checks ?? []).filter((c) => !c.ok)))
50
50
  check('A5 MCP 握手列出两个工具', /aura_basetools_read/.test(JSON.stringify(report?.checks)) && /aura_basetools_edit/.test(JSON.stringify(report?.checks)))
51
51
  check('A6 缓存目录真的被创建', fs.existsSync(cache), cache)
52
+ // strict 是“提示”而不是“检查项”:工具侧开不了它(那是 provider 配置),但装完就应该看得到怎么开。
53
+ check(
54
+ 'A7 doctor 给出 strict 启用提示',
55
+ report?.strict?.compatible === true && typeof report?.strict?.deepseek === 'string' && typeof report?.strict?.convert === 'string',
56
+ JSON.stringify(report?.strict).slice(0, 140),
57
+ )
52
58
  }
53
59
 
54
60
  say('')