dsh-d365-kit 0.34.1 → 0.34.4

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 (75) hide show
  1. package/CHANGELOG.md +319 -308
  2. package/CHANGELOG.zh-CN.md +318 -307
  3. package/INSTALL.md +94 -94
  4. package/INSTALL.zh-CN.md +93 -93
  5. package/LICENSE +21 -21
  6. package/README.md +336 -327
  7. package/README.zh-CN.md +336 -327
  8. package/client.js +1818 -1818
  9. package/cordis.patch.yml +44 -44
  10. package/docs/en/architecture.md +172 -172
  11. package/docs/en/checks.md +184 -184
  12. package/docs/en/installation.md +151 -151
  13. package/docs/en/permissions.md +161 -161
  14. package/docs/en/scenarios.md +202 -202
  15. package/docs/en/tools.md +665 -665
  16. package/docs/en/troubleshooting.md +131 -131
  17. package/docs/zh/architecture.md +172 -172
  18. package/docs/zh/checks.md +184 -184
  19. package/docs/zh/installation.md +151 -151
  20. package/docs/zh/permissions.md +161 -161
  21. package/docs/zh/scenarios.md +202 -202
  22. package/docs/zh/tools.md +665 -665
  23. package/docs/zh/troubleshooting.md +131 -131
  24. package/examples/prompts.en.md +206 -206
  25. package/examples/prompts.zh-CN.md +206 -206
  26. package/examples/waivers.example.json +20 -20
  27. package/icon.svg +6 -6
  28. package/index.js +139 -139
  29. package/lib/auth.js +181 -181
  30. package/lib/client.js +496 -496
  31. package/lib/code-check.js +549 -549
  32. package/lib/config.js +323 -323
  33. package/lib/contract-history.js +49 -49
  34. package/lib/contract.js +583 -583
  35. package/lib/csharp-check.js +493 -493
  36. package/lib/data-migrate.js +1318 -1318
  37. package/lib/data-policy.js +78 -78
  38. package/lib/error-codes.js +80 -80
  39. package/lib/gitlab.js +161 -161
  40. package/lib/health.js +205 -205
  41. package/lib/migrate.js +732 -732
  42. package/lib/preflight.js +116 -116
  43. package/lib/refs.js +191 -191
  44. package/lib/resources.js +320 -320
  45. package/lib/retention-defaults.js +17 -17
  46. package/lib/retention.js +384 -384
  47. package/lib/runtime.js +232 -232
  48. package/lib/settings-http.js +512 -512
  49. package/lib/settings-store.js +371 -371
  50. package/lib/snapshot.js +149 -149
  51. package/lib/tool-code-check.js +133 -133
  52. package/lib/tool-configure.js +169 -169
  53. package/lib/tool-contract.js +376 -376
  54. package/lib/tool-data-sync.js +559 -559
  55. package/lib/tool-data-write.js +203 -203
  56. package/lib/tool-data.js +282 -282
  57. package/lib/tool-diff.js +484 -484
  58. package/lib/tool-environments.js +106 -106
  59. package/lib/tool-flows.js +540 -540
  60. package/lib/tool-health.js +127 -127
  61. package/lib/tool-layers.js +497 -497
  62. package/lib/tool-metadata.js +417 -417
  63. package/lib/tool-migrate.js +102 -102
  64. package/lib/tool-refs.js +206 -206
  65. package/lib/tool-reports.js +299 -299
  66. package/lib/tool-sync.js +430 -430
  67. package/lib/tool-write.js +563 -563
  68. package/lib/waivers.js +83 -83
  69. package/lib/write-actions.js +1374 -1374
  70. package/lib/write-policy.js +199 -199
  71. package/lib/write-store.js +167 -167
  72. package/locale/en.json +68 -68
  73. package/locale/zh.json +68 -68
  74. package/package.json +2 -2
  75. package/skills/d365-troubleshooting/SKILL.md +115 -115
@@ -1,299 +1,299 @@
1
- /**
2
- * `d365_reports` — one place to see what the other tools produced, how much
3
- * space `.d365` is using, and to apply the retention policy by hand.
4
- *
5
- * Cleanup always plans first (`dryRun` defaults to true) and never touches
6
- * waivers, pending plans, config or secrets.
7
- */
8
-
9
- import { readdir, readFile, stat } from 'node:fs/promises'
10
- import { isAbsolute, join, relative, resolve } from 'node:path'
11
- import { loadConfig } from './config.js'
12
- import { D365Error } from './errors.js'
13
- import { dataRoot } from './snapshot.js'
14
- import { readHistory } from './contract-history.js'
15
- import { readWaivers } from './waivers.js'
16
- import {
17
- applyCleanup, compactAllSnapshots, formatBytes, planCleanup, storageSummary,
18
- } from './retention.js'
19
-
20
- const CLASS_IDS = ['snapshots', 'reports', 'diffs', 'query', 'changes', 'all']
21
-
22
- async function listFiles(directory, limit) {
23
- let names = []
24
- try {
25
- names = await readdir(directory)
26
- } catch {
27
- return []
28
- }
29
- const rows = []
30
- for (const name of names) {
31
- try {
32
- const info = await stat(join(directory, name))
33
- if (!info.isFile()) continue
34
- rows.push({ name, size: info.size, modifiedAt: info.mtime.toISOString() })
35
- } catch {
36
- // A file that vanished between readdir and stat is simply not listed.
37
- }
38
- }
39
- rows.sort((a, b) => b.modifiedAt.localeCompare(a.modifiedAt))
40
- return rows.slice(0, limit)
41
- }
42
-
43
- const VIEW_DEFAULT_LIMIT = 60
44
- const VIEW_MAX_LIMIT = 2000
45
- const VIEW_WRAP_DEFAULT = 400
46
-
47
- /** @returns true when `file` stays inside `root` (no `..` escape, no absolute jump). */
48
- function insideRoot(root, file) {
49
- const rel = relative(root, file)
50
- return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel))
51
- }
52
-
53
- /**
54
- * Locate an artifact the model asked to read.
55
- *
56
- * Only files under `.d365` are reachable: an absolute path that points
57
- * elsewhere is refused rather than silently read, because the tool's contract
58
- * is "show me what this plugin wrote", not "read any file".
59
- * @returns `{ file, size }`
60
- */
61
- async function resolveArtifact(config, name) {
62
- const wanted = String(name ?? '').trim()
63
- if (!wanted) {
64
- throw new D365Error('view 需要 file', {
65
- hint: '先用 action=list 看有哪些产物;file 可以是 .d365 下的相对路径(如 query/xxx.json)或文件名。',
66
- })
67
- }
68
- const root = resolve(dataRoot(config))
69
- const candidates = isAbsolute(wanted)
70
- ? [resolve(wanted)]
71
- : [resolve(root, wanted), ...['query', 'reports', 'diffs', 'changes'].map((dir) => resolve(root, dir, wanted))]
72
- for (const candidate of candidates) {
73
- if (!insideRoot(root, candidate)) continue
74
- try {
75
- const info = await stat(candidate)
76
- if (info.isFile()) return { file: candidate, size: info.size }
77
- } catch {
78
- // Not here; the next candidate may be.
79
- }
80
- }
81
- throw new D365Error(`找不到产物:${wanted}`, {
82
- code: 'artifact-not-found',
83
- hint: '只在 .d365 目录里查找。用 action=list 看现有文件;绝对路径也必须落在这个目录内。',
84
- })
85
- }
86
-
87
- /**
88
- * Render a window of one artifact.
89
- *
90
- * JSON written by the query tools is one very long line, so it is expanded by
91
- * default; `match` narrows to the lines that mention a string, which is the
92
- * only practical way to find one GUID in a 500 KB result.
93
- */
94
- async function renderView(config, args) {
95
- const { file, size } = await resolveArtifact(config, args.file)
96
- const raw = await readFile(file, 'utf8')
97
- const isJson = file.toLowerCase().endsWith('.json')
98
- const pretty = args.pretty ?? isJson
99
- let text = raw
100
- if (pretty && isJson) {
101
- try {
102
- text = JSON.stringify(JSON.parse(raw), null, 2)
103
- } catch {
104
- // Malformed or truncated JSON: show what is on disk instead of failing.
105
- }
106
- }
107
-
108
- const allLines = text.split(/\r?\n/)
109
- const match = String(args.match ?? '').trim()
110
- const numbered = allLines.map((line, index) => ({ line, number: index + 1 }))
111
- const filtered = match
112
- ? numbered.filter((item) => item.line.toLowerCase().includes(match.toLowerCase()))
113
- : numbered
114
-
115
- const offset = Number.isFinite(args.offset) && args.offset > 0 ? Math.floor(args.offset) : 0
116
- const limit = Math.min(Math.max(Number.isFinite(args.limit) ? Math.floor(args.limit) : VIEW_DEFAULT_LIMIT, 1), VIEW_MAX_LIMIT)
117
- const wrap = args.wrap === 0 ? 0 : Math.min(Math.max(Number.isFinite(args.wrap) ? Math.floor(args.wrap) : VIEW_WRAP_DEFAULT, 40), 4000)
118
- const window = filtered.slice(offset, offset + limit)
119
-
120
- const header = [
121
- `文件:${file}`,
122
- `大小:${formatBytes(size)} · 行数:${allLines.length}${pretty && isJson ? '(JSON 已展开)' : ''}${match ? ` · 匹配 "${match}":${filtered.length} 行` : ''}`,
123
- ]
124
- const shownTo = offset + window.length
125
- const remaining = filtered.length - shownTo
126
- header.push(`第 ${offset + 1}–${shownTo} 行${remaining > 0 ? `(还有 ${remaining} 行,调 offset: ${shownTo} 继续)` : ''}`)
127
-
128
- const body = []
129
- for (const item of window) {
130
- const pieces = wrap > 0 ? item.line.match(new RegExp(`[\\s\\S]{1,${wrap}}`, 'g')) ?? [''] : [item.line]
131
- body.push(`${String(item.number).padStart(6)}│ ${pieces[0]}`)
132
- for (const piece of pieces.slice(1)) body.push(`${' '.repeat(6)}│ ${piece}`)
133
- }
134
- if (!window.length) body.push(match ? '(没有匹配的行)' : '(文件为空)')
135
-
136
- return { text: [...header, '', ...body].join('\n'), file }
137
- }
138
-
139
- function storageLines(summary) {
140
- const lines = [`占用合计:${formatBytes(summary.totalBytes)}${summary.overLimit ? `(超过 ${summary.warnMegabytes} MB,建议清理)` : ''}`, '']
141
- for (const row of summary.classes) {
142
- lines.push(`- ${row.label}(${row.id}):${row.count} 个 · ${formatBytes(row.bytes)} · 保留 ${row.keep} 个 / ${row.maxAgeDays} 天${row.hint ? ` · ${row.hint}` : ''}`)
143
- }
144
- return lines
145
- }
146
-
147
- function planLines(plan) {
148
- const lines = [`将删除 ${plan.totalFiles} 个文件,释放 ${formatBytes(plan.totalBytes)}`, '']
149
- for (const row of plan.classes) {
150
- if (!row.remove.length) {
151
- lines.push(`- ${row.label}:无需清理(${row.count} 个,保留 ${row.keep} 个 / ${row.maxAgeDays} 天)`)
152
- continue
153
- }
154
- lines.push(`- ${row.label}:删除 ${row.remove.length} 个 · ${formatBytes(row.bytes)}`)
155
- for (const item of row.remove.slice(0, 5)) {
156
- lines.push(` · ${item.name}(${item.ageDays} 天前,${formatBytes(item.bytes)})`)
157
- }
158
- if (row.remove.length > 5) lines.push(` · … 其余 ${row.remove.length - 5} 个`)
159
- }
160
- return lines
161
- }
162
-
163
- export function register(ctx, state) {
164
- const dispose = ctx.tools.register({
165
- name: 'd365_reports',
166
- description:
167
- '查看本机数据占用与产物(.d365/reports、query 等),并按保留策略清理或压缩历史产物。cleanup 默认只预演(dryRun),确认后传 dryRun=false 才真正删除。',
168
- parameters: {
169
- type: 'object',
170
- additionalProperties: false,
171
- properties: {
172
- action: {
173
- type: 'string',
174
- enum: ['list', 'cleanup', 'compact', 'view'],
175
- description: 'list:看占用与产物;view:读回一个产物文件(分页/过滤);cleanup:按策略清理;compact:把已有快照压成紧凑 JSON。默认 list。',
176
- },
177
- file: {
178
- type: 'string',
179
- description: 'view 的文件:.d365 下的相对路径(如 query/2026-11-06T01-36-47-274Z-layers.json)或文件名。',
180
- },
181
- offset: { type: 'number', description: 'view 从第几行开始(0 基),默认 0。' },
182
- match: { type: 'string', description: 'view 只显示包含这个字符串的行(不区分大小写)。在大 JSON 里找一条 GUID 用这个。' },
183
- wrap: { type: 'number', description: 'view 每行最多多少字符,超长折行;0 表示不折行。默认 400。' },
184
- pretty: { type: 'boolean', description: 'view 是否把 JSON 缩进展开,默认对 .json 为 true。' },
185
- target: {
186
- type: 'string',
187
- enum: CLASS_IDS,
188
- description: 'cleanup 的范围:某一类或 all,默认 all。',
189
- },
190
- dryRun: {
191
- type: 'boolean',
192
- description: 'cleanup 默认 true,只预演不删除;确认要删时传 false。',
193
- },
194
- limit: { type: 'number', description: 'list 每类最多列多少条,默认 15。' },
195
- },
196
- },
197
- output: {
198
- schema: {
199
- type: 'object',
200
- additionalProperties: false,
201
- required: ['schemaVersion', 'action', 'text'],
202
- properties: {
203
- schemaVersion: { type: 'number' },
204
- action: { type: 'string' },
205
- text: { type: 'string' },
206
- },
207
- },
208
- render: (args, value) => [{ type: 'text', text: value.text }],
209
- },
210
- async execute(args) {
211
- const config = await loadConfig(state.rawConfig)
212
- const action = args.action ?? 'list'
213
-
214
- if (action === 'view') {
215
- const view = await renderView(config, args)
216
- return { schemaVersion: 1, action, text: view.text }
217
- }
218
-
219
- if (action === 'compact') {
220
- const result = await compactAllSnapshots(config)
221
- const summary = await storageSummary(config)
222
- const lines = [
223
- `已压缩 ${result.snapshots} 份快照里的 ${result.files} 个文件,释放 ${formatBytes(result.savedBytes)}。`,
224
- '',
225
- ...storageLines(summary),
226
- ]
227
- return { schemaVersion: 1, action, text: lines.join('\n') }
228
- }
229
-
230
- if (action === 'cleanup') {
231
- const target = CLASS_IDS.includes(args.target) ? args.target : 'all'
232
- const dryRun = args.dryRun !== false
233
- const plan = await planCleanup(config, { target })
234
- if (dryRun) {
235
- const lines = [`清理预演(${target}):尚未删除任何文件。`, '', ...planLines(plan)]
236
- lines.push('', '确认要删除时,把 dryRun 设为 false 再调用一次。')
237
- return { schemaVersion: 1, action, text: lines.join('\n') }
238
- }
239
- const applied = await applyCleanup(config, plan)
240
- const summary = await storageSummary(config)
241
- const lines = [
242
- `已删除 ${applied.deleted.length} 个文件,释放 ${formatBytes(applied.bytes)}。`,
243
- ...(applied.failures.length ? ['', `失败 ${applied.failures.length} 个:`, ...applied.failures.map((item) => `- ${item.name}:${item.reason}`)] : []),
244
- '',
245
- ...storageLines(summary),
246
- ]
247
- return { schemaVersion: 1, action, text: lines.join('\n') }
248
- }
249
-
250
- const limit = Number.isFinite(args.limit) ? Math.min(Math.max(args.limit, 1), 100) : 15
251
- const root = dataRoot(config)
252
- const reports = await listFiles(join(root, 'reports'), limit)
253
- const queries = await listFiles(join(root, 'query'), limit)
254
- const summary = await storageSummary(config)
255
-
256
- let waiverCount = 0
257
- try {
258
- const document = await readWaivers(config)
259
- waiverCount = (document.waivers ?? []).length
260
- } catch {
261
- waiverCount = 0
262
- }
263
- let lastContractRun = ''
264
- try {
265
- const history = await readHistory(config)
266
- const last = history.runs?.[history.runs.length - 1]
267
- if (last) {
268
- lastContractRun = `${last.at}(${last.source} → ${(last.targets ?? []).join(', ')},阻断 ${last.blocker ?? 0} / 警告 ${last.warning ?? 0},豁免 ${last.waived ?? 0})`
269
- }
270
- } catch {
271
- lastContractRun = ''
272
- }
273
-
274
- const lines = ['本机数据', '', ...storageLines(summary), '']
275
- // Totals come from the storage summary, not from the truncated listing:
276
- // "25 个" next to three bullets looked like a bug.
277
- const classCount = (id) => summary.classes.find((item) => item.id === id)?.count ?? 0
278
- const reportTotal = classCount('reports')
279
- lines.push(`报告(.d365/reports):${reportTotal} 个${reportTotal > reports.length ? `,下面是最近 ${reports.length} 个` : ''}`)
280
- for (const row of reports) {
281
- lines.push(`- ${row.name} · ${(row.size / 1024).toFixed(1)} KB · ${row.modifiedAt}`)
282
- }
283
- lines.push('')
284
- const queryTotal = classCount('query')
285
- const shownQueries = queries.slice(0, 8)
286
- lines.push(`查询留证(.d365/query):${queryTotal} 个${queryTotal > shownQueries.length ? `,下面是最近 ${shownQueries.length} 个` : ''}`)
287
- for (const row of shownQueries) {
288
- lines.push(`- ${row.name} · ${(row.size / 1024).toFixed(1)} KB`)
289
- }
290
- lines.push('')
291
- lines.push(`已登记豁免:${waiverCount} 条`)
292
- lines.push(`最近一次契约检查:${lastContractRun || '还没有跑过'}`)
293
- lines.push('')
294
- lines.push('要读回某个产物(而不是只看文件名),用 action=view 加 file;大文件配 match 过滤、offset/limit 翻页。')
295
- return { schemaVersion: 1, action: 'list', text: lines.join('\n') }
296
- },
297
- })
298
- return dispose
299
- }
1
+ /**
2
+ * `d365_reports` — one place to see what the other tools produced, how much
3
+ * space `.d365` is using, and to apply the retention policy by hand.
4
+ *
5
+ * Cleanup always plans first (`dryRun` defaults to true) and never touches
6
+ * waivers, pending plans, config or secrets.
7
+ */
8
+
9
+ import { readdir, readFile, stat } from 'node:fs/promises'
10
+ import { isAbsolute, join, relative, resolve } from 'node:path'
11
+ import { loadConfig } from './config.js'
12
+ import { D365Error } from './errors.js'
13
+ import { dataRoot } from './snapshot.js'
14
+ import { readHistory } from './contract-history.js'
15
+ import { readWaivers } from './waivers.js'
16
+ import {
17
+ applyCleanup, compactAllSnapshots, formatBytes, planCleanup, storageSummary,
18
+ } from './retention.js'
19
+
20
+ const CLASS_IDS = ['snapshots', 'reports', 'diffs', 'query', 'changes', 'all']
21
+
22
+ async function listFiles(directory, limit) {
23
+ let names = []
24
+ try {
25
+ names = await readdir(directory)
26
+ } catch {
27
+ return []
28
+ }
29
+ const rows = []
30
+ for (const name of names) {
31
+ try {
32
+ const info = await stat(join(directory, name))
33
+ if (!info.isFile()) continue
34
+ rows.push({ name, size: info.size, modifiedAt: info.mtime.toISOString() })
35
+ } catch {
36
+ // A file that vanished between readdir and stat is simply not listed.
37
+ }
38
+ }
39
+ rows.sort((a, b) => b.modifiedAt.localeCompare(a.modifiedAt))
40
+ return rows.slice(0, limit)
41
+ }
42
+
43
+ const VIEW_DEFAULT_LIMIT = 60
44
+ const VIEW_MAX_LIMIT = 2000
45
+ const VIEW_WRAP_DEFAULT = 400
46
+
47
+ /** @returns true when `file` stays inside `root` (no `..` escape, no absolute jump). */
48
+ function insideRoot(root, file) {
49
+ const rel = relative(root, file)
50
+ return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel))
51
+ }
52
+
53
+ /**
54
+ * Locate an artifact the model asked to read.
55
+ *
56
+ * Only files under `.d365` are reachable: an absolute path that points
57
+ * elsewhere is refused rather than silently read, because the tool's contract
58
+ * is "show me what this plugin wrote", not "read any file".
59
+ * @returns `{ file, size }`
60
+ */
61
+ async function resolveArtifact(config, name) {
62
+ const wanted = String(name ?? '').trim()
63
+ if (!wanted) {
64
+ throw new D365Error('view 需要 file', {
65
+ hint: '先用 action=list 看有哪些产物;file 可以是 .d365 下的相对路径(如 query/xxx.json)或文件名。',
66
+ })
67
+ }
68
+ const root = resolve(dataRoot(config))
69
+ const candidates = isAbsolute(wanted)
70
+ ? [resolve(wanted)]
71
+ : [resolve(root, wanted), ...['query', 'reports', 'diffs', 'changes'].map((dir) => resolve(root, dir, wanted))]
72
+ for (const candidate of candidates) {
73
+ if (!insideRoot(root, candidate)) continue
74
+ try {
75
+ const info = await stat(candidate)
76
+ if (info.isFile()) return { file: candidate, size: info.size }
77
+ } catch {
78
+ // Not here; the next candidate may be.
79
+ }
80
+ }
81
+ throw new D365Error(`找不到产物:${wanted}`, {
82
+ code: 'artifact-not-found',
83
+ hint: '只在 .d365 目录里查找。用 action=list 看现有文件;绝对路径也必须落在这个目录内。',
84
+ })
85
+ }
86
+
87
+ /**
88
+ * Render a window of one artifact.
89
+ *
90
+ * JSON written by the query tools is one very long line, so it is expanded by
91
+ * default; `match` narrows to the lines that mention a string, which is the
92
+ * only practical way to find one GUID in a 500 KB result.
93
+ */
94
+ async function renderView(config, args) {
95
+ const { file, size } = await resolveArtifact(config, args.file)
96
+ const raw = await readFile(file, 'utf8')
97
+ const isJson = file.toLowerCase().endsWith('.json')
98
+ const pretty = args.pretty ?? isJson
99
+ let text = raw
100
+ if (pretty && isJson) {
101
+ try {
102
+ text = JSON.stringify(JSON.parse(raw), null, 2)
103
+ } catch {
104
+ // Malformed or truncated JSON: show what is on disk instead of failing.
105
+ }
106
+ }
107
+
108
+ const allLines = text.split(/\r?\n/)
109
+ const match = String(args.match ?? '').trim()
110
+ const numbered = allLines.map((line, index) => ({ line, number: index + 1 }))
111
+ const filtered = match
112
+ ? numbered.filter((item) => item.line.toLowerCase().includes(match.toLowerCase()))
113
+ : numbered
114
+
115
+ const offset = Number.isFinite(args.offset) && args.offset > 0 ? Math.floor(args.offset) : 0
116
+ const limit = Math.min(Math.max(Number.isFinite(args.limit) ? Math.floor(args.limit) : VIEW_DEFAULT_LIMIT, 1), VIEW_MAX_LIMIT)
117
+ const wrap = args.wrap === 0 ? 0 : Math.min(Math.max(Number.isFinite(args.wrap) ? Math.floor(args.wrap) : VIEW_WRAP_DEFAULT, 40), 4000)
118
+ const window = filtered.slice(offset, offset + limit)
119
+
120
+ const header = [
121
+ `文件:${file}`,
122
+ `大小:${formatBytes(size)} · 行数:${allLines.length}${pretty && isJson ? '(JSON 已展开)' : ''}${match ? ` · 匹配 "${match}":${filtered.length} 行` : ''}`,
123
+ ]
124
+ const shownTo = offset + window.length
125
+ const remaining = filtered.length - shownTo
126
+ header.push(`第 ${offset + 1}–${shownTo} 行${remaining > 0 ? `(还有 ${remaining} 行,调 offset: ${shownTo} 继续)` : ''}`)
127
+
128
+ const body = []
129
+ for (const item of window) {
130
+ const pieces = wrap > 0 ? item.line.match(new RegExp(`[\\s\\S]{1,${wrap}}`, 'g')) ?? [''] : [item.line]
131
+ body.push(`${String(item.number).padStart(6)}│ ${pieces[0]}`)
132
+ for (const piece of pieces.slice(1)) body.push(`${' '.repeat(6)}│ ${piece}`)
133
+ }
134
+ if (!window.length) body.push(match ? '(没有匹配的行)' : '(文件为空)')
135
+
136
+ return { text: [...header, '', ...body].join('\n'), file }
137
+ }
138
+
139
+ function storageLines(summary) {
140
+ const lines = [`占用合计:${formatBytes(summary.totalBytes)}${summary.overLimit ? `(超过 ${summary.warnMegabytes} MB,建议清理)` : ''}`, '']
141
+ for (const row of summary.classes) {
142
+ lines.push(`- ${row.label}(${row.id}):${row.count} 个 · ${formatBytes(row.bytes)} · 保留 ${row.keep} 个 / ${row.maxAgeDays} 天${row.hint ? ` · ${row.hint}` : ''}`)
143
+ }
144
+ return lines
145
+ }
146
+
147
+ function planLines(plan) {
148
+ const lines = [`将删除 ${plan.totalFiles} 个文件,释放 ${formatBytes(plan.totalBytes)}`, '']
149
+ for (const row of plan.classes) {
150
+ if (!row.remove.length) {
151
+ lines.push(`- ${row.label}:无需清理(${row.count} 个,保留 ${row.keep} 个 / ${row.maxAgeDays} 天)`)
152
+ continue
153
+ }
154
+ lines.push(`- ${row.label}:删除 ${row.remove.length} 个 · ${formatBytes(row.bytes)}`)
155
+ for (const item of row.remove.slice(0, 5)) {
156
+ lines.push(` · ${item.name}(${item.ageDays} 天前,${formatBytes(item.bytes)})`)
157
+ }
158
+ if (row.remove.length > 5) lines.push(` · … 其余 ${row.remove.length - 5} 个`)
159
+ }
160
+ return lines
161
+ }
162
+
163
+ export function register(ctx, state) {
164
+ const dispose = ctx.tools.register({
165
+ name: 'd365_reports',
166
+ description:
167
+ '查看本机数据占用与产物(.d365/reports、query 等),并按保留策略清理或压缩历史产物。cleanup 默认只预演(dryRun),确认后传 dryRun=false 才真正删除。',
168
+ parameters: {
169
+ type: 'object',
170
+ additionalProperties: false,
171
+ properties: {
172
+ action: {
173
+ type: 'string',
174
+ enum: ['list', 'cleanup', 'compact', 'view'],
175
+ description: 'list:看占用与产物;view:读回一个产物文件(分页/过滤);cleanup:按策略清理;compact:把已有快照压成紧凑 JSON。默认 list。',
176
+ },
177
+ file: {
178
+ type: 'string',
179
+ description: 'view 的文件:.d365 下的相对路径(如 query/2026-11-06T01-36-47-274Z-layers.json)或文件名。',
180
+ },
181
+ offset: { type: 'number', description: 'view 从第几行开始(0 基),默认 0。' },
182
+ match: { type: 'string', description: 'view 只显示包含这个字符串的行(不区分大小写)。在大 JSON 里找一条 GUID 用这个。' },
183
+ wrap: { type: 'number', description: 'view 每行最多多少字符,超长折行;0 表示不折行。默认 400。' },
184
+ pretty: { type: 'boolean', description: 'view 是否把 JSON 缩进展开,默认对 .json 为 true。' },
185
+ target: {
186
+ type: 'string',
187
+ enum: CLASS_IDS,
188
+ description: 'cleanup 的范围:某一类或 all,默认 all。',
189
+ },
190
+ dryRun: {
191
+ type: 'boolean',
192
+ description: 'cleanup 默认 true,只预演不删除;确认要删时传 false。',
193
+ },
194
+ limit: { type: 'number', description: 'list 每类最多列多少条,默认 15。' },
195
+ },
196
+ },
197
+ output: {
198
+ schema: {
199
+ type: 'object',
200
+ additionalProperties: false,
201
+ required: ['schemaVersion', 'action', 'text'],
202
+ properties: {
203
+ schemaVersion: { type: 'number' },
204
+ action: { type: 'string' },
205
+ text: { type: 'string' },
206
+ },
207
+ },
208
+ render: (args, value) => [{ type: 'text', text: value.text }],
209
+ },
210
+ async execute(args) {
211
+ const config = await loadConfig(state.rawConfig)
212
+ const action = args.action ?? 'list'
213
+
214
+ if (action === 'view') {
215
+ const view = await renderView(config, args)
216
+ return { schemaVersion: 1, action, text: view.text }
217
+ }
218
+
219
+ if (action === 'compact') {
220
+ const result = await compactAllSnapshots(config)
221
+ const summary = await storageSummary(config)
222
+ const lines = [
223
+ `已压缩 ${result.snapshots} 份快照里的 ${result.files} 个文件,释放 ${formatBytes(result.savedBytes)}。`,
224
+ '',
225
+ ...storageLines(summary),
226
+ ]
227
+ return { schemaVersion: 1, action, text: lines.join('\n') }
228
+ }
229
+
230
+ if (action === 'cleanup') {
231
+ const target = CLASS_IDS.includes(args.target) ? args.target : 'all'
232
+ const dryRun = args.dryRun !== false
233
+ const plan = await planCleanup(config, { target })
234
+ if (dryRun) {
235
+ const lines = [`清理预演(${target}):尚未删除任何文件。`, '', ...planLines(plan)]
236
+ lines.push('', '确认要删除时,把 dryRun 设为 false 再调用一次。')
237
+ return { schemaVersion: 1, action, text: lines.join('\n') }
238
+ }
239
+ const applied = await applyCleanup(config, plan)
240
+ const summary = await storageSummary(config)
241
+ const lines = [
242
+ `已删除 ${applied.deleted.length} 个文件,释放 ${formatBytes(applied.bytes)}。`,
243
+ ...(applied.failures.length ? ['', `失败 ${applied.failures.length} 个:`, ...applied.failures.map((item) => `- ${item.name}:${item.reason}`)] : []),
244
+ '',
245
+ ...storageLines(summary),
246
+ ]
247
+ return { schemaVersion: 1, action, text: lines.join('\n') }
248
+ }
249
+
250
+ const limit = Number.isFinite(args.limit) ? Math.min(Math.max(args.limit, 1), 100) : 15
251
+ const root = dataRoot(config)
252
+ const reports = await listFiles(join(root, 'reports'), limit)
253
+ const queries = await listFiles(join(root, 'query'), limit)
254
+ const summary = await storageSummary(config)
255
+
256
+ let waiverCount = 0
257
+ try {
258
+ const document = await readWaivers(config)
259
+ waiverCount = (document.waivers ?? []).length
260
+ } catch {
261
+ waiverCount = 0
262
+ }
263
+ let lastContractRun = ''
264
+ try {
265
+ const history = await readHistory(config)
266
+ const last = history.runs?.[history.runs.length - 1]
267
+ if (last) {
268
+ lastContractRun = `${last.at}(${last.source} → ${(last.targets ?? []).join(', ')},阻断 ${last.blocker ?? 0} / 警告 ${last.warning ?? 0},豁免 ${last.waived ?? 0})`
269
+ }
270
+ } catch {
271
+ lastContractRun = ''
272
+ }
273
+
274
+ const lines = ['本机数据', '', ...storageLines(summary), '']
275
+ // Totals come from the storage summary, not from the truncated listing:
276
+ // "25 个" next to three bullets looked like a bug.
277
+ const classCount = (id) => summary.classes.find((item) => item.id === id)?.count ?? 0
278
+ const reportTotal = classCount('reports')
279
+ lines.push(`报告(.d365/reports):${reportTotal} 个${reportTotal > reports.length ? `,下面是最近 ${reports.length} 个` : ''}`)
280
+ for (const row of reports) {
281
+ lines.push(`- ${row.name} · ${(row.size / 1024).toFixed(1)} KB · ${row.modifiedAt}`)
282
+ }
283
+ lines.push('')
284
+ const queryTotal = classCount('query')
285
+ const shownQueries = queries.slice(0, 8)
286
+ lines.push(`查询留证(.d365/query):${queryTotal} 个${queryTotal > shownQueries.length ? `,下面是最近 ${shownQueries.length} 个` : ''}`)
287
+ for (const row of shownQueries) {
288
+ lines.push(`- ${row.name} · ${(row.size / 1024).toFixed(1)} KB`)
289
+ }
290
+ lines.push('')
291
+ lines.push(`已登记豁免:${waiverCount} 条`)
292
+ lines.push(`最近一次契约检查:${lastContractRun || '还没有跑过'}`)
293
+ lines.push('')
294
+ lines.push('要读回某个产物(而不是只看文件名),用 action=view 加 file;大文件配 match 过滤、offset/limit 翻页。')
295
+ return { schemaVersion: 1, action: 'list', text: lines.join('\n') }
296
+ },
297
+ })
298
+ return dispose
299
+ }