dsh-local-telemetry 0.1.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/src/config.mjs ADDED
@@ -0,0 +1,199 @@
1
+ /**
2
+ * dsh-local-telemetry — 配置解析(计划 §11)。
3
+ *
4
+ * 规则:
5
+ * - 解析失败 → 采用「关闭」这一安全默认,不猜测用户意图;
6
+ * - 未知键忽略(前向兼容),类型错误逐条记录;
7
+ * - 环境变量不参与配置(密钥不属于遥测配置)。
8
+ */
9
+
10
+ import { homedir } from "node:os";
11
+ import { readFileSync } from "node:fs";
12
+ import { isAbsolute, resolve } from "node:path";
13
+
14
+ export const DEFAULT_CONFIG = Object.freeze({
15
+ enabled: true,
16
+ store: "jsonl", // jsonl | sqlite
17
+ path: "~/.dsh/telemetry",
18
+ sample_rate: 1, // 0..1,按 trace 整体采样
19
+ errors_always_sample: true,
20
+ slow_request_ms: 10000,
21
+ capture_metadata: "none", // none | safe
22
+ hash_names: false,
23
+ retention_days: 7,
24
+ max_file_mb: 100,
25
+ flush_interval_ms: 1000,
26
+ batch_size: 100,
27
+ max_queue_events: 1000,
28
+ price_catalog: null, // 版本化价格目录 JSON 路径
29
+ redact_rules: [], // 用户自定义正则(source 字符串),只保留规则名计数
30
+ });
31
+
32
+ /** 资源预算(计划 §7.2)——作为上限参与校准,配置不能超出这些硬上限。 */
33
+ export const BUDGET_LIMITS = Object.freeze({
34
+ max_event_bytes: 64 * 1024,
35
+ max_queue_events: 1000,
36
+ max_batch_size: 1000,
37
+ min_flush_interval_ms: 50,
38
+ });
39
+
40
+ export function expandHome(p) {
41
+ if (typeof p !== "string" || p.length === 0) return p;
42
+ if (p === "~") return homedir();
43
+ if (p.startsWith("~/") || p.startsWith("~\\")) return resolve(homedir(), p.slice(2));
44
+ return p;
45
+ }
46
+
47
+ export function resolveDataPath(p, cwd = process.cwd()) {
48
+ const expanded = expandHome(p);
49
+ return isAbsolute(expanded) ? expanded : resolve(cwd, expanded);
50
+ }
51
+
52
+ /**
53
+ * 解析时长:`500ms` / `10s` / `30m` / `1h` / `7d` / `30d` → 毫秒;
54
+ * 也接受 ISO 8601 时间戳或 epoch 毫秒数(返回绝对毫秒,relative=false)。
55
+ */
56
+ export function parseDurationOrTimestamp(value, now = Date.now()) {
57
+ if (value === null || value === undefined) return null;
58
+ if (typeof value === "number" && Number.isFinite(value)) return { absolute: value, relative: false };
59
+ if (typeof value !== "string") return null;
60
+ const trimmed = value.trim();
61
+ const rel = trimmed.match(/^(\d+(?:\.\d+)?)(ms|s|m|h|d)$/);
62
+ if (rel) {
63
+ const n = Number.parseFloat(rel[1]);
64
+ const unit = rel[2];
65
+ const mult = { ms: 1, s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 }[unit];
66
+ return { absolute: Math.round(n * mult), relative: true };
67
+ }
68
+ if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(trimmed)) {
69
+ const t = Date.parse(trimmed.endsWith("Z") || /[+-]\d{2}:\d{2}$/.test(trimmed) ? trimmed : `${trimmed}Z`);
70
+ if (Number.isFinite(t)) return { absolute: t, relative: false };
71
+ return null;
72
+ }
73
+ if (/^\d{10,13}$/.test(trimmed)) {
74
+ const n = Number.parseInt(trimmed, 10);
75
+ return { absolute: n < 10_000_000_000 ? n * 1000 : n, relative: false };
76
+ }
77
+ return null;
78
+ }
79
+
80
+ /** 计算 --since / --until 的绝对毫秒边界。 */
81
+ export function sinceUntilRange(since, until, now = Date.now()) {
82
+ const s = parseDurationOrTimestamp(since, now);
83
+ const u = parseDurationOrTimestamp(until, now);
84
+ const from = s ? (s.relative ? now - s.absolute : s.absolute) : null;
85
+ const to = u ? (u.relative ? now - u.absolute : u.absolute) : null;
86
+ return { from, to };
87
+ }
88
+
89
+ function checkType(errors, config, key, type) {
90
+ const value = config[key];
91
+ if (value === undefined || value === null) return;
92
+ const actual = Array.isArray(value) ? "array" : typeof value;
93
+ if (actual !== type) {
94
+ errors.push(`${key} must be ${type}, got ${actual}`);
95
+ config[key] = DEFAULT_CONFIG[key]; // 回退安全默认而不是留空
96
+ }
97
+ }
98
+
99
+ /**
100
+ * 合并默认值 + 文件配置 + CLI 覆盖,产出冻结后的有效配置。
101
+ * 任何类型错误不中止:剔除坏键、记录原因,保持安全默认。
102
+ */
103
+ export function resolveConfig({ file, overrides } = {}) {
104
+ const errors = [];
105
+ let fileConfig = {};
106
+ if (file) {
107
+ if (typeof file === "string") {
108
+ try {
109
+ fileConfig = JSON.parse(readFileSync(file, "utf8"));
110
+ if (fileConfig === null || typeof fileConfig !== "object" || Array.isArray(fileConfig)) {
111
+ errors.push("config file must contain a JSON object");
112
+ fileConfig = {};
113
+ }
114
+ } catch (error) {
115
+ errors.push(`config file unreadable (${error.code ?? error.message})`);
116
+ fileConfig = {};
117
+ }
118
+ } else if (typeof file === "object") {
119
+ fileConfig = file;
120
+ }
121
+ }
122
+
123
+ const merged = { ...DEFAULT_CONFIG, ...fileConfig, ...(overrides ?? {}) };
124
+ for (const key of Object.keys(merged)) {
125
+ if (!(key in DEFAULT_CONFIG)) delete merged[key]; // 未知键忽略
126
+ }
127
+
128
+ checkType(errors, merged, "enabled", "boolean");
129
+ checkType(errors, merged, "store", "string");
130
+ checkType(errors, merged, "path", "string");
131
+ checkType(errors, merged, "hash_names", "boolean");
132
+ checkType(errors, merged, "errors_always_sample", "boolean");
133
+ checkType(errors, merged, "slow_request_ms", "number");
134
+ checkType(errors, merged, "retention_days", "number");
135
+ checkType(errors, merged, "max_file_mb", "number");
136
+ checkType(errors, merged, "flush_interval_ms", "number");
137
+ checkType(errors, merged, "batch_size", "number");
138
+ checkType(errors, merged, "max_queue_events", "number");
139
+ checkType(errors, merged, "price_catalog", "string");
140
+ checkType(errors, merged, "sample_rate", "number");
141
+ checkType(errors, merged, "capture_metadata", "string");
142
+ checkType(errors, merged, "redact_rules", "array");
143
+
144
+ if (!["jsonl", "sqlite"].includes(merged.store)) {
145
+ errors.push(`store must be "jsonl" or "sqlite", got ${JSON.stringify(merged.store)}`);
146
+ merged.store = DEFAULT_CONFIG.store;
147
+ }
148
+ if (!["none", "safe"].includes(merged.capture_metadata)) {
149
+ errors.push(`capture_metadata must be "none" or "safe", got ${JSON.stringify(merged.capture_metadata)}`);
150
+ merged.capture_metadata = "none";
151
+ }
152
+ if (typeof merged.sample_rate === "number") {
153
+ if (!Number.isFinite(merged.sample_rate) || merged.sample_rate < 0 || merged.sample_rate > 1) {
154
+ errors.push(`sample_rate must be within [0,1], got ${merged.sample_rate}`);
155
+ merged.sample_rate = DEFAULT_CONFIG.sample_rate;
156
+ }
157
+ }
158
+ for (const key of ["slow_request_ms", "retention_days", "max_file_mb", "flush_interval_ms", "batch_size", "max_queue_events"]) {
159
+ if (typeof merged[key] === "number" && (!Number.isFinite(merged[key]) || merged[key] < 0)) {
160
+ errors.push(`${key} must be a non-negative number`);
161
+ merged[key] = DEFAULT_CONFIG[key];
162
+ }
163
+ }
164
+ // 硬预算上限(§7.2):配置超出时钳制并记录
165
+ merged.batch_size = Math.min(Math.round(merged.batch_size), BUDGET_LIMITS.max_batch_size);
166
+ merged.max_queue_events = Math.min(Math.round(merged.max_queue_events), BUDGET_LIMITS.max_queue_events);
167
+ merged.flush_interval_ms = Math.max(Math.round(merged.flush_interval_ms), BUDGET_LIMITS.min_flush_interval_ms);
168
+ if (Array.isArray(merged.redact_rules)) {
169
+ merged.redact_rules = merged.redact_rules
170
+ .filter((rule) => rule && typeof rule === "object" && typeof rule.name === "string" && typeof rule.pattern === "string")
171
+ .map((rule) => {
172
+ try {
173
+ return { name: rule.name, regex: new RegExp(rule.pattern) };
174
+ } catch {
175
+ errors.push(`redact_rules: invalid pattern for ${rule.name}`);
176
+ return null;
177
+ }
178
+ })
179
+ .filter(Boolean);
180
+ }
181
+
182
+ return { ok: errors.length === 0, errors, config: Object.freeze(merged) };
183
+ }
184
+
185
+ /** 读取 --config 指定的文件;文件不存在/损坏时按「关闭」这一安全默认处理。 */
186
+ export function loadConfigFile(path) {
187
+ let raw;
188
+ try {
189
+ raw = readFileSync(path, "utf8");
190
+ } catch (error) {
191
+ return { ok: false, errors: [`config file unreadable (${error.code ?? error.message})`], config: { ...DEFAULT_CONFIG, enabled: false } };
192
+ }
193
+ const result = resolveConfig({ file: raw ? path : null });
194
+ if (!result.ok && result.config) {
195
+ // 解析失败 → 关闭(安全默认),保留错误说明
196
+ return { ok: false, errors: result.errors, config: { ...result.config, enabled: false } };
197
+ }
198
+ return result;
199
+ }
package/src/cost.mjs ADDED
@@ -0,0 +1,118 @@
1
+ /**
2
+ * dsh-local-telemetry — 成本模型(计划 §5)。
3
+ *
4
+ * 价格不入代码:来自版本化目录文件(currency / effective_at / models)。
5
+ * 只有 模型名、Token 用量、价格 三者齐备才计算;否则 cost=null 并给出原因,
6
+ * 绝不编造(§5.2)。
7
+ */
8
+
9
+ import { readFileSync } from "node:fs";
10
+
11
+ /**
12
+ * 加载价格目录。
13
+ * @returns {{ ok: boolean, errors: string[], catalog: object|null }}
14
+ */
15
+ export function loadPriceCatalog(path) {
16
+ if (!path || typeof path !== "string") {
17
+ return { ok: false, errors: ["price_catalog not configured"], catalog: null };
18
+ }
19
+ let parsed;
20
+ try {
21
+ parsed = JSON.parse(readFileSync(path, "utf8"));
22
+ } catch (error) {
23
+ return { ok: false, errors: [`price catalog unreadable (${error.code ?? error.message})`], catalog: null };
24
+ }
25
+ const errors = [];
26
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
27
+ return { ok: false, errors: ["price catalog must be a JSON object"], catalog: null };
28
+ }
29
+ if (typeof parsed.currency !== "string" || parsed.currency.length !== 3) errors.push("currency must be a 3-letter code");
30
+ if (typeof parsed.effective_at !== "string" || parsed.effective_at.length === 0) errors.push("effective_at is required");
31
+ if (typeof parsed.models !== "object" || parsed.models === null) errors.push("models is required");
32
+ else {
33
+ for (const [name, price] of Object.entries(parsed.models)) {
34
+ if (typeof price !== "object" || price === null) {
35
+ errors.push(`models.${name} must be an object`);
36
+ continue;
37
+ }
38
+ for (const field of ["input_per_million", "output_per_million"]) {
39
+ const value = price[field];
40
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) errors.push(`models.${name}.${field} must be a non-negative number`);
41
+ }
42
+ if (price.cached_input_per_million !== undefined && price.cached_input_per_million !== null) {
43
+ const value = price.cached_input_per_million;
44
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) errors.push(`models.${name}.cached_input_per_million must be a non-negative number`);
45
+ }
46
+ }
47
+ }
48
+ if (errors.length > 0) return { ok: false, errors, catalog: null };
49
+ return { ok: true, errors, catalog: parsed };
50
+ }
51
+
52
+ function priceFor(catalog, modelName) {
53
+ if (!catalog || typeof modelName !== "string" || modelName.length === 0) return null;
54
+ if (Object.prototype.hasOwnProperty.call(catalog.models, modelName)) return catalog.models[modelName];
55
+ // 带前缀的模型名(如 provider/name)回退到末段匹配
56
+ const tail = modelName.split("/").pop();
57
+ if (tail !== modelName && Object.prototype.hasOwnProperty.call(catalog.models, tail)) return catalog.models[tail];
58
+ return null;
59
+ }
60
+
61
+ /**
62
+ * 计算单事件成本。
63
+ * @returns {{ amount: number|null, currency: string|null, effective_at: string|null, missing: string[] }}
64
+ * amount 为 null 时 missing 给出原因(model_missing / usage_missing / price_missing / catalog_missing)。
65
+ */
66
+ export function computeCost({ model, usage, catalog }) {
67
+ if (!catalog) {
68
+ return { amount: null, currency: null, effective_at: null, missing: ["catalog_missing"] };
69
+ }
70
+ const modelName = typeof model === "object" && model !== null ? model.name : null;
71
+ if (!modelName || typeof modelName !== "string") {
72
+ return { amount: null, currency: catalog.currency, effective_at: catalog.effective_at, missing: ["model_missing"] };
73
+ }
74
+ if (typeof usage !== "object" || usage === null) {
75
+ return { amount: null, currency: catalog.currency, effective_at: catalog.effective_at, missing: ["usage_missing"] };
76
+ }
77
+ const input = usage.input_tokens;
78
+ const output = usage.output_tokens;
79
+ if (!Number.isInteger(input) || !Number.isInteger(output)) {
80
+ return { amount: null, currency: catalog.currency, effective_at: catalog.effective_at, missing: ["usage_missing"] };
81
+ }
82
+ const price = priceFor(catalog, modelName);
83
+ if (!price) {
84
+ return { amount: null, currency: catalog.currency, effective_at: catalog.effective_at, missing: ["price_missing"] };
85
+ }
86
+ const cached = Number.isInteger(usage.cached_input_tokens) ? usage.cached_input_tokens : 0;
87
+ const cachedPrice = typeof price.cached_input_per_million === "number" ? price.cached_input_per_million : price.input_per_million;
88
+ const amount =
89
+ (input / 1_000_000) * price.input_per_million +
90
+ (cached / 1_000_000) * cachedPrice +
91
+ (output / 1_000_000) * price.output_per_million;
92
+ return {
93
+ amount: Math.round(amount * 1e9) / 1e9, // 去浮点噪声,保留纳级精度
94
+ currency: catalog.currency,
95
+ effective_at: catalog.effective_at,
96
+ missing: [],
97
+ };
98
+ }
99
+
100
+ /** 聚合级成本汇总:逐事件计算并合并,缺失原因计数;无已计价事件时 amount 为 null。 */
101
+ export function sumCosts(costResults) {
102
+ let amount = 0;
103
+ let pricedEvents = 0;
104
+ const missing = {};
105
+ for (const result of costResults) {
106
+ if (result.amount === null) {
107
+ for (const reason of result.missing) missing[reason] = (missing[reason] ?? 0) + 1;
108
+ } else {
109
+ amount += result.amount;
110
+ pricedEvents += 1;
111
+ }
112
+ }
113
+ return {
114
+ amount: pricedEvents > 0 ? Math.round(amount * 1e9) / 1e9 : null,
115
+ priced_events: pricedEvents,
116
+ missing,
117
+ };
118
+ }
package/src/index.mjs ADDED
@@ -0,0 +1,20 @@
1
+ /**
2
+ * dsh-local-telemetry — 公共库接口(exports 子路径 `dsh-local-telemetry/telemetry`)。
3
+ *
4
+ * 上层插件(dsh-change-impact / dsh-test-insight 等)与宿主集成代码从这里
5
+ * 引入;插件入口(plugin/index.js)只负责 skills 注册与关闭时 flush。
6
+ */
7
+
8
+ export { SCHEMA_VERSION, EVENT_NAMES, TERMINAL_EVENTS, validateEvent, serializeEvent, deserializeEvent, newId, nowIsoUtc, toIsoUtc, MAX_EVENT_BYTES } from "./schema.mjs";
9
+ export { DEFAULT_CONFIG, BUDGET_LIMITS, resolveConfig, loadConfigFile, parseDurationOrTimestamp, sinceUntilRange, resolveDataPath } from "./config.mjs";
10
+ export { createRedactor, hashName, getOrCreateSalt, defaultPrivacy, SECRET_FIELDS } from "./privacy.mjs";
11
+ export { createSampler, SAMPLING_STRATEGY } from "./sampling.mjs";
12
+ export { createEventBusAdapter, createCapabilities, detectCapabilities } from "./adapter.mjs";
13
+ export { createRecorder } from "./recorder.mjs";
14
+ export { JsonlSink, utcDateOf } from "./sink-jsonl.mjs";
15
+ export { createSqliteSink, sqliteAvailable, isSqliteSupported } from "./sink-sqlite.mjs";
16
+ export { openStore, createJsonlStore, createSqliteStore, matchesFilters, isFailureEvent } from "./store.mjs";
17
+ export { aggregateEvents, aggregateGrouped, pairSpans, slowTraceIds, buildTraceView, listRequestRows, percentile } from "./aggregate.mjs";
18
+ export { loadPriceCatalog, computeCost, sumCosts } from "./cost.mjs";
19
+ export { renderTextSummary, renderMarkdownReport, renderTraceText, renderGroupedText } from "./report.mjs";
20
+ export { createTelemetryServer } from "./server.mjs";
@@ -0,0 +1,208 @@
1
+ /**
2
+ * dsh-local-telemetry — 隐私与脱敏(计划 §6)。
3
+ *
4
+ * 原则:内容字段默认不存在(不是采集后再脱敏);仅当 capture_metadata="safe"
5
+ * 时才对可选 metadata 执行脱敏,且只保留规则名与命中数量,不保存原文。
6
+ * 名称哈希使用数据目录内随机 salt(SHA-256 截断),稳定但不可直接还原。
7
+ */
8
+
9
+ import { createHash, randomBytes } from "node:crypto";
10
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
11
+ import { join } from "node:path";
12
+
13
+ /** 内置敏感字段名(计划 §6.2)。metadata 的键命中即整体丢弃该键。 */
14
+ export const SECRET_FIELDS = Object.freeze([
15
+ "authorization",
16
+ "proxy-authorization",
17
+ "cookie",
18
+ "set-cookie",
19
+ "token",
20
+ "access_token",
21
+ "refresh_token",
22
+ "id_token",
23
+ "session_token",
24
+ "password",
25
+ "passwd",
26
+ "secret",
27
+ "client_secret",
28
+ "private_key",
29
+ "api_key",
30
+ "apikey",
31
+ "api-key",
32
+ "x-api-key",
33
+ ]);
34
+
35
+ const SECRET_FIELDS_RE = new RegExp(`^(${SECRET_FIELDS.map((f) => f.replace(/[-]/g, "\\-")).join("|")})$`, "i");
36
+
37
+ /** 字符串内嵌的 `字段名: 值` / `字段名=值` 形态(含 header、query 风格)。 */
38
+ const INLINE_SECRET_RE = new RegExp(
39
+ `\\b(${SECRET_FIELDS.map((f) => f.replace(/-/g, "[-_]?")).join("|")})\\b\\s*[:=]\\s*["']?([A-Za-z0-9._~+/=\\-]{4,})["']?`,
40
+ "gi"
41
+ );
42
+
43
+ const BEARER_RE = /\b(?:bearer|basic|token)\s+[A-Za-z0-9._~+/=\-]{6,}/gi;
44
+ /** URL userinfo 凭据:scheme://user:pass@host / scheme://token@host。 */
45
+ const URL_USERINFO_RE = /\b([a-z][a-z0-9+.-]*:\/\/)([^\s/@:]+)?:([^\s/@]+)@/gi;
46
+ const URL_USERINFO_TOKEN_RE = /\b([a-z][a-z0-9+.-]*:\/\/)(ghp_[A-Za-z0-9]+|[A-Za-z0-9]{20,})@/gi;
47
+ /** URL query 中的敏感参数值。 */
48
+ const URL_QUERY_SECRET_RE = /([?&#](?:token|access_token|refresh_token|api_key|apikey|secret|password|sig|signature|client_secret|session_id)=)[^\s&"#]+/gi;
49
+ /** 绝对路径:Windows 盘符路径与常见 Unix 前缀。 */
50
+ const ABSOLUTE_PATH_RE = /(?:[A-Za-z]:\\[^\s"',:;)]+(?:\\[^\s"',:;)]*)*)|(?:\/(?:home|Users|root|var|tmp|etc|opt|mnt|srv)\/[^\s"',:;)]+)/g;
51
+
52
+ /**
53
+ * 创建脱敏器。
54
+ * @param {object} opts
55
+ * @param {Array<{name:string, regex:RegExp}>} opts.customRules 用户自定义规则
56
+ * @param {"basename"|"hash"|null} [opts.absolutePaths] 绝对路径处理策略(默认 basename)
57
+ * @param {string} [opts.salt] 名称哈希 salt;未提供时 hashName 抛错前会给出明确提示
58
+ */
59
+ export function createRedactor({ customRules = [], absolutePaths = "basename", salt = null } = {}) {
60
+ const stats = new Map(); // ruleName -> count
61
+
62
+ function hit(rule) {
63
+ stats.set(rule, (stats.get(rule) ?? 0) + 1);
64
+ }
65
+
66
+ function redactAbsolutePath(value) {
67
+ return value.replace(ABSOLUTE_PATH_RE, (match) => {
68
+ hit("absolute_path");
69
+ if (absolutePaths === "hash" && salt) return hashName(match, salt);
70
+ const normalized = match.replace(/\\+/g, "/");
71
+ const base = normalized.split("/").filter(Boolean).pop() ?? match;
72
+ return base;
73
+ });
74
+ }
75
+
76
+ /** 对单个字符串执行全部内置 + 自定义规则。 */
77
+ function redactString(value) {
78
+ if (typeof value !== "string" || value.length === 0) return value;
79
+ let out = value;
80
+ for (const rule of customRules) {
81
+ out = out.replace(rule.regex, () => {
82
+ hit(rule.name);
83
+ return `[redacted:${rule.name}]`;
84
+ });
85
+ }
86
+ out = out.replace(URL_USERINFO_RE, (_m, scheme) => {
87
+ hit("url_credentials");
88
+ return `${scheme}[redacted:url_credentials]@`;
89
+ });
90
+ out = out.replace(URL_USERINFO_TOKEN_RE, (_m, scheme) => {
91
+ hit("url_credentials");
92
+ return `${scheme}[redacted:url_credentials]@`;
93
+ });
94
+ out = out.replace(URL_QUERY_SECRET_RE, (_m, prefix) => {
95
+ hit("url_query_token");
96
+ return `${prefix}[redacted:url_query_token]`;
97
+ });
98
+ out = out.replace(BEARER_RE, () => {
99
+ hit("authorization");
100
+ return "[redacted:authorization]";
101
+ });
102
+ out = out.replace(INLINE_SECRET_RE, (_m, field) => {
103
+ const name = String(field).toLowerCase().replace(/[-_]?key$/, "_key").replace(/-/g, "_");
104
+ hit(name);
105
+ return `${field}=[redacted:${name}]`;
106
+ });
107
+ if (absolutePaths) out = redactAbsolutePath(out);
108
+ return out;
109
+ }
110
+
111
+ /**
112
+ * 对可选 metadata 对象脱敏:敏感键整体丢弃;字符串值走 redactString;
113
+ * 数字/布尔保留(大小、计数类安全信息)。返回 { value, redactions, rules }。
114
+ */
115
+ function redactMetadata(metadata) {
116
+ if (metadata === null || metadata === undefined) return { value: null, redactions: 0, rules: [] };
117
+ if (typeof metadata !== "object" || Array.isArray(metadata)) {
118
+ return { value: null, redactions: 0, rules: [] };
119
+ }
120
+ const before = new Map(stats);
121
+ const out = {};
122
+ for (const [key, value] of Object.entries(metadata)) {
123
+ if (SECRET_FIELDS_RE.test(key)) {
124
+ hit(key.toLowerCase().replace(/-/g, "_"));
125
+ continue; // 整键丢弃,不保留占位值
126
+ }
127
+ if (typeof value === "string") {
128
+ out[key] = redactString(value);
129
+ } else if (typeof value === "number" || typeof value === "boolean" || value === null) {
130
+ out[key] = value;
131
+ } else {
132
+ // 嵌套结构不递归展开:只保留安全原语,防内容夹带
133
+ hit("nested_metadata_dropped");
134
+ }
135
+ }
136
+ // 仅统计本次调用命中的规则
137
+ const rules = [];
138
+ let redactions = 0;
139
+ for (const [name, count] of stats.entries()) {
140
+ const delta = count - (before.get(name) ?? 0);
141
+ if (delta > 0) {
142
+ redactions += delta;
143
+ rules.push(name);
144
+ }
145
+ }
146
+ return { value: out, redactions, rules };
147
+ }
148
+
149
+ function totalHits() {
150
+ let sum = 0;
151
+ for (const count of stats.values()) sum += count;
152
+ return sum;
153
+ }
154
+
155
+ function summarize(before, value) {
156
+ const redactions = totalHits() - before;
157
+ const rules = [];
158
+ for (const [name, count] of stats.entries()) {
159
+ if (count > 0) rules.push(name);
160
+ }
161
+ return { value, redactions, rules };
162
+ }
163
+
164
+ /** 汇总自创建以来的命中(用于 privacy 块)。 */
165
+ function summary() {
166
+ return { redactions: totalHits(), rules: [...new Set(stats.keys())].filter((k) => (stats.get(k) ?? 0) > 0) };
167
+ }
168
+
169
+ function statsSnapshot() {
170
+ return Object.fromEntries(stats.entries());
171
+ }
172
+
173
+ return { redactString, redactMetadata, summary, statsSnapshot };
174
+ }
175
+
176
+ /**
177
+ * 名称哈希:SHA-256(salt + name) 截断 16 hex,加 `h:` 前缀。
178
+ * 稳定(同 salt 同名同结果),不可直接还原。
179
+ */
180
+ export function hashName(name, salt) {
181
+ if (typeof name !== "string" || name.length === 0) return name;
182
+ return `h:${createHash("sha256").update(`${salt}\u0000${name}`).digest("hex").slice(0, 16)}`;
183
+ }
184
+
185
+ /**
186
+ * 获取或创建数据目录内的哈希 salt(`.salt` 文件,32 hex)。
187
+ * 目录不存在时惰性创建。创建失败返回进程内临时 salt(稳定性降级,可用性优先)。
188
+ */
189
+ export function getOrCreateSalt(dataDir) {
190
+ try {
191
+ if (!existsSync(dataDir)) mkdirSync(dataDir, { recursive: true });
192
+ const saltPath = join(dataDir, ".salt");
193
+ if (existsSync(saltPath)) {
194
+ const existing = readFileSync(saltPath, "utf8").trim();
195
+ if (/^[0-9a-f]{32}$/.test(existing)) return existing;
196
+ }
197
+ const salt = randomBytes(16).toString("hex");
198
+ writeFileSync(saltPath, `${salt}\n`, { mode: 0o600 });
199
+ return salt;
200
+ } catch {
201
+ return randomBytes(16).toString("hex");
202
+ }
203
+ }
204
+
205
+ /** 事件级 privacy 块默认值:内容未采集,无脱敏命中。 */
206
+ export function defaultPrivacy() {
207
+ return { content_captured: false, redactions: 0, rules: [] };
208
+ }