@huaqiu/dsh-log 0.0.0-stage → 1.1.2

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/package.json CHANGED
@@ -1,6 +1,38 @@
1
1
  {
2
2
  "name": "@huaqiu/dsh-log",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.1.2",
4
+ "description": "DSH plugin: external API calls + whitelisted script execution with structured audit logging (v1.1.2)",
5
+ "type": "module",
6
+ "main": "index.ts",
7
+ "files": [
8
+ "index.ts",
9
+ "audit-core.ts",
10
+ "dsh-log.yml",
11
+ "tools/audit-log.mjs"
12
+ ],
13
+ "publishConfig": {
14
+ "access": "public",
15
+ "registry": "https://registry.npmjs.org"
16
+ },
17
+ "scripts": {
18
+ "test": "node --test test/audit-core.test.ts && node --import ./test/register-stubs.mjs --test test/plugin.test.ts && node --test test/audit-log.test.ts",
19
+ "demo": "node --import ./test/register-stubs.mjs demo.ts",
20
+ "log": "node tools/audit-log.mjs"
21
+ },
22
+ "peerDependencies": {
23
+ "@deepseek-ai/cordis": "*",
24
+ "@deepseek-ai/dsh-tools": "*",
25
+ "@deepseek-ai/schemastery": "*"
26
+ },
27
+ "peerDependenciesMeta": {
28
+ "@deepseek-ai/cordis": {
29
+ "optional": true
30
+ },
31
+ "@deepseek-ai/dsh-tools": {
32
+ "optional": true
33
+ },
34
+ "@deepseek-ai/schemastery": {
35
+ "optional": true
36
+ }
37
+ }
38
+ }
@@ -0,0 +1,391 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * audit-log.mjs — read the dsh-log host audit log (channel 1).
4
+ *
5
+ * The host log is the only place the FULL lifecycle of a tool call is observable:
6
+ * the trajectory channel receives just the terminal record, so a call that begins
7
+ * and never returns is invisible there. This reader makes the richer channel
8
+ * actually usable — filter by call/session/tool, dump statistics, and detect
9
+ * invocations that LOST a record (`--incomplete`).
10
+ *
11
+ * Usage:
12
+ * node tools/audit-log.mjs # newest day: header + last 20 records
13
+ * node tools/audit-log.mjs --stats # aggregate statistics
14
+ * node tools/audit-log.mjs --errors # ERROR records only
15
+ * node tools/audit-log.mjs --call 8b47827d # one invocation (callId prefix ok)
16
+ * node tools/audit-log.mjs --session sess-abc # one session
17
+ * node tools/audit-log.mjs --tool run_external_script
18
+ * node tools/audit-log.mjs --incomplete # calls missing start or terminal
19
+ * node tools/audit-log.mjs --tail 100 --json # raw JSON lines of the last 100
20
+ * node tools/audit-log.mjs --file <path.jsonl> # explicit file
21
+ * node tools/audit-log.mjs --all # every rotated file, not just newest day
22
+ *
23
+ * Default directory: $DSH_HOME/logs, falling back to ~/.dsh/logs.
24
+ * Zero dependencies; plain Node ESM (works on the same Node the plugin needs).
25
+ */
26
+
27
+ import { readdirSync, readFileSync, statSync } from 'node:fs'
28
+ import os from 'node:os'
29
+ import path from 'node:path'
30
+
31
+ const PLUGIN = 'dsh-log'
32
+ const DEFAULT_TAIL = 20
33
+
34
+ const HELP = `audit-log.mjs — 读取 dsh-log 主机审计日志
35
+
36
+ 用法: node tools/audit-log.mjs [选项]
37
+
38
+ 选项:
39
+ --file <path> 指定日志文件(默认读 $DSH_HOME/logs 下最新一天的文件)
40
+ --dir <path> 指定日志目录(默认 $DSH_HOME/logs 或 ~/.dsh/logs)
41
+ --all 读取所有轮转文件,而不只是最新一天
42
+ --call <id> 只看某个调用(callId 前缀即可)
43
+ --session <id> 只看某个会话
44
+ --tool <name> call_external_api | run_external_script
45
+ --errors 只看 ERROR 级记录
46
+ --incomplete 只看记录不完整的调用(缺 start 或缺终态)
47
+ --tail <n> 只显示最后 n 条(默认 ${DEFAULT_TAIL};带过滤条件时默认全部)
48
+ --stats 打印统计汇总
49
+ --json 输出原始 JSON 行
50
+ --help 显示本帮助
51
+
52
+ 示例:
53
+ node tools/audit-log.mjs --errors --tail 50
54
+ node tools/audit-log.mjs --call 8b47827d
55
+ node tools/audit-log.mjs --incomplete
56
+ `
57
+
58
+ // ---------------------------------------------------------------- arg parsing
59
+
60
+ function parseArgs(argv) {
61
+ const o = {
62
+ file: null, dir: null, all: false, call: null, session: null, tool: null,
63
+ errors: false, incomplete: false, tail: null, stats: false, json: false, help: false,
64
+ }
65
+ for (let i = 0; i < argv.length; i += 1) {
66
+ const a = argv[i]
67
+ const need = () => {
68
+ const v = argv[i + 1]
69
+ if (v === undefined || v.startsWith('--')) throw new Error(`${a} 需要一个参数值`)
70
+ i += 1
71
+ return v
72
+ }
73
+ switch (a) {
74
+ case '--file': o.file = need(); break
75
+ case '--dir': o.dir = need(); break
76
+ case '--all': o.all = true; break
77
+ case '--call': o.call = need(); break
78
+ case '--session': o.session = need(); break
79
+ case '--tool': o.tool = need(); break
80
+ case '--errors': o.errors = true; break
81
+ case '--incomplete': o.incomplete = true; break
82
+ case '--tail': o.tail = Number(need()); break
83
+ case '--stats': o.stats = true; break
84
+ case '--json': o.json = true; break
85
+ case '--help': case '-h': o.help = true; break
86
+ default: throw new Error(`未知选项: ${a}(用 --help 查看用法)`)
87
+ }
88
+ }
89
+ if (o.tail !== null && (!Number.isInteger(o.tail) || o.tail < 0)) throw new Error('--tail 需要非负整数')
90
+ return o
91
+ }
92
+
93
+ // ------------------------------------------------------------ file discovery
94
+
95
+ /** Default audit-log directory: $DSH_HOME/logs, else ~/.dsh/logs. */
96
+ function defaultLogDir() {
97
+ const home = (process.env.DSH_HOME || '').trim() || path.join(os.homedir(), '.dsh')
98
+ return path.join(home, 'logs')
99
+ }
100
+
101
+ const FILE_RE = new RegExp(`^${PLUGIN}-(\\d{4})-(\\d{2})-(\\d{2})(-part\\d+)?\\.jsonl$`)
102
+
103
+ /**
104
+ * Resolve which files to read. Without --all, returns every rotated file of the
105
+ * NEWEST day (rotation splits one day across -partN siblings, so one day is the
106
+ * smallest complete unit).
107
+ */
108
+ function resolveFiles(o) {
109
+ if (o.file) {
110
+ statSync(o.file) // throws with a clear message when missing
111
+ return [path.resolve(o.file)]
112
+ }
113
+ const dir = o.dir ? path.resolve(o.dir) : defaultLogDir()
114
+ let entries
115
+ try {
116
+ entries = readdirSync(dir)
117
+ } catch {
118
+ throw new Error(`日志目录不存在或不可读: ${dir}\n(用 --file 直接指定文件,或用 --dir 指定目录)`)
119
+ }
120
+ const matched = entries.filter(e => FILE_RE.test(e))
121
+ if (matched.length === 0) throw new Error(`目录中没有 ${PLUGIN}-*.jsonl 文件: ${dir}`)
122
+ let picked = matched
123
+ if (!o.all) {
124
+ const newest = matched.map(e => e.match(FILE_RE)).map(m => `${m[1]}-${m[2]}-${m[3]}`).sort().at(-1)
125
+ picked = matched.filter(e => e.startsWith(`${PLUGIN}-${newest}`))
126
+ }
127
+ return picked.sort().map(e => path.join(dir, e))
128
+ }
129
+
130
+ // ---------------------------------------------------------------- reading
131
+
132
+ /** Read one file into { file, line, record } entries; unparsable lines are kept with record=null. */
133
+ function readFile(file) {
134
+ const text = readFileSync(file, 'utf8')
135
+ const out = []
136
+ const lines = text.split('\n')
137
+ for (let i = 0; i < lines.length; i += 1) {
138
+ const line = lines[i]
139
+ if (line.trim() === '') continue
140
+ let record = null
141
+ try { record = JSON.parse(line) } catch { /* surfaced by --json / stats as corrupt */ }
142
+ out.push({ file, line, lineNo: i + 1, record })
143
+ }
144
+ return out
145
+ }
146
+
147
+ /**
148
+ * Phase of one record. Prefers the explicit `phase` field (plugin >= 1.0.1) and
149
+ * falls back to field-presence inference so logs written by older builds — like
150
+ * the ones already on disk — still analyse correctly.
151
+ */
152
+ function phaseOf(r) {
153
+ if (typeof r.phase === 'string') return r.phase
154
+ if (r.level === 'ERROR') return 'error'
155
+ if (r.response !== undefined || r.code !== undefined) return 'finished'
156
+ return 'start'
157
+ }
158
+
159
+ const isAudit = r => r !== null && typeof r === 'object' && typeof r.callId === 'string'
160
+ const isEvent = r => r !== null && typeof r === 'object' && typeof r.event === 'string'
161
+
162
+ // ---------------------------------------------------------------- filtering
163
+
164
+ function applyFilters(entries, o) {
165
+ return entries.filter(({ record: r }) => {
166
+ if (o.errors && !(isAudit(r) && r.level === 'ERROR')) return false
167
+ if (o.call && !(isAudit(r) && String(r.callId).startsWith(o.call))) return false
168
+ if (o.session && !(isAudit(r) && String(r.sessionId).includes(o.session))) return false
169
+ if (o.tool && !(isAudit(r) && r.tool === o.tool)) return false
170
+ return true
171
+ })
172
+ }
173
+
174
+ /**
175
+ * Group audit records by callId and report every invocation whose record set is
176
+ * incomplete. Two failure modes, both invisible on the trajectory channel:
177
+ * - `缺终态`: the call started and never reported back — process killed
178
+ * mid-flight, a broken sink, or simply a call still in progress.
179
+ * - `首条记录丢失` / `seq 不连续`: the log's own `seq` proves a record should
180
+ * exist and does not (start lost, gap in the chain).
181
+ * Lost-record detection deliberately requires `seq` evidence: without it a missing
182
+ * `start` cannot be told apart from a call that failed before it ever started
183
+ * (which is a complete story, not a lost record). Legacy records without `seq`
184
+ * therefore only yield the `缺终态` check.
185
+ */
186
+ function analyzeCalls(entries) {
187
+ const calls = new Map()
188
+ for (const { record: r } of entries) {
189
+ if (!isAudit(r)) continue
190
+ let c = calls.get(r.callId)
191
+ if (!c) {
192
+ c = { callId: r.callId, sessionId: r.sessionId, tool: r.tool, records: [], seqs: [] }
193
+ calls.set(r.callId, c)
194
+ }
195
+ c.records.push(r)
196
+ if (typeof r.seq === 'number') c.seqs.push(r.seq)
197
+ }
198
+ const list = [...calls.values()].map(c => {
199
+ const phases = c.records.map(phaseOf)
200
+ const hasTerminal = phases.some(p => p === 'finished' || p === 'error')
201
+ const problems = []
202
+ if (!hasTerminal) problems.push('缺终态')
203
+ const seqs = c.seqs.length === c.records.length ? c.seqs.slice().sort((a, b) => a - b) : null
204
+ if (seqs !== null && seqs.length > 0) {
205
+ if (seqs[0] !== 1) problems.push('首条记录丢失(缺 seq1)')
206
+ else if (!seqs.every((s, i) => s === i + 1)) problems.push(`seq 不连续 (${seqs.join(',')})`)
207
+ }
208
+ const last = c.records.at(-1)
209
+ return {
210
+ ...c,
211
+ phases,
212
+ complete: problems.length === 0,
213
+ problems,
214
+ costMs: typeof last?.costMs === 'number' ? last.costMs : undefined,
215
+ errorType: c.records.map(r => r.errorType).find(Boolean),
216
+ ts: c.records.at(-1)?.ts,
217
+ }
218
+ })
219
+ return list.sort((a, b) => (a.ts ?? 0) - (b.ts ?? 0))
220
+ }
221
+
222
+ // ---------------------------------------------------------------- rendering
223
+
224
+ /**
225
+ * Display width of a string in terminal cells: CJK and fullwidth code points
226
+ * occupy two cells, everything else one. Plain `String.length` padding produces
227
+ * ragged columns the moment a header contains Chinese.
228
+ */
229
+ function width(s) {
230
+ let n = 0
231
+ for (const ch of String(s)) {
232
+ const cp = ch.codePointAt(0)
233
+ const wide = (cp >= 0x1100 && cp <= 0x115f) || (cp >= 0x2e80 && cp <= 0xa4cf)
234
+ || (cp >= 0xac00 && cp <= 0xd7a3) || (cp >= 0xf900 && cp <= 0xfaff)
235
+ || (cp >= 0xfe30 && cp <= 0xfe4f) || (cp >= 0xff00 && cp <= 0xff60) || (cp >= 0xffe0 && cp <= 0xffe6)
236
+ n += wide ? 2 : 1
237
+ }
238
+ return n
239
+ }
240
+
241
+ const pad = (s, w) => String(s) + ' '.repeat(Math.max(0, w - width(s)))
242
+ const padL = (s, w) => ' '.repeat(Math.max(0, w - width(s))) + String(s)
243
+
244
+ /** Local wall-clock time of a record, `HH:MM:SS.mmm`. */
245
+ function clockOf(ts) {
246
+ if (typeof ts !== 'number') return '--:--:--.---'
247
+ const d = new Date(ts)
248
+ const p = (n, w = 2) => String(n).padStart(w, '0')
249
+ return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}.${p(d.getMilliseconds(), 3)}`
250
+ }
251
+
252
+ const short = (id, n = 8) => (typeof id === 'string' ? id.slice(0, n) : '-')
253
+
254
+ function renderRecord({ record: r }) {
255
+ if (isEvent(r)) {
256
+ // Lifecycle events (plugin-started, exporter-unavailable) share the audit
257
+ // column layout so a mixed dump stays readable; the event name takes the
258
+ // errorType column and a false `persistentLog` is called out explicitly.
259
+ return [
260
+ pad(clockOf(r.ts), 12),
261
+ pad(r.level ?? '-', 6),
262
+ pad('(event)', 19),
263
+ pad('-', 9),
264
+ padL('-', 3),
265
+ padL('-', 9),
266
+ pad('-', 5),
267
+ pad(r.event, 15),
268
+ r.persistentLog === false ? '未持久化!' : '',
269
+ ].join(' ')
270
+ }
271
+ if (!isAudit(r)) {
272
+ return [
273
+ pad(clockOf(r?.ts), 12),
274
+ pad('?', 6),
275
+ pad('(无法解析)', 19),
276
+ pad('-', 9),
277
+ padL('-', 3),
278
+ padL('-', 9),
279
+ pad('-', 5),
280
+ pad('-', 15),
281
+ r === null ? 'JSON 解析失败' : JSON.stringify(r).slice(0, 60),
282
+ ].join(' ')
283
+ }
284
+ return [
285
+ pad(clockOf(r.ts), 12),
286
+ pad(r.level, 6),
287
+ pad(r.tool, 19),
288
+ pad(phaseOf(r), 9),
289
+ padL(r.seq ?? '-', 3),
290
+ padL(r.costMs ?? '-', 7) + 'ms',
291
+ pad(r.code ?? '-', 5),
292
+ pad(r.errorType ?? '-', 15),
293
+ short(r.callId),
294
+ ].join(' ')
295
+ }
296
+
297
+ function renderTable(entries) {
298
+ const head = [
299
+ pad('时间', 12), pad('级别', 6), pad('工具', 19), pad('阶段', 9),
300
+ padL('seq', 3), padL('耗时', 9), pad('code', 5), pad('errorType', 15), 'callId',
301
+ ].join(' ')
302
+ return [head, '-'.repeat(width(head)), ...entries.map(renderRecord)].join('\n')
303
+ }
304
+
305
+ function renderStats(entries) {
306
+ const audits = entries.filter(e => isAudit(e.record)).map(e => e.record)
307
+ const events = entries.filter(e => isEvent(e.record)).map(e => e.record)
308
+ const corrupt = entries.filter(e => e.record === null).length
309
+ const calls = analyzeCalls(entries)
310
+ const byTool = {}, byLevel = {}, byError = {}, byPhase = {}
311
+ for (const r of audits) {
312
+ byTool[r.tool] = (byTool[r.tool] ?? 0) + 1
313
+ byLevel[r.level] = (byLevel[r.level] ?? 0) + 1
314
+ byPhase[phaseOf(r)] = (byPhase[phaseOf(r)] ?? 0) + 1
315
+ if (r.errorType) byError[r.errorType] = (byError[r.errorType] ?? 0) + 1
316
+ }
317
+ const costs = audits.map(r => r.costMs).filter(n => typeof n === 'number').sort((a, b) => a - b)
318
+ const pct = p => (costs.length === 0 ? '-' : `${costs[Math.min(costs.length - 1, Math.floor(costs.length * p))]}ms`)
319
+ const ts = audits.map(r => r.ts).filter(n => typeof n === 'number')
320
+ const incomplete = calls.filter(c => !c.complete)
321
+ const kv = obj => Object.entries(obj).sort((a, b) => b[1] - a[1]).map(([k, v]) => `${k}=${v}`).join(' ') || '-'
322
+ const lines = [
323
+ `文件 ${[...new Set(entries.map(e => path.basename(e.file)))].join(', ') || '-'}`,
324
+ `记录 ${audits.length} 条审计 + ${events.length} 条事件${corrupt ? ` + ${corrupt} 条无法解析` : ''}`,
325
+ `调用 ${calls.length} 个(完整 ${calls.length - incomplete.length},不完整 ${incomplete.length})`,
326
+ `时间范围 ${ts.length ? `${clockOf(Math.min(...ts))} → ${clockOf(Math.max(...ts))}` : '-'}`,
327
+ `按工具 ${kv(byTool)}`,
328
+ `按阶段 ${kv(byPhase)}`,
329
+ `按级别 ${kv(byLevel)}`,
330
+ `按错误类型 ${kv(byError)}`,
331
+ `耗时 p50=${pct(0.5)} p95=${pct(0.95)} max=${costs.at(-1) ?? '-'}ms`,
332
+ ]
333
+ if (incomplete.length > 0) {
334
+ lines.push('', '不完整的调用:')
335
+ for (const c of incomplete) lines.push(` ! ${short(c.callId)} ${c.tool} 记录 ${c.records.length} 条 ${c.problems.join(' + ')}`)
336
+ }
337
+ return lines.join('\n')
338
+ }
339
+
340
+ function renderIncomplete(entries) {
341
+ const calls = analyzeCalls(entries).filter(c => !c.complete)
342
+ const head = [pad('callId', 10), pad('工具', 19), padL('记录', 5), pad('问题', 22), 'sessionId'].join(' ')
343
+ const body = calls.map(c => [pad(short(c.callId), 10), pad(c.tool, 19), padL(c.records.length, 5), pad(c.problems.join(' + '), 22), short(c.sessionId, 18)].join(' '))
344
+ const all = analyzeCalls(entries)
345
+ return [
346
+ `共 ${all.length} 个调用,其中 ${calls.length} 个记录不完整`,
347
+ '',
348
+ ...(calls.length === 0 ? ['(没有发现缺失记录的情况)'] : [head, '-'.repeat(width(head)), ...body]),
349
+ ].join('\n')
350
+ }
351
+
352
+ // ---------------------------------------------------------------- main
353
+
354
+ function main() {
355
+ const o = parseArgs(process.argv.slice(2))
356
+ if (o.help) { process.stdout.write(HELP); return 0 }
357
+
358
+ const files = resolveFiles(o)
359
+ const entries = files.flatMap(readFile)
360
+ if (o.json) {
361
+ const filtered = applyFilters(entries, o)
362
+ const tail = o.tail ?? (o.call || o.session || o.tool || o.errors ? filtered.length : DEFAULT_TAIL)
363
+ for (const e of tail > 0 ? filtered.slice(-tail) : filtered) process.stdout.write(`${e.line}\n`)
364
+ return 0
365
+ }
366
+ if (o.incomplete) { process.stdout.write(`${renderIncomplete(entries)}\n`); return 0 }
367
+ if (o.stats) { process.stdout.write(`${renderStats(entries)}\n`); return 0 }
368
+
369
+ const filtered = applyFilters(entries, o).filter(e => !o.errors || !isEvent(e.record))
370
+ const tail = o.tail ?? (o.call || o.session || o.tool || o.errors ? filtered.length : DEFAULT_TAIL)
371
+ const shown = tail > 0 ? filtered.slice(-tail) : filtered
372
+ const all = analyzeCalls(entries)
373
+ const bad = all.filter(c => !c.complete).length
374
+ const audits = filtered.filter(e => isAudit(e.record)).length
375
+ process.stdout.write([
376
+ `文件 ${files.map(f => path.basename(f)).join(', ')}`,
377
+ `筛选 ${shown.length}/${filtered.length} 条${o.call || o.session || o.tool || o.errors ? '(已应用过滤条件)' : `(默认最近 ${DEFAULT_TAIL} 条)`} · 命中审计记录 ${audits} 条 · 全文件不完整调用 ${bad} 个`,
378
+ '',
379
+ renderTable(shown),
380
+ '',
381
+ bad > 0 ? `提示: 有 ${bad} 个调用记录不完整,用 --incomplete 查看详情。` : '提示: 未发现记录不完整的调用。',
382
+ ].join('\n') + '\n')
383
+ return 0
384
+ }
385
+
386
+ try {
387
+ process.exitCode = main()
388
+ } catch (err) {
389
+ process.stderr.write(`audit-log: ${err instanceof Error ? err.message : String(err)}\n`)
390
+ process.exitCode = 1
391
+ }