aura-basetools 0.7.0 → 0.13.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 +223 -13
- package/bin/aura-mcp.mjs +169 -27
- package/bin/aura.mjs +257 -37
- package/lib/observation-store.mjs +150 -17
- package/lib/ops.mjs +183 -19
- package/lib/transaction-store.mjs +1 -1
- package/lib/workspace-lock.mjs +25 -4
- package/locator-lab/anchor-core.mjs +1 -1
- package/locator-lab/args.mjs +103 -6
- package/locator-lab/locator-api.mjs +260 -22
- package/locator-lab/read-core.mjs +634 -563
- package/locator-lab/repo-map.mjs +1 -1
- package/locator-lab/struct-core.mjs +92 -34
- package/locator-lab/write-path.mjs +661 -112
- package/package.json +8 -8
- package/test/argument-surface-test.mjs +119 -0
- package/test/doctor-test.mjs +73 -0
- package/test/observation-concurrency-test.mjs +90 -0
- package/test/standalone-test.mjs +202 -128
- package/test/write-test.mjs +294 -0
package/bin/aura.mjs
CHANGED
|
@@ -2,19 +2,23 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* aura CLI(薄壳):参数解析 → `lib/ops.mjs` → 一行 JSON。
|
|
4
4
|
*
|
|
5
|
-
* aura read --file F [--mode …] [--around X] [--lines a,b] [--search S] [--since V] [--max-chars N]
|
|
6
|
-
* aura edit --file F (--hunks '<json>' | --hunks-file P) [--dry-run] [--create <文本|@文件>]
|
|
7
|
-
* aura read --targets '[...]|@spec.json' 一次读多个目标(与 --file 互斥)
|
|
8
|
-
* aura edit --targets '[...]|@spec.json' 一次改多个文件(与 --file 互斥;任一规划失败 ⇒ 零写入)
|
|
9
|
-
* aura undo [--id <事务 id>] [--force] 撤销最近一次/指定写事务
|
|
10
|
-
* aura transactions 列出当前工作区写事务
|
|
11
|
-
* aura mcp
|
|
5
|
+
* aura basetools read --file F [--mode …] [--around X] [--lines a,b] [--search S] [--since V] [--max-chars N]
|
|
6
|
+
* aura basetools edit --file F (--hunks '<json>' | --hunks-file P) [--dry-run] [--create <文本|@文件>]
|
|
7
|
+
* aura basetools read --targets '[...]|@spec.json' 一次读多个目标(与 --file 互斥)
|
|
8
|
+
* aura basetools edit --targets '[...]|@spec.json' 一次改多个文件(与 --file 互斥;任一规划失败 ⇒ 零写入)
|
|
9
|
+
* aura basetools undo [--id <事务 id>] [--force] 撤销最近一次/指定写事务
|
|
10
|
+
* aura basetools transactions 列出当前工作区写事务
|
|
11
|
+
* aura basetools mcp
|
|
12
12
|
* aura --help | --version
|
|
13
13
|
*
|
|
14
14
|
* 退出码:0 成功;2 结构化拒绝(回执带 code/next 一类信息);1 用法错误。
|
|
15
15
|
*/
|
|
16
16
|
import fs from 'node:fs'
|
|
17
|
+
import os from 'node:os'
|
|
18
|
+
import path from 'node:path'
|
|
17
19
|
import process from 'node:process'
|
|
20
|
+
import { spawnSync } from 'node:child_process'
|
|
21
|
+
import { fileURLToPath } from 'node:url'
|
|
18
22
|
import {
|
|
19
23
|
openProvider,
|
|
20
24
|
readEnvelope,
|
|
@@ -25,9 +29,13 @@ import {
|
|
|
25
29
|
editManyEnvelope,
|
|
26
30
|
undoEnvelope,
|
|
27
31
|
transactionsEnvelope,
|
|
32
|
+
capabilitiesEnvelope,
|
|
33
|
+
writeEnvelope,
|
|
28
34
|
assertReadTargets,
|
|
29
35
|
assertEditTargets,
|
|
30
36
|
assertKnownArgs,
|
|
37
|
+
MT_READ_ELEMENT_KEYS,
|
|
38
|
+
MT_EDIT_ELEMENT_KEYS,
|
|
31
39
|
errorEvidence,
|
|
32
40
|
} from '../lib/ops.mjs'
|
|
33
41
|
// `flatHunk` 是 `locator-lab/args.mjs` 里那一份容错规则(DSH 壳与 CLI 共用),但**不在这里定位引擎**:
|
|
@@ -42,7 +50,17 @@ const VERSION = (() => {
|
|
|
42
50
|
return 'dev'
|
|
43
51
|
}
|
|
44
52
|
})()
|
|
45
|
-
const
|
|
53
|
+
const rootArgv = process.argv.slice(2)
|
|
54
|
+
/**
|
|
55
|
+
* CLI 命名空间:`aura basetools <verb>`。
|
|
56
|
+
*
|
|
57
|
+
* 顶层 `read` / `edit` / `write` ... 不再保留别名:以后会有多个 aura 产品共用 `aura`
|
|
58
|
+
* 这个入口,顶层动词迟早撞名。命名空间只改**调用写法**,引擎与回执不变。
|
|
59
|
+
*/
|
|
60
|
+
let argv = rootArgv
|
|
61
|
+
const namespaced = rootArgv[0] === 'basetools'
|
|
62
|
+
if (namespaced) argv = argv.slice(1)
|
|
63
|
+
let command = argv[0]
|
|
46
64
|
|
|
47
65
|
/**
|
|
48
66
|
/**
|
|
@@ -56,9 +74,72 @@ const argv = process.argv.slice(2)
|
|
|
56
74
|
* 有了这份名单,“下一个是已知布尔旗标”才当没带值,**其它以 `--` 开头的东西都当值**。
|
|
57
75
|
* 代价是名单要维护;收益是那个静默丢值的路径从根上消失,而不是靠叫用户改写法。
|
|
58
76
|
*/
|
|
59
|
-
const BOOLEAN_FLAGS = new Set(['pretty', 'dry-run', 'transaction', 'force'])
|
|
77
|
+
const BOOLEAN_FLAGS = new Set(['pretty', 'dry-run', 'plan', 'transaction', 'force', 'no-overwrite', 'replace-all', 'require-observation', 'allow-unobserved', 'json'])
|
|
60
78
|
const isBooleanFlag = (token) => typeof token === 'string' && BOOLEAN_FLAGS.has(token.replace(/^--/, ''))
|
|
61
79
|
|
|
80
|
+
/**
|
|
81
|
+
* 每个命令认识的旗标(唯一一份)。`flag()` 只读取它要找的那个名字;**不在这里的 `--xxx`
|
|
82
|
+
* 以前会被静默忽略**——`--dryrun` 于是变成“没传 dry-run”的一次真写盘。
|
|
83
|
+
*
|
|
84
|
+
* 这里同时承担两件事:未知旗标拒绝(`ARGS_UNKNOWN_KEY`)与扫描时的“这个旗标要不要吃值”。
|
|
85
|
+
* 合法键名与命令实现放在同一张表里,新增旗标不会出现“parser 认、校验不认”的分叉。
|
|
86
|
+
*/
|
|
87
|
+
const COMMAND_FLAGS = {
|
|
88
|
+
read: {
|
|
89
|
+
value: ['cwd', 'file', 'targets', 'mode', 'around', 'lines', 'start-line', 'end-line', 'search', 'since', 'context', 'kind', 'budget', 'max-chars', 'offset', 'limit'],
|
|
90
|
+
bool: ['pretty'],
|
|
91
|
+
},
|
|
92
|
+
edit: {
|
|
93
|
+
value: ['cwd', 'file', 'targets', 'hunks', 'hunks-file', 'create', 'target', 'intent', 'lines', 'start-line', 'end-line', 'line-cue', 'anchor', 'old', 'context', 'handle', 'line', 'position', 'replace', 'expect', 'near', 'eol'],
|
|
94
|
+
bool: ['pretty', 'dry-run', 'plan', 'transaction', 'replace-all', 'require-observation'],
|
|
95
|
+
},
|
|
96
|
+
write: {
|
|
97
|
+
value: ['cwd', 'file', 'content', 'eol'],
|
|
98
|
+
bool: ['pretty', 'dry-run', 'plan', 'transaction', 'no-overwrite', 'allow-unobserved'],
|
|
99
|
+
},
|
|
100
|
+
undo: { value: ['cwd', 'id'], bool: ['pretty', 'force'] },
|
|
101
|
+
transactions: { value: ['cwd'], bool: ['pretty'] },
|
|
102
|
+
doctor: { value: ['cwd'], bool: ['pretty', 'json'] },
|
|
103
|
+
capabilities: { value: ['cwd'], bool: ['pretty'] },
|
|
104
|
+
mcp: { value: ['cwd'], bool: ['pretty'] },
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* 扫一遍命令后的 argv:未知旗标 / 裸位置参数 / 布尔旗标带值 一律拒绝。
|
|
109
|
+
*
|
|
110
|
+
* **值旗标吃下一个 token**(与 `flag()` 的既有语义一致):`--content "---"` 这种以 `--`
|
|
111
|
+
* 开头的正文不能被误判成旗标。没有值的值旗标不在这一层判(各命令会给出自己的错误)。
|
|
112
|
+
*/
|
|
113
|
+
function validateCommandFlags(command, tokens) {
|
|
114
|
+
const spec = COMMAND_FLAGS[command]
|
|
115
|
+
if (spec === undefined) return undefined
|
|
116
|
+
const valueFlags = new Set(spec.value)
|
|
117
|
+
const boolFlags = new Set(spec.bool)
|
|
118
|
+
for (let i = 0; i < tokens.length; i += 1) {
|
|
119
|
+
const token = tokens[i]
|
|
120
|
+
if (typeof token !== 'string' || !token.startsWith('--')) {
|
|
121
|
+
return `unexpected argument ${JSON.stringify(token)} — this command takes flags only`
|
|
122
|
+
}
|
|
123
|
+
const eq = token.indexOf('=')
|
|
124
|
+
const name = token.slice(2, eq === -1 ? undefined : eq)
|
|
125
|
+
if (boolFlags.has(name)) {
|
|
126
|
+
if (eq !== -1) return `--${name} is a boolean flag and takes no value (got ${JSON.stringify(token.slice(eq + 1))})`
|
|
127
|
+
continue
|
|
128
|
+
}
|
|
129
|
+
if (!valueFlags.has(name)) {
|
|
130
|
+
const legal = [...spec.value, ...spec.bool].sort().map((flag) => `--${flag}`).join(', ')
|
|
131
|
+
return `unknown flag --${name}; this command accepts: ${legal}`
|
|
132
|
+
}
|
|
133
|
+
if (eq === -1) {
|
|
134
|
+
const next = tokens[i + 1]
|
|
135
|
+
// 与 `flag()` 同一条:下一个 token 若是本命令的布尔旗标,就当“这个值旗标没带值”,
|
|
136
|
+
// 不去吃它(否则 `--content --dry-run` 会把 dry-run 藏进正文里)。
|
|
137
|
+
if (next !== undefined && !(next.startsWith('--') && boolFlags.has(next.slice(2)))) i += 1
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return undefined
|
|
141
|
+
}
|
|
142
|
+
|
|
62
143
|
const flag = (name, fallback = undefined) => {
|
|
63
144
|
const i = argv.indexOf(`--${name}`)
|
|
64
145
|
if (i >= 0) {
|
|
@@ -73,38 +154,51 @@ const emit = (obj, code) => {
|
|
|
73
154
|
process.stdout.write(`${has('pretty') ? JSON.stringify(obj, null, 2) : JSON.stringify(obj)}\n`)
|
|
74
155
|
process.exit(code)
|
|
75
156
|
}
|
|
157
|
+
/** `@文件` 参数读不到时回一条 JSON 回执,而不是把 Node 栈抛给调用方。 */
|
|
158
|
+
const readArgumentFile = (target, flagName) => {
|
|
159
|
+
try {
|
|
160
|
+
return fs.readFileSync(target, 'utf8')
|
|
161
|
+
} catch (error) {
|
|
162
|
+
emit({ ok: false, error: `${flagName} 读不到 ${target}:${error.message}`, code: 'USAGE' }, 1)
|
|
163
|
+
}
|
|
164
|
+
}
|
|
76
165
|
|
|
77
166
|
const USAGE = [
|
|
78
167
|
`aura ${VERSION} —— 按意图读写文件(独立版:不依赖 DeepSeek Harness,Windows / macOS / Linux 同形)`,
|
|
79
168
|
'',
|
|
80
|
-
' aura read --file <路径> [--mode map|digest|full|around|lines|search|changed]',
|
|
169
|
+
' aura basetools read --file <路径> [--mode map|digest|full|around|lines|search|changed]',
|
|
81
170
|
' [--around <符号>] [--lines <a,b>|<a-b>|<n>] [--start-line <n> --end-line <n>]',
|
|
82
|
-
' [--search <子串>] [--since <令牌>] [--max-chars <n>]',
|
|
171
|
+
' [--search <子串>] [--context <n>] [--kind <类型>] [--budget <字符数>] [--since <令牌>] [--max-chars <n>] [--offset <n> --limit <n>]',
|
|
83
172
|
// `--file` 指向**目录**时直接给一张仓库地图(模块/目录/依赖方向),不需要额外旗标。
|
|
84
173
|
' (--file 传目录 ⇒ 仓库地图:模块清单 + 目录骨架 + 被依赖最多的模块 + 入口点)',
|
|
85
|
-
' aura read --targets <json|@文件> # 一次读多个目标(≤32 项;与 --file 互斥)',
|
|
86
|
-
' aura edit --targets <json|@文件> [--dry-run]
|
|
87
|
-
" aura edit --file <路径> --create <内容|@文件> # 建文件(不必再给 --hunks)",
|
|
88
|
-
" aura edit --file <路径> (--hunks '<json>' | --hunks-file <路径>) [--dry-run]",
|
|
89
|
-
' aura edit --file <路径> --lines <a,b> --replace <文本> [--expect <文本>] [--near <n>]',
|
|
90
|
-
|
|
174
|
+
' aura basetools read --targets <json|@文件> # 一次读多个目标(≤32 项;与 --file 互斥)',
|
|
175
|
+
' aura basetools edit --targets <json|@文件> [--dry-run|--plan] # 一次改多个文件(先全量规划,任一失败则零写入)',
|
|
176
|
+
" aura basetools edit --file <路径> --create <内容|@文件> # 建文件(不必再给 --hunks)",
|
|
177
|
+
" aura basetools edit --file <路径> (--hunks '<json>' | --hunks-file <路径>) [--dry-run|--plan] [--eol auto|preserve|lf|crlf] [--require-observation]",
|
|
178
|
+
' aura basetools edit --file <路径> --lines <a,b> --replace <文本> [--expect <文本>] [--near <n>]',
|
|
179
|
+
' aura basetools edit --file <路径> --old <原文> --replace <文本> --replace-all # 改全部非重叠出现',
|
|
180
|
+
" (只改一处时不必包成 JSON:--target / --intent / --lines / --start-line+--end-line / --line-cue / --anchor 任选其一)",
|
|
91
181
|
// 句柄(fs-contract-v2 §5):读侧发的柄 + 三种定位写法。下面三行是三种**用法**,不是三种键。
|
|
92
|
-
' aura edit --file <路径> --handle <h…> --replace <文本> # 整段替换:区间由柄定,不靠转述',
|
|
93
|
-
' aura edit --file <路径> --handle <h…> --line <段内行号> --replace <文本> # 只换区间内第 k 行(1-based,从区间首行数起)',
|
|
94
|
-
' aura edit --file <路径> --handle <h…> --position before|after --replace <文本> # 在区间边界插入,区间本身不动',
|
|
182
|
+
' aura basetools edit --file <路径> --handle <h…> --replace <文本> # 整段替换:区间由柄定,不靠转述',
|
|
183
|
+
' aura basetools edit --file <路径> --handle <h…> --line <段内行号> --replace <文本> # 只换区间内第 k 行(1-based,从区间首行数起)',
|
|
184
|
+
' aura basetools edit --file <路径> --handle <h…> --position before|after --replace <文本> # 在区间边界插入,区间本身不动',
|
|
95
185
|
" (--line / --position 是从属键:只有与 --handle 同时给才成立,单独给不构成一次编辑)",
|
|
96
|
-
' aura mcp 启动 MCP stdio 服务',
|
|
186
|
+
' aura basetools mcp 启动 MCP stdio 服务',
|
|
187
|
+
' aura basetools write --file <路径> --content <文本|@文件> [--no-overwrite] [--dry-run|--plan] [--transaction] [--allow-unobserved] [--eol auto|preserve|lf|crlf]',
|
|
188
|
+
' (覆写默认要求先读过这一版;--allow-unobserved 才允许盲写)',
|
|
189
|
+
' aura basetools doctor [--json] 自检:Node/版本/MCP 握手/缓存与锁目录可写',
|
|
190
|
+
' aura basetools capabilities [--pretty] 能力清单:读模式/地址档/语言/限制(JSON)',
|
|
97
191
|
' aura --help | --version',
|
|
98
192
|
'',
|
|
99
|
-
'hunks 形状:[{"target":"符号名"|"lines":[a,b]|"line_cue":"唯一词"|"anchor":"记得的原文",',
|
|
193
|
+
'hunks 形状:[{"target":"符号名"|"intent":"模糊意图(符号片段/短语/近似原文)"|"lines":[a,b]|"line_cue":"唯一词"|"anchor":"记得的原文",',
|
|
100
194
|
' "replace":"替换文本","expect":"该处应是什么","near":行号}]',
|
|
101
195
|
'',
|
|
102
196
|
'例:',
|
|
103
|
-
" aura edit --file a.mjs --hunks '[{\"lines\":[5,5],\"replace\":\"x\"}]' # 单引号包住整段 JSON",
|
|
104
|
-
' aura edit --file a.mjs --hunks-file hunks.json # 内容走文件,绕开引号',
|
|
105
|
-
' aura edit --file new.md --create @body.md # @文件 ——推荐给长内容',
|
|
106
|
-
' aura edit --file new.md --create=--- # 值以 -- 开头时必须用 = 形式',
|
|
107
|
-
' aura read --file ./src # 目录 ⇒ 仓库地图',
|
|
197
|
+
" aura basetools edit --file a.mjs --hunks '[{\"lines\":[5,5],\"replace\":\"x\"}]' # 单引号包住整段 JSON",
|
|
198
|
+
' aura basetools edit --file a.mjs --hunks-file hunks.json # 内容走文件,绕开引号',
|
|
199
|
+
' aura basetools edit --file new.md --create @body.md # @文件 ——推荐给长内容',
|
|
200
|
+
' aura basetools edit --file new.md --create=--- # 值以 -- 开头时必须用 = 形式',
|
|
201
|
+
' aura basetools read --file ./src # 目录 ⇒ 仓库地图',
|
|
108
202
|
'',
|
|
109
203
|
'PowerShell 两个已踩过的坑:',
|
|
110
204
|
' · 反引号 `n 写进 JSON 字符串会变成控制字符(JSON 不允许)⇒ 用 --hunks-file 或单引号 heredoc;',
|
|
@@ -126,10 +220,10 @@ const USAGE = [
|
|
|
126
220
|
* (于是“两个都给”由那条唯一判据说话)、把引擎抛的参数面错接到 exit 1 那一档。
|
|
127
221
|
*
|
|
128
222
|
* @param assertShape - 读侧 `assertReadTargets`、写侧 `assertEditTargets`(同一个判据,两套提示语)
|
|
129
|
-
* @param toolName - 回执里点名的工具(`
|
|
223
|
+
* @param toolName - 回执里点名的工具(`aura_basetools_read` / `aura_basetools_edit`)
|
|
130
224
|
* @returns `{ targets, dryRun }`(逐项判决在引擎里,这里只递参数)
|
|
131
225
|
*/
|
|
132
|
-
const engineTargets = (assertShape, toolName) => {
|
|
226
|
+
const engineTargets = (assertShape, toolName, elementKeys) => {
|
|
133
227
|
const raw = flag('targets')
|
|
134
228
|
const where = typeof raw === 'string' && raw.startsWith('@') ? `--targets @${raw.slice(1)}` : '--targets'
|
|
135
229
|
if (typeof raw !== 'string') {
|
|
@@ -183,7 +277,7 @@ const engineTargets = (assertShape, toolName) => {
|
|
|
183
277
|
if (typeof flag('file') === 'string') source.file_path = flag('file')
|
|
184
278
|
let targets
|
|
185
279
|
try {
|
|
186
|
-
targets = assertShape(source, { toolName })
|
|
280
|
+
targets = assertShape(source, { toolName, elementKeys })
|
|
187
281
|
} catch (error) {
|
|
188
282
|
refuseShape(where, error)
|
|
189
283
|
}
|
|
@@ -211,9 +305,28 @@ const refuseShape = (where, error) =>
|
|
|
211
305
|
1,
|
|
212
306
|
)
|
|
213
307
|
|
|
214
|
-
const command = argv[0]
|
|
215
308
|
if (command === undefined || command === '--help' || command === '-h') emit({ ok: true, usage: USAGE }, 0)
|
|
216
309
|
if (command === '--version' || command === '-v') emit({ ok: true, version: VERSION }, 0)
|
|
310
|
+
/**
|
|
311
|
+
* 旧顶层动词只给**迁移提示**,不再作为别名执行——保留别名等于保留未来的撞名。
|
|
312
|
+
* 只提示、不执行:`aura basetools read` 不会偷偷改文件。
|
|
313
|
+
*/
|
|
314
|
+
const MOVED_COMMANDS = new Set(['read', 'outline', 'edit', 'write', 'undo', 'transactions', 'mcp', 'doctor', 'capabilities'])
|
|
315
|
+
if (!namespaced && MOVED_COMMANDS.has(command)) {
|
|
316
|
+
emit(
|
|
317
|
+
{
|
|
318
|
+
ok: false,
|
|
319
|
+
error: `command moved: use \`aura basetools ${command}\` (top-level verbs are reserved for product namespaces)`,
|
|
320
|
+
code: 'USAGE',
|
|
321
|
+
shape: 'CLI_NAMESPACE_REQUIRED',
|
|
322
|
+
},
|
|
323
|
+
1,
|
|
324
|
+
)
|
|
325
|
+
}
|
|
326
|
+
const flagProblem = validateCommandFlags(command, argv.slice(1))
|
|
327
|
+
if (flagProblem !== undefined) {
|
|
328
|
+
emit({ ok: false, error: flagProblem, code: 'USAGE', shape: 'ARGS_UNKNOWN_KEY' }, 1)
|
|
329
|
+
}
|
|
217
330
|
if (command === 'mcp') {
|
|
218
331
|
const { serve } = await import('./aura-mcp.mjs')
|
|
219
332
|
await serve()
|
|
@@ -222,6 +335,34 @@ if (command === 'mcp') {
|
|
|
222
335
|
|
|
223
336
|
const provider = openProvider(typeof flag('cwd') === 'string' ? flag('cwd') : process.cwd())
|
|
224
337
|
|
|
338
|
+
if (command === 'capabilities') {
|
|
339
|
+
const env = { ok: true, version: VERSION, ...capabilitiesEnvelope() }
|
|
340
|
+
process.stdout.write(`${JSON.stringify(env, null, has('pretty') ? 2 : 0)}\n`)
|
|
341
|
+
process.exit(0)
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (command === 'write') {
|
|
345
|
+
const file = flag('file')
|
|
346
|
+
if (typeof file !== 'string') emit({ ok: false, error: 'write 需要 --file <路径>', code: 'USAGE' }, 1)
|
|
347
|
+
const raw = flag('content')
|
|
348
|
+
if (typeof raw !== 'string') emit({ ok: false, error: 'write 需要 --content <文本|@文件>', code: 'USAGE' }, 1)
|
|
349
|
+
const content = raw.startsWith('@') ? readArgumentFile(raw.slice(1), '--content') : raw
|
|
350
|
+
const eol = flag('eol')
|
|
351
|
+
if (eol !== undefined && typeof eol !== 'string') {
|
|
352
|
+
emit({ ok: false, error: 'write 的 --eol 需要跟一个值:auto|preserve|lf|crlf', code: 'USAGE' }, 1)
|
|
353
|
+
}
|
|
354
|
+
const env = await writeEnvelope(provider, {
|
|
355
|
+
file,
|
|
356
|
+
content,
|
|
357
|
+
dryRun: has('dry-run') || has('plan'),
|
|
358
|
+
noOverwrite: has('no-overwrite'),
|
|
359
|
+
includeTransaction: has('transaction'),
|
|
360
|
+
allowUnobserved: has('allow-unobserved'),
|
|
361
|
+
...(typeof eol === 'string' ? { eol } : {}),
|
|
362
|
+
})
|
|
363
|
+
emit(env, env.ok ? 0 : 2)
|
|
364
|
+
}
|
|
365
|
+
|
|
225
366
|
if (command === 'undo') {
|
|
226
367
|
const env = await undoEnvelope(provider, {
|
|
227
368
|
id: typeof flag('id') === 'string' ? flag('id') : undefined,
|
|
@@ -235,13 +376,77 @@ if (command === 'transactions') {
|
|
|
235
376
|
emit(env, 0)
|
|
236
377
|
}
|
|
237
378
|
|
|
379
|
+
if (command === 'doctor') {
|
|
380
|
+
/**
|
|
381
|
+
* `aura basetools doctor`:把“装完能不能用”的机械检查放在 CLI 自己身上。
|
|
382
|
+
* 与安装器的 `--doctor` 不同:这里不假设 DSH profile,只查这个包自己的入口、MCP 握手与缓存目录。
|
|
383
|
+
*/
|
|
384
|
+
const checks = []
|
|
385
|
+
const add = (id, ok, detail) => checks.push({ id, ok, detail })
|
|
386
|
+
const nodeMajor = Number.parseInt(process.versions.node.split('.')[0], 10)
|
|
387
|
+
add('node', nodeMajor >= 18, `node ${process.versions.node} (need >=18)`)
|
|
388
|
+
add('version', typeof VERSION === 'string' && VERSION.length > 0, VERSION)
|
|
389
|
+
const mcpPath = fileURLToPath(new URL('./aura-mcp.mjs', import.meta.url))
|
|
390
|
+
add('mcp-bin', fs.existsSync(mcpPath), mcpPath)
|
|
391
|
+
const cacheDir = process.env.AURA_CACHE_DIR ?? path.join(os.homedir(), '.aura')
|
|
392
|
+
if (fs.existsSync(mcpPath)) {
|
|
393
|
+
const frames = [
|
|
394
|
+
JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'aura-doctor', version: '1' } } }),
|
|
395
|
+
JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }),
|
|
396
|
+
JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }),
|
|
397
|
+
'',
|
|
398
|
+
].join('\n')
|
|
399
|
+
const res = spawnSync(process.execPath, [mcpPath], {
|
|
400
|
+
input: frames,
|
|
401
|
+
encoding: 'utf8',
|
|
402
|
+
timeout: 30000,
|
|
403
|
+
env: { ...process.env, AURA_CACHE_DIR: cacheDir },
|
|
404
|
+
})
|
|
405
|
+
const tools = String(res.stdout ?? '').split('\n').flatMap((line) => {
|
|
406
|
+
try { return JSON.parse(line).result?.tools?.map((tool) => tool.name) ?? [] } catch { return [] }
|
|
407
|
+
})
|
|
408
|
+
add('mcp-handshake', res.status === 0 && tools.includes('aura_basetools_read') && tools.includes('aura_basetools_edit'), `exit=${res.status} tools=${tools.join(',') || '(none)'}`)
|
|
409
|
+
} else {
|
|
410
|
+
add('mcp-handshake', false, 'skipped: mcp bin missing')
|
|
411
|
+
}
|
|
412
|
+
const probeWritable = (dir, id) => {
|
|
413
|
+
try {
|
|
414
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
415
|
+
const probe = path.join(dir, `.doctor-${process.pid}`)
|
|
416
|
+
fs.writeFileSync(probe, 'ok')
|
|
417
|
+
const text = fs.readFileSync(probe, 'utf8')
|
|
418
|
+
fs.rmSync(probe, { force: true })
|
|
419
|
+
add(id, text === 'ok', dir)
|
|
420
|
+
} catch (error) {
|
|
421
|
+
add(id, false, `${dir}: ${String(error?.message ?? error)}`)
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
probeWritable(cacheDir, 'cache-writable')
|
|
425
|
+
probeWritable(path.join(cacheDir, 'locks'), 'lock-writable')
|
|
426
|
+
const ok = checks.every((check) => check.ok)
|
|
427
|
+
const report = {
|
|
428
|
+
ok,
|
|
429
|
+
version: VERSION,
|
|
430
|
+
node: process.versions.node,
|
|
431
|
+
checks,
|
|
432
|
+
...(process.env.AURA_NO_LOCK === '1' ? { notes: ['AURA_NO_LOCK=1: workspace lock is disabled'] } : {}),
|
|
433
|
+
}
|
|
434
|
+
if (has('json')) {
|
|
435
|
+
process.stdout.write(`${JSON.stringify(report)}\n`)
|
|
436
|
+
process.exit(ok ? 0 : 2)
|
|
437
|
+
}
|
|
438
|
+
process.stdout.write(`aura basetools doctor: ${ok ? 'ok' : 'FAILED'} (${VERSION}, node ${process.versions.node})\n`)
|
|
439
|
+
for (const check of checks) process.stdout.write(` ${check.ok ? '✓' : '✗'} ${check.id}: ${check.detail}\n`)
|
|
440
|
+
process.exit(ok ? 0 : 2)
|
|
441
|
+
}
|
|
442
|
+
|
|
238
443
|
if (command === 'read' || command === 'outline') {
|
|
239
444
|
/**
|
|
240
445
|
* 多目标(fs-contract-v2 §2.1):`--targets` 在场 ⇒ 引擎的批入口。
|
|
241
446
|
* `--file` 那条路一个字不动(就在下面,含它自己的 `USAGE` 与 `outline ⇒ map`)。
|
|
242
447
|
*/
|
|
243
448
|
if (has('targets')) {
|
|
244
|
-
const { targets } = engineTargets(assertReadTargets, '
|
|
449
|
+
const { targets } = engineTargets(assertReadTargets, 'aura_basetools_read', MT_READ_ELEMENT_KEYS)
|
|
245
450
|
const env = await readManyWith(provider, targets)
|
|
246
451
|
emit(env, env.ok ? 0 : 2)
|
|
247
452
|
}
|
|
@@ -262,7 +467,13 @@ if (command === 'read' || command === 'outline') {
|
|
|
262
467
|
start_line: flag('start-line'),
|
|
263
468
|
end_line: flag('end-line'),
|
|
264
469
|
search: flag('search'),
|
|
470
|
+
context: typeof flag('context') === 'string' ? Number(flag('context')) : undefined,
|
|
471
|
+
kind: typeof flag('kind') === 'string' ? flag('kind') : undefined,
|
|
472
|
+
budget: typeof flag('budget') === 'string' ? Number(flag('budget')) : undefined,
|
|
265
473
|
since: flag('since'),
|
|
474
|
+
// 0.10.0:内置 Read 的分页形状(offset 0-based;limit 行数;默认 2000)。
|
|
475
|
+
offset: typeof flag('offset') === 'string' ? Number(flag('offset')) : undefined,
|
|
476
|
+
limit: typeof flag('limit') === 'string' ? Number(flag('limit')) : undefined,
|
|
266
477
|
// 光写 `--max-chars` 不带值时 flag() 回 true,Number(true)=1 会变成"预算 1 字符"这种怪值
|
|
267
478
|
maxChars: typeof flag('max-chars') === 'string' ? Number(flag('max-chars')) : undefined,
|
|
268
479
|
})
|
|
@@ -277,11 +488,12 @@ if (command === 'edit') {
|
|
|
277
488
|
* `dryRun` 两个来源都认:spec 里的 `dryRun`(与 DSH 壳同名)与既有的 `--dry-run` 旗标。
|
|
278
489
|
*/
|
|
279
490
|
if (has('targets')) {
|
|
280
|
-
const spec = engineTargets(assertEditTargets, '
|
|
491
|
+
const spec = engineTargets(assertEditTargets, 'aura_basetools_edit', MT_EDIT_ELEMENT_KEYS)
|
|
281
492
|
const env = await editManyEnvelope(provider, {
|
|
282
493
|
targets: spec.targets,
|
|
283
|
-
dryRun: has('dry-run') || spec.dryRun,
|
|
494
|
+
dryRun: has('dry-run') || has('plan') || spec.dryRun,
|
|
284
495
|
includeTransaction: has('transaction'),
|
|
496
|
+
...(has('require-observation') ? { requireObservation: true } : {}),
|
|
285
497
|
})
|
|
286
498
|
emit(env, env.ok ? 0 : 2)
|
|
287
499
|
}
|
|
@@ -297,6 +509,8 @@ if (command === 'edit') {
|
|
|
297
509
|
*/
|
|
298
510
|
const flat = flatHunk({
|
|
299
511
|
target: flag('target'),
|
|
512
|
+
// 意图地址(T16):`--intent 'config loader'` 一句话定位一处改动,不要求符号全名或原文。
|
|
513
|
+
intent: flag('intent'),
|
|
300
514
|
lines: flag('lines'),
|
|
301
515
|
start_line: flag('start-line'),
|
|
302
516
|
end_line: flag('end-line'),
|
|
@@ -309,8 +523,13 @@ if (command === 'edit') {
|
|
|
309
523
|
handle: flag('handle'),
|
|
310
524
|
line: flag('line'),
|
|
311
525
|
position: flag('position'),
|
|
526
|
+
// 内容地址(0.10.2):平铺 CLI 也能直接给逐字原文;`--replace-all` 用它改全部出现。
|
|
527
|
+
old: flag('old'),
|
|
528
|
+
context: flag('context'),
|
|
312
529
|
replace: flag('replace'),
|
|
313
530
|
expect: flag('expect'),
|
|
531
|
+
eol: flag('eol'),
|
|
532
|
+
replace_all: has('replace-all') ? true : undefined,
|
|
314
533
|
// 旗标值天然是字符串:`--near 42` ⇒ `'42'`。收敛层只认整数,所以在这里转一次
|
|
315
534
|
near: typeof flag('near') === 'string' && flag('near').trim() !== '' ? Number(flag('near')) : flag('near'),
|
|
316
535
|
})
|
|
@@ -374,7 +593,7 @@ if (command === 'edit') {
|
|
|
374
593
|
{
|
|
375
594
|
ok: false,
|
|
376
595
|
error:
|
|
377
|
-
'edit 需要 --hunks/--hunks-file,或平铺一组地址旗标(--target / --lines / --start-line+--end-line / --line-cue / --anchor / --handle(配 --line 或 --position),外加 --replace),或 --create <整份内容>',
|
|
596
|
+
'edit 需要 --hunks/--hunks-file,或平铺一组地址旗标(--target / --intent / --lines / --start-line+--end-line / --line-cue / --anchor / --handle(配 --line 或 --position),外加 --replace),或 --create <整份内容>',
|
|
378
597
|
code: 'USAGE',
|
|
379
598
|
},
|
|
380
599
|
1,
|
|
@@ -382,13 +601,14 @@ if (command === 'edit') {
|
|
|
382
601
|
}
|
|
383
602
|
const createRaw = flag('create')
|
|
384
603
|
// `@文件` 形式:内容完全不经过命令行引号。创建含引号/换行/front matter 的内容时这是最稳的路。
|
|
385
|
-
const create = typeof createRaw === 'string' && createRaw.startsWith('@') ?
|
|
604
|
+
const create = typeof createRaw === 'string' && createRaw.startsWith('@') ? readArgumentFile(createRaw.slice(1), '--create') : createRaw
|
|
386
605
|
const env = await editEnvelope(provider, {
|
|
387
606
|
file,
|
|
388
607
|
hunks: hunks ?? [],
|
|
389
|
-
dryRun: has('dry-run'),
|
|
608
|
+
dryRun: has('dry-run') || has('plan'),
|
|
390
609
|
create,
|
|
391
610
|
includeTransaction: has('transaction'),
|
|
611
|
+
...(has('require-observation') ? { requireObservation: true } : {}),
|
|
392
612
|
})
|
|
393
613
|
emit(env, env.ok ? 0 : 2)
|
|
394
614
|
}
|
|
@@ -147,6 +147,100 @@ export function createDiskStore(file = observationsPath()) {
|
|
|
147
147
|
for (const [spelling, target] of aliases) if (!alive.has(target)) aliases.delete(spelling)
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
+
/**
|
|
151
|
+
* 落盘状态的新鲜度标记。多个 CLI/MCP 进程会同时读写同一个 `observations.json`;
|
|
152
|
+
* 旧实现“启动时读一次、结束时整份写回”会让后写的进程覆盖先写的观测(并发读两个文件,
|
|
153
|
+
* 最后只剩一条)——严格新鲜度随后对刚读过的文件报 `never-read`。
|
|
154
|
+
*/
|
|
155
|
+
let lastStamp = null
|
|
156
|
+
const fileStamp = () => {
|
|
157
|
+
try {
|
|
158
|
+
const st = fs.statSync(file)
|
|
159
|
+
return `${st.mtimeMs}:${st.size}`
|
|
160
|
+
} catch {
|
|
161
|
+
return 'missing'
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
lastStamp = fileStamp()
|
|
165
|
+
|
|
166
|
+
/** 从盘上重新读一份合法缓存;坏文件/不存在都当空(“没观测过”是合法状态)。 */
|
|
167
|
+
const loadDisk = () => {
|
|
168
|
+
try {
|
|
169
|
+
const raw = JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
170
|
+
if (raw !== null && typeof raw === 'object' && Array.isArray(raw.seen) && typeof raw.latest === 'object') return raw
|
|
171
|
+
} catch {
|
|
172
|
+
/* 落到 null */
|
|
173
|
+
}
|
|
174
|
+
return null
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** 把盘上条目并进内存:seen 按 key 去重(本地优先),latest/aliases 本地优先。 */
|
|
178
|
+
const mergeDisk = (disk) => {
|
|
179
|
+
if (disk === null) return
|
|
180
|
+
const seen = new Map()
|
|
181
|
+
for (const entry of disk.seen) seen.set(entry.key, entry)
|
|
182
|
+
for (const entry of data.seen) seen.set(entry.key, entry)
|
|
183
|
+
data.seen = [...seen.values()]
|
|
184
|
+
data.latest = { ...disk.latest, ...data.latest }
|
|
185
|
+
if (typeof disk.aliases === 'object' && disk.aliases !== null) {
|
|
186
|
+
for (const [spelling, target] of Object.entries(disk.aliases)) {
|
|
187
|
+
if (!aliases.has(spelling)) aliases.set(spelling, target)
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** 查询前刷新:长进程(MCP)也能看见别的进程刚写下的观测。 */
|
|
193
|
+
const refresh = () => {
|
|
194
|
+
const stamp = fileStamp()
|
|
195
|
+
if (stamp === lastStamp) return
|
|
196
|
+
mergeDisk(loadDisk())
|
|
197
|
+
lastStamp = stamp
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* 写锁:同一个缓存文件同时只允许一个进程做 read-merge-write。
|
|
202
|
+
*
|
|
203
|
+
* 锁是**旁路文件**(`observations.json.lock`,`wx` 原子创建),不引入平台分支;
|
|
204
|
+
* 陈旧锁(进程崩溃留下)超过 `LOCK_STALE_MS` 后清掉。拿不到锁时仍做一次尽力而为的
|
|
205
|
+
* 合并写入——缓存损坏/丢一条都不该让编辑失败,但正常情况下不会走到这条。
|
|
206
|
+
*/
|
|
207
|
+
const LOCK_STALE_MS = 5000
|
|
208
|
+
const LOCK_WAIT_MS = 2000
|
|
209
|
+
const sleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms)
|
|
210
|
+
const acquireLock = () => {
|
|
211
|
+
const lock = `${file}.lock`
|
|
212
|
+
const deadline = Date.now() + LOCK_WAIT_MS
|
|
213
|
+
try {
|
|
214
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 })
|
|
215
|
+
} catch {
|
|
216
|
+
return null
|
|
217
|
+
}
|
|
218
|
+
for (;;) {
|
|
219
|
+
try {
|
|
220
|
+
const fd = fs.openSync(lock, 'wx', 0o600)
|
|
221
|
+
try { fs.writeFileSync(fd, String(process.pid)) } finally { fs.closeSync(fd) }
|
|
222
|
+
return lock
|
|
223
|
+
} catch (error) {
|
|
224
|
+
if (error?.code !== 'EEXIST') return null
|
|
225
|
+
try {
|
|
226
|
+
const st = fs.statSync(lock)
|
|
227
|
+
if (Date.now() - st.mtimeMs > LOCK_STALE_MS) {
|
|
228
|
+
fs.unlinkSync(lock)
|
|
229
|
+
continue
|
|
230
|
+
}
|
|
231
|
+
} catch {
|
|
232
|
+
continue
|
|
233
|
+
}
|
|
234
|
+
if (Date.now() >= deadline) return null
|
|
235
|
+
sleep(10)
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
const releaseLock = (lock) => {
|
|
240
|
+
if (lock === null) return
|
|
241
|
+
try { fs.unlinkSync(lock) } catch { /* 已被清理:没有需要撤销的状态 */ }
|
|
242
|
+
}
|
|
243
|
+
|
|
150
244
|
const save = () => {
|
|
151
245
|
try {
|
|
152
246
|
const dir = path.dirname(file)
|
|
@@ -161,6 +255,7 @@ export function createDiskStore(file = observationsPath()) {
|
|
|
161
255
|
try { fs.chmodSync(tmp, 0o600) } catch { /* 同上 */ }
|
|
162
256
|
}
|
|
163
257
|
fs.renameSync(tmp, file)
|
|
258
|
+
lastStamp = fileStamp()
|
|
164
259
|
if (path.sep !== '\\') {
|
|
165
260
|
try { fs.chmodSync(file, 0o600) } catch { /* 同上 */ }
|
|
166
261
|
}
|
|
@@ -172,21 +267,44 @@ export function createDiskStore(file = observationsPath()) {
|
|
|
172
267
|
|
|
173
268
|
return {
|
|
174
269
|
remember(filePath, version, source, byteOrderMark = false) {
|
|
175
|
-
if (typeof source !== 'string'
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
270
|
+
if (typeof source !== 'string') return
|
|
271
|
+
/**
|
|
272
|
+
* 超过 `MAX_SOURCE` 的文件**仍然记令牌**,只是不存全文。
|
|
273
|
+
*
|
|
274
|
+
* 旧行为是整条丢掉,于是严格新鲜度(`requireObservedRevision`)对大文件永远返回
|
|
275
|
+
* `never-read`——而它要求的恢复动作(先读一次)根本不可能成功。令牌足够回答
|
|
276
|
+
* “这一版读过没有”;区域级闸门拿不到全文时如实退回 `basis:'none'`(见 `regionGate`)。
|
|
277
|
+
*/
|
|
278
|
+
const omitted = source.length > MAX_SOURCE
|
|
279
|
+
const lock = acquireLock()
|
|
280
|
+
try {
|
|
281
|
+
// 先合并盘上条目,再算键与写入:否则两个并发进程会各自用旧快照覆盖对方。
|
|
282
|
+
mergeDisk(loadDisk())
|
|
283
|
+
const accounted = accountKeys(filePath, aliases)
|
|
284
|
+
if (accounted.canonical !== null) aliases.set(accounted.canonical, accounted.key)
|
|
285
|
+
const key = `${accounted.key}@${version}`
|
|
286
|
+
data.seen = data.seen.filter((e) => e.key !== key)
|
|
287
|
+
data.seen.push({
|
|
288
|
+
key,
|
|
289
|
+
version,
|
|
290
|
+
source: omitted ? null : source,
|
|
291
|
+
...(omitted ? { source_omitted: true } : {}),
|
|
292
|
+
byteOrderMark: byteOrderMark === true,
|
|
293
|
+
at: Date.now(),
|
|
294
|
+
})
|
|
295
|
+
while (data.seen.length > LIMIT) data.seen.shift()
|
|
296
|
+
data.latest[accounted.key] = key
|
|
297
|
+
// 指针指向已被淘汰的条目时一并清掉:留着它只会让"最近观测"指向不存在的全文
|
|
298
|
+
const alive = new Set(data.seen.map((e) => e.key))
|
|
299
|
+
for (const [p, k] of Object.entries(data.latest)) if (!alive.has(k)) delete data.latest[p]
|
|
300
|
+
dropDanglingAliases()
|
|
301
|
+
save()
|
|
302
|
+
} finally {
|
|
303
|
+
releaseLock(lock)
|
|
304
|
+
}
|
|
188
305
|
},
|
|
189
306
|
latest(filePath) {
|
|
307
|
+
refresh()
|
|
190
308
|
/**
|
|
191
309
|
* 快路:**精确键**命中就直接返回(引擎传进来的通常是它自己登记过的那个键)。
|
|
192
310
|
* 只有查不到时才用文件系统事实再问一次"这个写法是不是盘上那一份" —— 于是常见形态下
|
|
@@ -203,16 +321,27 @@ export function createDiskStore(file = observationsPath()) {
|
|
|
203
321
|
if (stored === undefined) return null
|
|
204
322
|
const entry = data.seen.find((e) => e.key === stored)
|
|
205
323
|
if (entry === undefined) return null
|
|
206
|
-
return {
|
|
324
|
+
return {
|
|
325
|
+
version: entry.version,
|
|
326
|
+
source: entry.source,
|
|
327
|
+
byteOrderMark: entry.byteOrderMark === true,
|
|
328
|
+
source_omitted: entry.source_omitted === true,
|
|
329
|
+
}
|
|
207
330
|
},
|
|
208
331
|
entry(filePath, version) {
|
|
332
|
+
refresh()
|
|
209
333
|
const direct = findEntry(filePath, version)
|
|
210
|
-
if (direct !== null)
|
|
334
|
+
if (direct !== null) {
|
|
335
|
+
return { source: direct.source, byteOrderMark: direct.byteOrderMark === true, source_omitted: direct.source_omitted === true }
|
|
336
|
+
}
|
|
211
337
|
const accounted = accountKeys(filePath, aliases)
|
|
212
338
|
const entry = findEntry(accounted.key, version) ?? (accounted.canonical === null ? null : findEntry(accounted.canonical, version))
|
|
213
|
-
return entry === null
|
|
339
|
+
return entry === null
|
|
340
|
+
? null
|
|
341
|
+
: { source: entry.source, byteOrderMark: entry.byteOrderMark === true, source_omitted: entry.source_omitted === true }
|
|
214
342
|
},
|
|
215
343
|
recall(filePath, version) {
|
|
344
|
+
refresh()
|
|
216
345
|
const direct = findEntry(filePath, version)
|
|
217
346
|
if (direct !== null) return direct.source
|
|
218
347
|
const accounted = accountKeys(filePath, aliases)
|
|
@@ -227,10 +356,14 @@ export function createDiskStore(file = observationsPath()) {
|
|
|
227
356
|
* 模型手上的行号来自更早的一版,且没有任何东西能替它发现这件事。
|
|
228
357
|
*/
|
|
229
358
|
has(filePath, version) {
|
|
359
|
+
refresh()
|
|
230
360
|
if (findEntry(filePath, version) !== null) return true
|
|
231
361
|
const accounted = accountKeys(filePath, aliases)
|
|
232
362
|
return findEntry(accounted.key, version) !== null || (accounted.canonical !== null && findEntry(accounted.canonical, version) !== null)
|
|
233
363
|
},
|
|
234
|
-
count: () =>
|
|
364
|
+
count: () => {
|
|
365
|
+
refresh()
|
|
366
|
+
return data.seen.length
|
|
367
|
+
},
|
|
235
368
|
}
|
|
236
369
|
}
|