dsh-tap 0.20.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.
Files changed (121) hide show
  1. package/AGENTS.md +99 -0
  2. package/CHANGELOG.md +751 -0
  3. package/LICENSE +21 -0
  4. package/README.en.md +59 -0
  5. package/README.md +256 -0
  6. package/cordis.patch.yml +385 -0
  7. package/core/bridge.js +698 -0
  8. package/core/json-store.js +91 -0
  9. package/core/rotation.js +108 -0
  10. package/core/usage-meter.js +176 -0
  11. package/docs/diagnosis-cache-quota.md +181 -0
  12. package/docs/diagnosis-qoder-flash.md +67 -0
  13. package/docs/diagnosis-trae-3003.md +302 -0
  14. package/docs/goals/bridge-port-host-split.md +91 -0
  15. package/docs/goals/desktop-adaptation.md +106 -0
  16. package/docs/goals/qoder-cn-provider-design.md +308 -0
  17. package/docs/goals/settings-card-ux-redesign-plan.md +1680 -0
  18. package/docs/goals/settings-card-ux-redesign.md +162 -0
  19. package/docs/goals/trae-agent-v3.md +41 -0
  20. package/docs/goals/trae-work-cn-repair.md +44 -0
  21. package/docs/goals/v0.8-/351/242/235/345/272/246/345/217/257/350/247/201-/346/250/241/345/236/213/345/212/250/346/200/201/345/214/226-/345/244/232/346/234/215/345/212/241/345/225/206.md +85 -0
  22. package/docs/pitfalls.md +105 -0
  23. package/docs/reverse/trae-cloud-api.md +218 -0
  24. package/docs/reverse/trae-model-catalog.md +129 -0
  25. package/docs/reverse/traework-cn.md +520 -0
  26. package/docs/rules/STATE.md +198 -0
  27. package/docs/rules/content-moderation.md +54 -0
  28. package/docs/rules/dev-role-boundary.md +94 -0
  29. package/docs/rules/extra-providers.md +42 -0
  30. package/docs/rules/gateway-facts.md +91 -0
  31. package/docs/rules/oauth-handshake.md +76 -0
  32. package/docs/rules/prompt-cache.md +93 -0
  33. package/docs/rules/quota-signals.md +125 -0
  34. package/docs/rules/routing.md +84 -0
  35. package/docs/rules/templates/oauth-reverse-checklist.md +42 -0
  36. package/docs/rules/trae-surface.md +242 -0
  37. package/docs/rules/ua-validation.md +81 -0
  38. package/host-config.js +282 -0
  39. package/index.js +2306 -0
  40. package/lib/client.js +2893 -0
  41. package/local-scan.js +104 -0
  42. package/package.json +82 -0
  43. package/providers/ark/index.js +11 -0
  44. package/providers/bailian/index.js +10 -0
  45. package/providers/bigmodel/index.js +11 -0
  46. package/providers/codebuddy/agenttool.js +122 -0
  47. package/providers/codebuddy/catalog.js +227 -0
  48. package/providers/codebuddy/errors.js +44 -0
  49. package/providers/codebuddy/headers.js +36 -0
  50. package/providers/codebuddy/images.js +125 -0
  51. package/providers/codebuddy/index.js +123 -0
  52. package/providers/codebuddy/oauth.js +279 -0
  53. package/providers/deepseek/index.js +11 -0
  54. package/providers/moonshot/index.js +11 -0
  55. package/providers/openai-compat.js +177 -0
  56. package/providers/openrouter/index.js +27 -0
  57. package/providers/qoder/catalog.js +145 -0
  58. package/providers/qoder/cosy.js +419 -0
  59. package/providers/qoder/gateway.js +563 -0
  60. package/providers/qoder/index.js +116 -0
  61. package/providers/qoder/oauth.js +364 -0
  62. package/providers/qoder/qoder_auth.wasm +0 -0
  63. package/providers/qoder/quota.js +56 -0
  64. package/providers/qwen/index.js +15 -0
  65. package/providers/tool-pairing.js +129 -0
  66. package/providers/trae/catalog.js +103 -0
  67. package/providers/trae/errors.js +85 -0
  68. package/providers/trae/gateway.js +853 -0
  69. package/providers/trae/index.js +126 -0
  70. package/providers/trae/oauth.js +443 -0
  71. package/providers/trae/quota.js +75 -0
  72. package/providers/trae/remote.js +365 -0
  73. package/scripts/capture-cache.mjs +65 -0
  74. package/scripts/capture-traffic.mjs +83 -0
  75. package/scripts/hermes-probe-dev-role.mjs +263 -0
  76. package/scripts/measure-latency.mjs +253 -0
  77. package/scripts/probe-ark-thinking.mjs +298 -0
  78. package/scripts/probe-cache-decline.mjs +292 -0
  79. package/scripts/probe-cache-ttl.mjs +221 -0
  80. package/scripts/probe-cache.mjs +156 -0
  81. package/scripts/probe-codebuddy-efforts.mjs +355 -0
  82. package/scripts/probe-codebuddy-tier-wiring.mjs +244 -0
  83. package/scripts/probe-effort-gaps.mjs +133 -0
  84. package/scripts/probe-media.mjs +115 -0
  85. package/scripts/probe-moderation.mjs +159 -0
  86. package/scripts/probe-oauth.mjs +617 -0
  87. package/scripts/probe-qoder-attribution-arm8.mjs +92 -0
  88. package/scripts/probe-qoder-attribution-arm9.mjs +102 -0
  89. package/scripts/probe-qoder-attribution.mjs +266 -0
  90. package/scripts/probe-qoder-flash-confirm.mjs +94 -0
  91. package/scripts/probe-qoder-live.mjs +344 -0
  92. package/scripts/probe-qoder-matrix.mjs +274 -0
  93. package/scripts/probe-qoder-null-content.mjs +153 -0
  94. package/scripts/probe-qoder-pairing.mjs +308 -0
  95. package/scripts/probe-qoder-quota.mjs +162 -0
  96. package/scripts/probe-qoder-thinking-config.mjs +52 -0
  97. package/scripts/probe-qoder-thinking-efforts.mjs +269 -0
  98. package/scripts/probe-quota.mjs +136 -0
  99. package/scripts/probe-quota2.mjs +144 -0
  100. package/scripts/probe-routing.mjs +226 -0
  101. package/scripts/probe-trae-3003-diagnosis.mjs +139 -0
  102. package/scripts/probe-trae-agent-v3.mjs +399 -0
  103. package/scripts/probe-trae-efforts.mjs +149 -0
  104. package/scripts/probe-trae-live.mjs +147 -0
  105. package/scripts/probe-trae-max-effort.mjs +179 -0
  106. package/scripts/probe-trae-model-routing.mjs +381 -0
  107. package/scripts/probe-trae-thinking-scene.mjs +238 -0
  108. package/scripts/probe-trae-transport-outage.mjs +176 -0
  109. package/scripts/probe-ua.mjs +508 -0
  110. package/scripts/trae-model-catalog.mjs +632 -0
  111. package/scripts/verify-agents-md.mjs +45 -0
  112. package/scripts/verify-bridge.mjs +1041 -0
  113. package/scripts/verify-core-generic.mjs +302 -0
  114. package/scripts/verify-desktop-acceptance.mjs +184 -0
  115. package/scripts/verify-host-config.mjs +265 -0
  116. package/scripts/verify-models.mjs +390 -0
  117. package/scripts/verify-providers.mjs +255 -0
  118. package/scripts/verify-qoder-provider.mjs +1020 -0
  119. package/scripts/verify-rotation.mjs +308 -0
  120. package/scripts/verify-trae-model-catalog.mjs +284 -0
  121. package/scripts/verify-trae-provider.mjs +1451 -0
@@ -0,0 +1,632 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * TraeWork CN / TRAE SOLO CN model catalog extractor (read-only, offline).
4
+ *
5
+ * Reads the VS Code global-state SQLite DB used by the Windows IDE
6
+ * %APPDATA%\TRAE SOLO CN\User\globalStorage\state.vscdb (ItemTable)
7
+ * and extracts the `AI.agent.model.model_list_map` cache into a stable JSON
8
+ * catalog + a Markdown table. No network, no credentials, no OAuth.
9
+ *
10
+ * Safety contract (docs/reverse/trae-model-catalog.md):
11
+ * - never reads/prints/persists token-like fields (ak/sk/token/…, exact-name
12
+ * match after lowercasing + stripping non-alphanumerics, so promptMaxTokens
13
+ * etc. are untouched);
14
+ * - outputs carry SHA-256/12 fingerprints instead of raw user_id / itemKey /
15
+ * db path;
16
+ * - the DB is copied to a temp dir before opening (Windows side may hold it).
17
+ *
18
+ * Usage:
19
+ * node scripts/trae-model-catalog.mjs [--db <state.vscdb>] [--out <json>] [--md <md>] [--pretty]
20
+ * [--list-keys] [--key <itemKey>] [--all]
21
+ * Requires Node with node:sqlite (22.5+; no flag needed on 23.4+/24).
22
+ */
23
+
24
+ import { createHash } from 'node:crypto'
25
+ import { mkdtempSync, mkdirSync, copyFileSync, existsSync, statSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
26
+ import { tmpdir, homedir } from 'node:os'
27
+ import { join, dirname, basename } from 'node:path'
28
+ import { pathToFileURL } from 'node:url'
29
+ import { DatabaseSync } from 'node:sqlite'
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // constants
33
+
34
+ const MODEL_LIST_KEY_SUFFIX = 'AI.agent.model.model_list_map'
35
+ // Windows product dirs known to host the same layout; discovery scans both.
36
+ const PRODUCT_DIRS = ['TRAE SOLO CN', 'TraeWork CN']
37
+ // WSL 默认扫描根。Windows 原生进程里 /mnt/c 不存在——win32 无参发现走 homedir
38
+ // 直查(见 discoverStateDbs)。
39
+ const DEFAULT_USERS_ROOT = '/mnt/c/Users'
40
+
41
+ const DEFAULT_OUT_JSON = 'docs/probes/trae-model-catalog.json'
42
+ const DEFAULT_OUT_MD = 'docs/probes/trae-model-catalog.md'
43
+
44
+ // Exact names (case/symbol-insensitive) that must never survive into output.
45
+ // Exact match on purpose: substring matching would eat promptMaxTokens etc.
46
+ const FORBIDDEN_KEY_NAMES = new Set([
47
+ 'ak', 'sk', 'token', 'secret', 'password', 'authorization', 'credential',
48
+ 'apikey', 'refreshtoken', 'sessiontoken', 'encryptedmodelparams',
49
+ ])
50
+
51
+ // Source fields the normalizer consumes (mapped or technical). Anything else
52
+ // is dropped and reported by name only.
53
+ const MAPPED_SOURCE_FIELDS = new Set([
54
+ 'config_name', 'name', 'display_name', 'provider', 'model_type', 'multimodal',
55
+ 'prompt_max_tokens', 'context_window_size', 'max_tokens', 'max_turn', 'max_turns',
56
+ 'is_default', 'is_preset', 'selectable', 'status', 'fee_model_level',
57
+ 'reasoning_effort_options', 'reasoning_effort_config',
58
+ ])
59
+ const TECHNICAL_SOURCE_FIELDS = new Set(['temperature', 'top_p', 'top_k', 'thinking_enable', 'tags', 'features'])
60
+
61
+ // Per-category limit fields tracked in categoryProfiles when they differ from
62
+ // the merged (first-occurrence) values.
63
+ const LIMIT_FIELDS = ['promptMaxTokens', 'contextWindowDefault', 'contextWindowMax', 'maxOutputTokens', 'maxTurns']
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // small helpers
67
+
68
+ function sha256hex(s) {
69
+ return createHash('sha256').update(s, 'utf8').digest('hex')
70
+ }
71
+
72
+ function fingerprint(s) {
73
+ return 'sha256:' + sha256hex(String(s)).slice(0, 12)
74
+ }
75
+
76
+ function normalizeKeyName(key) {
77
+ return String(key).toLowerCase().replace(/[^a-z0-9]/g, '')
78
+ }
79
+
80
+ function isForbiddenKeyName(key) {
81
+ return FORBIDDEN_KEY_NAMES.has(normalizeKeyName(key))
82
+ }
83
+
84
+ function numOrNull(v) {
85
+ return (typeof v === 'number' && Number.isFinite(v)) ? v : null
86
+ }
87
+
88
+ /** context_window_size.max may be a number, an array of numbers, or junk. */
89
+ function normalizeContextMax(v) {
90
+ if (typeof v === 'number' && Number.isFinite(v)) return v
91
+ if (Array.isArray(v)) {
92
+ const nums = v.filter((n) => typeof n === 'number' && Number.isFinite(n))
93
+ return nums.length ? nums : null
94
+ }
95
+ return null
96
+ }
97
+
98
+ /** max_turns may be a number or {default,max}; falls back to max_turn. */
99
+ function normalizeMaxTurns(maxTurns, maxTurn) {
100
+ if (typeof maxTurns === 'number' && Number.isFinite(maxTurns)) return maxTurns
101
+ if (maxTurns && typeof maxTurns === 'object') {
102
+ const m = numOrNull(maxTurns.max)
103
+ if (m !== null) return m
104
+ const d = numOrNull(maxTurns.default)
105
+ if (d !== null) return d
106
+ }
107
+ return numOrNull(maxTurn)
108
+ }
109
+
110
+ /** reasoning_effort_config(2026-10-04 实测形态 {support_thinking, options[],
111
+ * default_level})→ {supportThinking, options, defaultLevel};非对象 → null,
112
+ * options 只收非空字符串。三个键都不在场的空对象同样归 null——否则"首个
113
+ * 非 null wins"的跨分类合并会让空 config 遮蔽后面的真配置。
114
+ * 档位拼写逐模型照抄声明(不臆造,踩坑 #42)。 */
115
+ function normalizeEffortConfig(v) {
116
+ if (!v || typeof v !== 'object' || Array.isArray(v)) return null
117
+ if (v.support_thinking === undefined && !Array.isArray(v.options) && v.default_level == null) return null
118
+ return {
119
+ supportThinking: v.support_thinking === true,
120
+ options: Array.isArray(v.options) ? v.options.filter((o) => typeof o === 'string' && o) : [],
121
+ defaultLevel: typeof v.default_level === 'string' && v.default_level ? v.default_level : null,
122
+ }
123
+ }
124
+
125
+ /**
126
+ * openaiCompatibleId slug. The sample contract maps "glm-5.3" -> "glm-5-3"
127
+ * (dots are replaced too), so the allowed set here is [A-Za-z0-9_-].
128
+ */
129
+ export function toOpenaiCompatibleId(id) {
130
+ return String(id).replace(/[^A-Za-z0-9_-]/g, '-')
131
+ }
132
+
133
+ function stableStringify(v) {
134
+ if (Array.isArray(v)) return '[' + v.map(stableStringify).join(',') + ']'
135
+ if (v && typeof v === 'object') {
136
+ return '{' + Object.keys(v).sort().map((k) => JSON.stringify(k) + ':' + stableStringify(v[k])).join(',') + '}'
137
+ }
138
+ return JSON.stringify(v) ?? 'null'
139
+ }
140
+
141
+ function shallowEqualByString(a, b) {
142
+ return stableStringify(a) === stableStringify(b)
143
+ }
144
+
145
+ function firstString(...vals) {
146
+ for (const v of vals) {
147
+ if (typeof v === 'string' && v.trim() !== '') return v
148
+ }
149
+ return null
150
+ }
151
+
152
+ // ---------------------------------------------------------------------------
153
+ // security: recursive scrub + violation scan
154
+
155
+ /** Returns a copy of `value` with every forbidden key removed at any depth. */
156
+ export function scrubSensitive(value) {
157
+ if (Array.isArray(value)) return value.map(scrubSensitive)
158
+ if (value && typeof value === 'object') {
159
+ const out = {}
160
+ for (const [k, v] of Object.entries(value)) {
161
+ if (isForbiddenKeyName(k)) continue
162
+ out[k] = scrubSensitive(v)
163
+ }
164
+ return out
165
+ }
166
+ return value
167
+ }
168
+
169
+ /** Returns the paths of every forbidden key found at any depth (for asserts). */
170
+ export function findForbiddenKeys(value, path = '$') {
171
+ const hits = []
172
+ if (Array.isArray(value)) {
173
+ value.forEach((v, i) => hits.push(...findForbiddenKeys(v, `${path}[${i}]`)))
174
+ } else if (value && typeof value === 'object') {
175
+ for (const [k, v] of Object.entries(value)) {
176
+ if (isForbiddenKeyName(k)) hits.push(`${path}.${k}`)
177
+ else hits.push(...findForbiddenKeys(v, `${path}.${k}`))
178
+ }
179
+ }
180
+ return hits
181
+ }
182
+
183
+ // ---------------------------------------------------------------------------
184
+ // db discovery / copy / read
185
+
186
+ /** Scan &lt;userDir&gt;/AppData/Roaming/&lt;product&gt;/User/globalStorage for
187
+ * state.vscdb files. Returns [{dbPath, mtimeMs}] sorted newest-first; [] when
188
+ * nothing matches.
189
+ *
190
+ * 无参调用的默认扫描按平台分路(桌面 dsh 是 Windows 原生进程,无 /mnt/c):
191
+ * - win32:当前用户主目录即"用户目录",直查 home/AppData/Roaming/<产品>/…;
192
+ * - 其他(WSL):扫 usersRoot(默认 /mnt/c/Users)下逐用户目录。
193
+ * 显式传 usersRoot 时两平台同行为(逐用户扫描)。 */
194
+ export function discoverStateDbs(usersRoot, { platform = process.platform, home = homedir() } = {}) {
195
+ const found = []
196
+ const pushIfFile = (dbPath) => {
197
+ try {
198
+ const st = statSync(dbPath)
199
+ if (st.isFile()) found.push({ dbPath, mtimeMs: st.mtimeMs })
200
+ } catch {
201
+ // absent — skip
202
+ }
203
+ }
204
+ const scanUserDir = (userDir) => {
205
+ for (const product of PRODUCT_DIRS) {
206
+ pushIfFile(join(userDir, 'AppData', 'Roaming', product, 'User', 'globalStorage', 'state.vscdb'))
207
+ }
208
+ }
209
+ if (usersRoot === undefined && platform === 'win32') {
210
+ if (home) scanUserDir(home)
211
+ } else {
212
+ const root = usersRoot ?? DEFAULT_USERS_ROOT
213
+ let users
214
+ try {
215
+ users = readdirSync(root, { withFileTypes: true })
216
+ } catch {
217
+ return [] // no /mnt/c (not WSL) or unreadable root — not an error
218
+ }
219
+ for (const entry of users) {
220
+ if (!entry.isDirectory()) continue
221
+ scanUserDir(join(root, entry.name))
222
+ }
223
+ }
224
+ found.sort((a, b) => b.mtimeMs - a.mtimeMs)
225
+ return found
226
+ }
227
+
228
+ /** Copy the DB (+ -wal/-shm when present) into tmpDir and return the copy path.
229
+ * We always read from the copy: the Windows-side app may hold the original. */
230
+ export function copySqliteForRead(dbPath, tmpDir) {
231
+ mkdirSync(tmpDir, { recursive: true })
232
+ const copyPath = join(tmpDir, basename(dbPath))
233
+ copyFileSync(dbPath, copyPath)
234
+ for (const suffix of ['-wal', '-shm']) {
235
+ const side = dbPath + suffix
236
+ if (existsSync(side)) copyFileSync(side, copyPath + suffix)
237
+ }
238
+ return copyPath
239
+ }
240
+
241
+ function openSqliteReadOnly(dbPath) {
242
+ try {
243
+ return new DatabaseSync(dbPath, { readOnly: true })
244
+ } catch {
245
+ // WAL recovery may need write access; this is our private temp copy, so a
246
+ // read-write open never touches the original DB.
247
+ return new DatabaseSync(dbPath)
248
+ }
249
+ }
250
+
251
+ /** Parse BLOB/string value into {text, bytes}. */
252
+ function decodeItemValue(value) {
253
+ if (typeof value === 'string') return { text: value, bytes: Buffer.byteLength(value, 'utf8') }
254
+ const buf = Buffer.from(value ?? '')
255
+ return { text: buf.toString('utf8'), bytes: buf.length }
256
+ }
257
+
258
+ /** All model_list_map candidates from a state.vscdb (open the temp copy). */
259
+ export function readModelListCandidates(dbPath) {
260
+ const db = openSqliteReadOnly(dbPath)
261
+ try {
262
+ const rows = db.prepare(
263
+ 'SELECT key, value FROM ItemTable WHERE key LIKE ? ORDER BY key'
264
+ ).all(`%${MODEL_LIST_KEY_SUFFIX}`)
265
+ return rows.map(({ key, value }) => {
266
+ const { text, bytes } = decodeItemValue(value)
267
+ const m = /^(?:([^:_]+)[:_])?AI\.agent\.model\.model_list_map$/.exec(String(key))
268
+ const candidate = {
269
+ dbPath,
270
+ itemKey: String(key),
271
+ userId: m ? (m[1] ?? '') : '',
272
+ valueBytes: bytes,
273
+ valid: false,
274
+ json: null,
275
+ parseError: null,
276
+ entryCount: 0,
277
+ categories: [],
278
+ }
279
+ try {
280
+ const json = JSON.parse(text)
281
+ candidate.valid = true
282
+ if (json && typeof json === 'object' && !Array.isArray(json)) {
283
+ candidate.json = json
284
+ candidate.categories = Object.keys(json)
285
+ candidate.entryCount = candidate.categories.reduce(
286
+ (sum, cat) => sum + (Array.isArray(json[cat]) ? json[cat].length : 0), 0)
287
+ } else {
288
+ candidate.parseError = 'value is valid JSON but not an object'
289
+ }
290
+ } catch (err) {
291
+ candidate.parseError = String(err?.message ?? err)
292
+ }
293
+ return candidate
294
+ })
295
+ } finally {
296
+ db.close()
297
+ }
298
+ }
299
+
300
+ /** Default candidate: most entries, then longest value, then smallest key. */
301
+ export function selectDefaultCandidate(candidates) {
302
+ const usable = candidates.filter((c) => c.valid && c.json)
303
+ if (!usable.length) return null
304
+ const sorted = [...usable].sort((a, b) =>
305
+ (b.entryCount - a.entryCount) ||
306
+ (b.valueBytes - a.valueBytes) ||
307
+ (a.itemKey < b.itemKey ? -1 : a.itemKey > b.itemKey ? 1 : 0))
308
+ return sorted[0]
309
+ }
310
+
311
+ // ---------------------------------------------------------------------------
312
+ // normalization
313
+
314
+ /** Normalize one parsed candidate into the stable catalog envelope. */
315
+ export function normalizeCatalog(candidate, { generatedAt = new Date().toISOString() } = {}) {
316
+ const warnings = new Set()
317
+ const functions = {}
318
+ const models = new Map()
319
+
320
+ const source = {
321
+ kind: 'trae-solo-cn-state-vscdb',
322
+ dbPathFingerprint: fingerprint(candidate.dbPath ?? ''),
323
+ itemKeyFingerprint: fingerprint(candidate.itemKey ?? ''),
324
+ userIdFingerprint: fingerprint(candidate.userId ?? ''),
325
+ generatedAt,
326
+ }
327
+
328
+ if (!candidate.valid || !candidate.json) {
329
+ return {
330
+ schemaVersion: 1,
331
+ source,
332
+ functions: {},
333
+ models: [],
334
+ warnings: [`候选 ${source.itemKeyFingerprint} 无法解析:${candidate.parseError ?? 'unknown'}`],
335
+ }
336
+ }
337
+
338
+ const json = candidate.json
339
+ for (const category of Object.keys(json)) {
340
+ const list = json[category]
341
+ if (!Array.isArray(list)) {
342
+ warnings.add(`分类 ${category} 的值不是数组,已跳过`)
343
+ functions[category] = []
344
+ continue
345
+ }
346
+ functions[category] = []
347
+ for (const item of list) {
348
+ if (!item || typeof item !== 'object' || Array.isArray(item)) {
349
+ warnings.add(`跳过分类 ${category} 中非对象条目`)
350
+ continue
351
+ }
352
+ for (const field of Object.keys(item)) {
353
+ if (MAPPED_SOURCE_FIELDS.has(field) || TECHNICAL_SOURCE_FIELDS.has(field)) continue
354
+ if (isForbiddenKeyName(field)) warnings.add(`已移除敏感字段:${field}`)
355
+ else warnings.add(`已忽略非白名单字段:${field}`)
356
+ }
357
+ const id = firstString(item.config_name, item.name, item.display_name)
358
+ if (!id) {
359
+ warnings.add(`跳过分类 ${category} 中无法确定 id 的模型条目`)
360
+ continue
361
+ }
362
+
363
+ const profile = {
364
+ provider: item.provider || null,
365
+ modelType: item.model_type ?? null,
366
+ multimodal: item.multimodal === true,
367
+ input: item.multimodal === true ? ['text', 'image'] : ['text'],
368
+ promptMaxTokens: numOrNull(item.prompt_max_tokens),
369
+ contextWindowDefault: numOrNull(item.context_window_size?.default),
370
+ contextWindowMax: normalizeContextMax(item.context_window_size?.max),
371
+ maxOutputTokens: numOrNull(item.max_tokens),
372
+ maxTurns: normalizeMaxTurns(item.max_turns, item.max_turn),
373
+ isDefault: item.is_default === true,
374
+ isPreset: item.is_preset === true,
375
+ selectable: item.selectable === undefined ? null : item.selectable === true,
376
+ status: item.status === undefined ? null : item.status === true,
377
+ feeModelLevel: numOrNull(item.fee_model_level),
378
+ reasoningEffortOptions: item.reasoning_effort_options ?? null,
379
+ reasoningEffortConfig: normalizeEffortConfig(item.reasoning_effort_config),
380
+ }
381
+ const technical = {}
382
+ for (const field of TECHNICAL_SOURCE_FIELDS) {
383
+ const v = item[field]
384
+ if (v === null || v === undefined) continue
385
+ if (field === 'features' && !(v && typeof v === 'object' && !Array.isArray(v))) continue
386
+ if (field === 'tags' && !Array.isArray(v) && typeof v !== 'string') continue
387
+ technical[field] = v
388
+ }
389
+
390
+ const existing = models.get(id)
391
+ if (!existing) {
392
+ models.set(id, {
393
+ id,
394
+ openaiCompatibleId: toOpenaiCompatibleId(id),
395
+ configName: item.config_name || null,
396
+ displayName: firstString(item.display_name, item.name) ?? id,
397
+ ...profile,
398
+ categories: [category],
399
+ categoryProfiles: { [category]: {} },
400
+ technical,
401
+ })
402
+ functions[category].push({ id, displayName: firstString(item.display_name, item.name) ?? id })
403
+ continue
404
+ }
405
+
406
+ // Merge rule: first occurrence wins for limits/labels; differences are
407
+ // preserved per-category in categoryProfiles; isDefault ORs; provider /
408
+ // reasoningEffortOptions / reasoningEffortConfig take the first non-null
409
+ // across categories.
410
+ if (!existing.categories.includes(category)) existing.categories.push(category)
411
+ const diff = {}
412
+ for (const field of LIMIT_FIELDS) {
413
+ if (!shallowEqualByString(existing[field], profile[field])) diff[field] = profile[field]
414
+ }
415
+ existing.categoryProfiles[category] = diff
416
+ existing.isDefault = existing.isDefault || profile.isDefault
417
+ if (existing.provider == null && profile.provider != null) existing.provider = profile.provider
418
+ if (existing.reasoningEffortOptions == null && profile.reasoningEffortOptions != null) {
419
+ existing.reasoningEffortOptions = profile.reasoningEffortOptions
420
+ }
421
+ if (existing.reasoningEffortConfig == null && profile.reasoningEffortConfig != null) {
422
+ existing.reasoningEffortConfig = profile.reasoningEffortConfig
423
+ }
424
+ functions[category].push({ id, displayName: firstString(item.display_name, item.name) ?? id })
425
+ }
426
+ }
427
+
428
+ const modelList = [...models.values()]
429
+ .sort((a, b) => (a.displayName.localeCompare(b.displayName)) || a.id.localeCompare(b.id))
430
+
431
+ const catalog = {
432
+ schemaVersion: 1,
433
+ source,
434
+ functions,
435
+ models: modelList,
436
+ warnings: [...warnings].sort((a, b) => a.localeCompare(b, 'zh-Hans-CN')),
437
+ }
438
+ // Defense in depth: the whitelist above should never let one through, but a
439
+ // sensitive key could hide inside a copied `features` blob.
440
+ return assertNoSensitiveKeys(scrubSensitive(catalog))
441
+ }
442
+
443
+ function assertNoSensitiveKeys(catalog) {
444
+ const hits = findForbiddenKeys(catalog)
445
+ if (hits.length) {
446
+ throw new Error(`敏感字段未被清除:${hits.join(', ')}(这是 bug,请回报)`)
447
+ }
448
+ return catalog
449
+ }
450
+
451
+ // ---------------------------------------------------------------------------
452
+ // markdown rendering
453
+
454
+ function mdCell(v) {
455
+ if (v === null || v === undefined) return '-'
456
+ if (Array.isArray(v)) return v.join(',').replace(/\|/g, '\\|')
457
+ return String(v).replace(/\|/g, '\\|').replace(/\r?\n/g, ' ')
458
+ }
459
+
460
+ const MD_COLUMNS = ['id', 'openaiCompatibleId', 'displayName', 'provider', 'multimodal',
461
+ 'contextWindowDefault', 'contextWindowMax', 'maxOutputTokens', 'maxTurns', 'categories']
462
+
463
+ /** Render the catalog (or {candidates:[...]}) as a Markdown document. */
464
+ export function renderMarkdown(catalog) {
465
+ const catalogs = catalog.candidates ? catalog.candidates : [catalog]
466
+ const lines = []
467
+ if (catalog.candidates) {
468
+ lines.push('# TraeWork CN 模型目录(全部候选 key)', '')
469
+ } else {
470
+ lines.push('# TraeWork CN 模型目录(本地缓存提取)', '')
471
+ }
472
+ for (const c of catalogs) {
473
+ if (catalog.candidates) {
474
+ lines.push(`## 候选 ${c.source.itemKeyFingerprint}`, '')
475
+ }
476
+ lines.push(
477
+ `- 生成时间:${c.source.generatedAt}`,
478
+ `- 来源:${c.source.kind}(db ${c.source.dbPathFingerprint} / itemKey ${c.source.itemKeyFingerprint} / user ${c.source.userIdFingerprint})`,
479
+ `- 去重后模型数:${c.models.length};分类数:${Object.keys(c.functions).length}`,
480
+ ''
481
+ )
482
+ lines.push('| ' + MD_COLUMNS.join(' | ') + ' |')
483
+ lines.push('|' + MD_COLUMNS.map(() => '---').join('|') + '|')
484
+ for (const m of c.models) {
485
+ lines.push('| ' + MD_COLUMNS.map((col) => {
486
+ if (col === 'multimodal') return m.multimodal ? '✔' : '✘'
487
+ if (col === 'categories') return mdCell(m.categories)
488
+ return mdCell(m[col])
489
+ }).join(' | ') + ' |')
490
+ }
491
+ lines.push('')
492
+ if (c.warnings?.length) {
493
+ lines.push('## 警告', '')
494
+ for (const w of c.warnings) lines.push(`- ${w}`)
495
+ lines.push('')
496
+ }
497
+ }
498
+ return lines.join('\n')
499
+ }
500
+
501
+ // ---------------------------------------------------------------------------
502
+ // CLI
503
+
504
+ function usage() {
505
+ return [
506
+ '用法:node scripts/trae-model-catalog.mjs [选项]',
507
+ ' --db <path> 指定 state.vscdb(默认自动扫描,取 mtime 最新;win32 查 %APPDATA%,WSL 扫 /mnt/c/Users)',
508
+ ' --out <path> JSON 输出路径(默认 docs/probes/trae-model-catalog.json)',
509
+ ' --md <path> Markdown 输出路径(默认 docs/probes/trae-model-catalog.md)',
510
+ ' --pretty 格式化 JSON 输出',
511
+ ' --list-keys 仅在 stdout 列出候选 key、长度、指纹,不写文件',
512
+ ' --key <itemKey> 只解析指定候选 key',
513
+ ' --all 输出所有候选 key 的归一化结果',
514
+ ].join('\n')
515
+ }
516
+
517
+ function parseArgs(argv) {
518
+ const args = { flags: new Set(), values: {} }
519
+ for (let i = 0; i < argv.length; i++) {
520
+ const tok = argv[i]
521
+ const eq = tok.indexOf('=')
522
+ let name = tok, inlineValue = null
523
+ if (tok.startsWith('--') && eq > 2) { name = tok.slice(0, eq); inlineValue = tok.slice(eq + 1) }
524
+ if (name === '--db' || name === '--out' || name === '--md' || name === '--key') {
525
+ const v = inlineValue ?? argv[++i]
526
+ if (v === undefined) { console.error(`缺少 ${name} 的值`); process.exit(1) }
527
+ args.values[name] = v
528
+ } else if (name === '--pretty' || name === '--list-keys' || name === '--all' || name === '--help' || name === '-h') {
529
+ args.flags.add(name)
530
+ } else {
531
+ console.error(`未知参数:${tok}\n${usage()}`); process.exit(1)
532
+ }
533
+ }
534
+ return args
535
+ }
536
+
537
+ async function main() {
538
+ const args = parseArgs(process.argv.slice(2))
539
+ if (args.flags.has('--help') || args.flags.has('-h')) { console.log(usage()); return }
540
+ if (args.flags.has('--list-keys') && args.flags.has('--all')) {
541
+ console.error('--list-keys 与 --all 互斥'); process.exit(1)
542
+ }
543
+ if (args.values['--key'] && args.flags.has('--all')) {
544
+ console.error('--key 与 --all 互斥'); process.exit(1)
545
+ }
546
+
547
+ let dbPath = args.values['--db']
548
+ if (dbPath) {
549
+ if (!existsSync(dbPath)) { console.error(`数据库不存在:${dbPath}`); process.exit(1) }
550
+ } else {
551
+ const found = discoverStateDbs()
552
+ if (!found.length) {
553
+ const where = process.platform === 'win32'
554
+ ? `${homedir()}\\AppData\\Roaming\\{TRAE SOLO CN,TraeWork CN}\\User\\globalStorage\\`
555
+ : '/mnt/c/Users/*/AppData/Roaming/{TRAE SOLO CN,TraeWork CN}/User/globalStorage/'
556
+ console.error(`未发现 state.vscdb(可用 --db 指定路径)。扫描位置:${where}`)
557
+ process.exit(1)
558
+ }
559
+ dbPath = found[0].dbPath
560
+ if (found.length > 1) {
561
+ console.error(`发现 ${found.length} 个数据库,取 mtime 最新:${dbPath}`)
562
+ }
563
+ }
564
+
565
+ const tmpDir = mkdtempSync(join(tmpdir(), 'trae-catalog-'))
566
+ let candidates
567
+ try {
568
+ const copyPath = copySqliteForRead(dbPath, tmpDir)
569
+ candidates = readModelListCandidates(copyPath)
570
+ // 指纹一律指向原始路径(而不是临时副本)
571
+ for (const c of candidates) c.dbPath = dbPath
572
+ } finally {
573
+ rmSync(tmpDir, { recursive: true, force: true })
574
+ }
575
+
576
+ if (!candidates.length) {
577
+ console.error('数据库中没有 AI.agent.model.model_list_map 候选 key')
578
+ process.exit(1)
579
+ }
580
+
581
+ if (args.flags.has('--list-keys')) {
582
+ console.log(`候选 key 共 ${candidates.length} 个(数据库:${dbPath})`)
583
+ for (const [i, c] of candidates.entries()) {
584
+ console.log(`${i + 1}) key=${c.itemKey}`)
585
+ console.log(` bytes=${c.valueBytes} valid=${c.valid} entries=${c.entryCount} categories=${c.categories.length}`)
586
+ console.log(` itemKeyFp=${fingerprint(c.itemKey)} userIdFp=${fingerprint(c.userId)}${c.parseError ? ` parseError=${c.parseError}` : ''}`)
587
+ }
588
+ return
589
+ }
590
+
591
+ const outPath = args.values['--out'] ?? DEFAULT_OUT_JSON
592
+ const mdPath = args.values['--md'] ?? DEFAULT_OUT_MD
593
+ const defaultCandidate = selectDefaultCandidate(candidates)
594
+ let envelope, summaryKeyFp, summaryModelCount
595
+
596
+ if (args.values['--key']) {
597
+ const chosen = candidates.find((c) => c.itemKey === args.values['--key'])
598
+ if (!chosen) {
599
+ console.error(`未找到候选 key:${args.values['--key']}(可用 --list-keys 查看候选)`)
600
+ process.exit(1)
601
+ }
602
+ envelope = normalizeCatalog(chosen)
603
+ summaryKeyFp = fingerprint(chosen.itemKey)
604
+ summaryModelCount = envelope.models.length
605
+ } else if (args.flags.has('--all')) {
606
+ const catalogs = candidates.map((c) => normalizeCatalog(c))
607
+ envelope = { schemaVersion: 1, kind: 'trae-model-catalog-multi', candidates: catalogs }
608
+ const defIdx = defaultCandidate ? candidates.indexOf(defaultCandidate) : -1
609
+ summaryKeyFp = defIdx >= 0 ? catalogs[defIdx].source.itemKeyFingerprint : '-'
610
+ summaryModelCount = defIdx >= 0 ? catalogs[defIdx].models.length : 0
611
+ } else {
612
+ if (!defaultCandidate) { console.error('所有候选 key 都无法解析为有效 JSON'); process.exit(1) }
613
+ envelope = normalizeCatalog(defaultCandidate)
614
+ summaryKeyFp = envelope.source.itemKeyFingerprint
615
+ summaryModelCount = envelope.models.length
616
+ }
617
+
618
+ const json = JSON.stringify(envelope, null, args.flags.has('--pretty') ? 2 : undefined)
619
+ for (const [path, content] of [[outPath, json + '\n'], [mdPath, renderMarkdown(envelope) + '\n']]) {
620
+ mkdirSync(dirname(path), { recursive: true })
621
+ writeFileSync(path, content, { mode: 0o644 })
622
+ }
623
+
624
+ console.log(`完成:候选 ${candidates.length} 个,选用 ${summaryKeyFp},去重后模型 ${summaryModelCount} 个`)
625
+ console.log(`JSON → ${outPath}(${json.length} bytes)`)
626
+ console.log(`MD → ${mdPath}`)
627
+ }
628
+
629
+ // import.meta.url check so the verify script can import this module freely
630
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
631
+ main().catch((err) => { console.error(err?.stack ?? err); process.exit(1) })
632
+ }
@@ -0,0 +1,45 @@
1
+ #!/usr/bin/env node
2
+ // AGENTS.md 预算与引用完整性闸门(离线、零依赖;本地与 CI 同跑)。
3
+ // 锁三件事:①体积预算(行数/字节)②引用的本仓路径都存在 ③踩坑速查编号集 == docs/pitfalls.md 编号集且连续。
4
+ // 背景与动机见 docs/pitfalls.md #52。
5
+ import { readFileSync, existsSync } from 'node:fs';
6
+
7
+ const MAX_LINES = 150;
8
+ const MAX_BYTES = 12000;
9
+ let failures = 0;
10
+ const ok = (m) => console.log(`ok ${m}`);
11
+ const fail = (m) => { console.error(`FAIL ${m}`); failures++; };
12
+
13
+ const buf = readFileSync('AGENTS.md');
14
+ const text = buf.toString('utf8');
15
+
16
+ const nLines = text.split('\n').length - (text.endsWith('\n') ? 1 : 0);
17
+ (nLines <= MAX_LINES ? ok : fail)(`AGENTS.md 行数 ${nLines} <= ${MAX_LINES}`);
18
+ (buf.length <= MAX_BYTES ? ok : fail)(`AGENTS.md 字节数 ${buf.length} <= ${MAX_BYTES}`);
19
+
20
+ // 引用路径存在性:只查白名单根前缀/裸文件名,URL 与 ~/.dsh 等不纳入
21
+ const ROOTS = ['docs/', 'wiki/', 'scripts/', 'providers/', 'core/', 'lib/', '.agents/', '.github/'];
22
+ const BARE = new Set(['AGENTS.md', 'CHANGELOG.md', 'cordis.patch.yml', 'package.json', 'index.js', 'host-config.js']);
23
+ const candidates = new Set();
24
+ for (const m of text.matchAll(/[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)+\/?/g)) {
25
+ const p = m[0];
26
+ if (ROOTS.some((r) => p.startsWith(r))) candidates.add(p);
27
+ }
28
+ for (const m of text.matchAll(/[A-Za-z0-9_.-]+\.[a-z]+/g)) if (BARE.has(m[0])) candidates.add(m[0]);
29
+ for (const p of [...candidates].sort()) (existsSync(p) ? ok : fail)(`引用存在 ${p}`);
30
+
31
+ // 踩坑编号连续性:AGENTS.md 速查表 `#N` 集合 == pitfalls.md `^N.` 集合 == 1..max
32
+ const sec = text.match(/## 踩坑速查[\s\S]*?(?=\n## )/);
33
+ if (!sec) fail('踩坑速查章节缺失');
34
+ const agentNums = new Set(sec ? [...sec[0].matchAll(/`#(\d+)`/g)].map((m) => +m[1]) : []);
35
+ const pitText = readFileSync('docs/pitfalls.md', 'utf8');
36
+ const fileNums = new Set([...pitText.matchAll(/^(\d+)\.\s/gm)].map((m) => +m[1]));
37
+ const max = Math.max(...fileNums);
38
+ const continuous = fileNums.size === max && agentNums.size === max;
39
+ const sameSet = agentNums.size === fileNums.size && [...agentNums].every((n) => fileNums.has(n));
40
+ (sameSet && continuous ? ok : fail)(
41
+ `踩坑编号连续对账 AGENTS.md=${agentNums.size} pitfalls.md=${fileNums.size} 1..${max}${sameSet && continuous ? '' : '(集合不一致或断号)'}`,
42
+ );
43
+
44
+ if (failures) { console.error(`${failures} 项未过`); process.exit(1); }
45
+ console.log('agents-md budget check all green');