oh-my-knowledge 0.42.0 → 0.44.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 (47) hide show
  1. package/README.md +1 -1
  2. package/README.zh.md +1 -1
  3. package/dist/artifact-graph/doctor.d.ts +21 -0
  4. package/dist/artifact-graph/doctor.js +569 -0
  5. package/dist/artifact-graph/eval.d.ts +16 -0
  6. package/dist/artifact-graph/eval.js +351 -0
  7. package/dist/assets/agent-skills/omk/SKILL.md +9 -9
  8. package/dist/assets/agent-skills/omk/references/commands.md +1 -1
  9. package/dist/cli/commands/doctor.js +62 -61
  10. package/dist/cli/commands/eval/index.js +20 -2
  11. package/dist/cli/commands/init.js +1 -0
  12. package/dist/cli/commands/observe/index.d.ts +2 -2
  13. package/dist/cli/commands/observe/index.js +8 -7
  14. package/dist/cli/commands/sample.js +22 -16
  15. package/dist/cli/lib/i18n-dict/common.d.ts +1 -1
  16. package/dist/cli/lib/i18n-dict/common.js +8 -0
  17. package/dist/cli/lib/i18n-dict/help.js +12 -10
  18. package/dist/cli/lib/parse-run-config/samples-discovery.d.ts +5 -7
  19. package/dist/cli/lib/parse-run-config/samples-discovery.js +10 -32
  20. package/dist/cli/lib/parse-run-config.js +4 -4
  21. package/dist/cli/lib/resolve-skill-input.js +10 -12
  22. package/dist/doctor/index.js +2 -7
  23. package/dist/doctor/messages.js +2 -2
  24. package/dist/eval-core/artifact-file-names.d.ts +15 -0
  25. package/dist/eval-core/artifact-file-names.js +45 -0
  26. package/dist/eval-core/evaluation-reporting.d.ts +1 -1
  27. package/dist/eval-core/evaluation-reporting.js +32 -12
  28. package/dist/eval-core/measurement-dirs.js +13 -7
  29. package/dist/eval-core/report-file-migration.d.ts +10 -0
  30. package/dist/eval-core/report-file-migration.js +90 -0
  31. package/dist/inputs/sample-locator.d.ts +23 -0
  32. package/dist/inputs/sample-locator.js +195 -0
  33. package/dist/inputs/skill-loader.js +7 -17
  34. package/dist/observability/inbox.js +7 -3
  35. package/dist/renderer/html-renderer.js +3 -2
  36. package/dist/renderer/skill-detail-renderer.js +832 -0
  37. package/dist/renderer/skill-list-renderer.js +2 -7
  38. package/dist/server/report-server.js +55 -15
  39. package/dist/server/report-store.js +17 -9
  40. package/dist/server/skill-index.d.ts +6 -0
  41. package/dist/server/skill-index.js +399 -15
  42. package/dist/types/artifact-graph.d.ts +93 -0
  43. package/dist/types/artifact-graph.js +1 -0
  44. package/dist/types/index.d.ts +1 -0
  45. package/dist/types/index.js +1 -0
  46. package/dist/types/skill-index.d.ts +38 -0
  47. package/package.json +1 -1
@@ -0,0 +1,90 @@
1
+ import { existsSync, readdirSync, readFileSync, renameSync, unlinkSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { doctorReportFileStem, isReportFileName, reportFilePath, stripDomainPrefix } from './artifact-file-names.js';
4
+ function legacyJsonStem(fileName) {
5
+ if (!fileName.endsWith('.json'))
6
+ return null;
7
+ if (isReportFileName(fileName))
8
+ return null;
9
+ if (fileName.includes('.json.tmp.'))
10
+ return null;
11
+ return fileName.slice(0, -'.json'.length);
12
+ }
13
+ function isObject(value) {
14
+ return !!value && typeof value === 'object' && !Array.isArray(value);
15
+ }
16
+ function looksLikeObserveHealthReport(value) {
17
+ return isObject(value.meta) && isObject(value.bySkill) && isObject(value.overall);
18
+ }
19
+ function targetStemForLegacyReport(domain, fileName, data) {
20
+ const fileStem = legacyJsonStem(fileName);
21
+ if (!fileStem || !isObject(data))
22
+ return null;
23
+ if (domain === 'report') {
24
+ const kind = data.kind;
25
+ return (kind === 'evaluation' || kind === 'batch-evaluation') && typeof data.id === 'string' && data.id
26
+ ? data.id
27
+ : null;
28
+ }
29
+ if (domain === 'doctor') {
30
+ if (data.kind !== 'doctor' || typeof data.id !== 'string' || !Array.isArray(data.skills) || data.skills.length !== 1)
31
+ return null;
32
+ const skill = data.skills[0];
33
+ return isObject(skill) && typeof skill.skillName === 'string' && skill.skillName
34
+ ? doctorReportFileStem(skill.skillName, data.id)
35
+ : null;
36
+ }
37
+ if (domain === 'observe-health') {
38
+ if (data.kind !== 'observe-health' && !looksLikeObserveHealthReport(data))
39
+ return null;
40
+ return stripDomainPrefix(fileStem.replace(/-observe-health$/, ''), 'observe-health');
41
+ }
42
+ if (data.kind !== 'observe-inbox')
43
+ return null;
44
+ return stripDomainPrefix(fileStem.replace(/-observe-inbox$/, ''), 'observe-inbox');
45
+ }
46
+ /**
47
+ * One-shot migration for pre-`.report.json` run artifacts.
48
+ *
49
+ * This is deliberately scoped to measurement report directories. It does not make
50
+ * bare `.json` a normal reader format again; it just renames known legacy report
51
+ * files into the canonical name so old local data can be discovered, rotated, and
52
+ * eventually garbage-collected by the new code paths.
53
+ */
54
+ export function migrateLegacyReportFiles(dir, domain) {
55
+ if (!existsSync(dir))
56
+ return;
57
+ let files;
58
+ try {
59
+ files = readdirSync(dir);
60
+ }
61
+ catch {
62
+ return;
63
+ }
64
+ for (const file of files) {
65
+ if (!legacyJsonStem(file))
66
+ continue;
67
+ const sourcePath = join(dir, file);
68
+ let targetStem = null;
69
+ try {
70
+ targetStem = targetStemForLegacyReport(domain, file, JSON.parse(readFileSync(sourcePath, 'utf-8')));
71
+ }
72
+ catch {
73
+ continue;
74
+ }
75
+ if (!targetStem)
76
+ continue;
77
+ const targetPath = reportFilePath(dir, targetStem);
78
+ try {
79
+ if (existsSync(targetPath)) {
80
+ unlinkSync(sourcePath);
81
+ }
82
+ else {
83
+ renameSync(sourcePath, targetPath);
84
+ }
85
+ }
86
+ catch {
87
+ // Best-effort migration only. Readers will ignore the legacy file if it remains.
88
+ }
89
+ }
90
+ }
@@ -0,0 +1,23 @@
1
+ export declare const PROJECT_SAMPLE_FILENAMES: readonly ["eval-samples.json", "eval-samples.yaml", "eval-samples.yml"];
2
+ export declare const SKILL_LOCAL_SAMPLE_FILENAMES: readonly ["samples.json", "samples.yaml", "samples.yml"];
3
+ export interface DeprecatedSkillSamplesHint {
4
+ oldPath: string;
5
+ newPath: string;
6
+ }
7
+ export declare function isExistingDirectory(path: string): boolean;
8
+ export declare function hasLoadableSampleFile(dir: string): boolean;
9
+ export declare function findProjectSamplesFile(dir: string): string | null;
10
+ export declare function skillLocalSamplesDir(skillRoot: string): string;
11
+ export declare function defaultSkillLocalSamplesFile(skillRoot: string): string;
12
+ export declare function hasUsableSamplesPath(path: string): boolean;
13
+ export declare function findSkillLocalSamplesDir(skillRoot: string): string | null;
14
+ export declare function findSkillSamplesPath(skillRoot: string): string | null;
15
+ export declare function findDeprecatedSkillSamplesHint(skillRoot: string): DeprecatedSkillSamplesHint | null;
16
+ export declare function findFlatSkillSamplesPath(skillDir: string, skillName: string): string | null;
17
+ export declare function defaultFlatSkillSamplesFile(skillDir: string, skillName: string): string;
18
+ export declare function isDirectorySkillRoot(path: string): boolean;
19
+ export declare function findNamedSkillSamplesPath(skillDir: string, skillName: string): string | null;
20
+ export declare function findSingleTreatmentSamplesPath(treatmentExpr: string, skillDir: string, cwd?: string): string | null;
21
+ export declare function findSingleTreatmentDeprecatedSamplesHint(treatmentExpr: string, skillDir: string, cwd?: string): DeprecatedSkillSamplesHint | null;
22
+ export declare function findDoctorSamplesPath(target: string | null, cwd: string): string | null;
23
+ export declare function findDoctorDeprecatedSamplesHint(target: string | null, cwd: string): DeprecatedSkillSamplesHint | null;
@@ -0,0 +1,195 @@
1
+ import { existsSync, readdirSync, statSync } from 'node:fs';
2
+ import { basename, dirname, join, resolve } from 'node:path';
3
+ export const PROJECT_SAMPLE_FILENAMES = ['eval-samples.json', 'eval-samples.yaml', 'eval-samples.yml'];
4
+ export const SKILL_LOCAL_SAMPLE_FILENAMES = ['samples.json', 'samples.yaml', 'samples.yml'];
5
+ const SAMPLE_FILE_RE = /\.(json|ya?ml)$/i;
6
+ const RESERVED_SAMPLE_FILE_RE = /^(report|health|_)/i;
7
+ function isExistingFile(path) {
8
+ try {
9
+ return existsSync(path) && statSync(path).isFile();
10
+ }
11
+ catch {
12
+ return false;
13
+ }
14
+ }
15
+ export function isExistingDirectory(path) {
16
+ try {
17
+ return existsSync(path) && statSync(path).isDirectory();
18
+ }
19
+ catch {
20
+ return false;
21
+ }
22
+ }
23
+ export function hasLoadableSampleFile(dir) {
24
+ if (!isExistingDirectory(dir))
25
+ return false;
26
+ try {
27
+ return readdirSync(dir).some((file) => SAMPLE_FILE_RE.test(file) && !RESERVED_SAMPLE_FILE_RE.test(file));
28
+ }
29
+ catch {
30
+ return false;
31
+ }
32
+ }
33
+ export function findProjectSamplesFile(dir) {
34
+ for (const name of PROJECT_SAMPLE_FILENAMES) {
35
+ const candidate = join(dir, name);
36
+ if (isExistingFile(candidate))
37
+ return candidate;
38
+ }
39
+ return null;
40
+ }
41
+ export function skillLocalSamplesDir(skillRoot) {
42
+ return join(skillRoot, '.omk');
43
+ }
44
+ export function defaultSkillLocalSamplesFile(skillRoot) {
45
+ return join(skillLocalSamplesDir(skillRoot), SKILL_LOCAL_SAMPLE_FILENAMES[0]);
46
+ }
47
+ export function hasUsableSamplesPath(path) {
48
+ if (isExistingFile(path))
49
+ return true;
50
+ return hasLoadableSampleFile(path);
51
+ }
52
+ export function findSkillLocalSamplesDir(skillRoot) {
53
+ const dir = skillLocalSamplesDir(skillRoot);
54
+ return hasLoadableSampleFile(dir) ? dir : null;
55
+ }
56
+ export function findSkillSamplesPath(skillRoot) {
57
+ return findSkillLocalSamplesDir(skillRoot);
58
+ }
59
+ export function findDeprecatedSkillSamplesHint(skillRoot) {
60
+ if (!isDirectorySkillRoot(skillRoot))
61
+ return null;
62
+ for (const name of PROJECT_SAMPLE_FILENAMES) {
63
+ const oldPath = join(skillRoot, name);
64
+ if (isExistingFile(oldPath)) {
65
+ return { oldPath, newPath: defaultSkillLocalSamplesFile(skillRoot) };
66
+ }
67
+ }
68
+ return null;
69
+ }
70
+ export function findFlatSkillSamplesPath(skillDir, skillName) {
71
+ for (const ext of ['json', 'yaml', 'yml']) {
72
+ const candidate = join(skillDir, `${skillName}.eval-samples.${ext}`);
73
+ if (isExistingFile(candidate))
74
+ return candidate;
75
+ }
76
+ return null;
77
+ }
78
+ export function defaultFlatSkillSamplesFile(skillDir, skillName) {
79
+ return join(skillDir, `${skillName}.eval-samples.json`);
80
+ }
81
+ export function isDirectorySkillRoot(path) {
82
+ return isExistingDirectory(path) && isExistingFile(join(path, 'SKILL.md'));
83
+ }
84
+ export function findNamedSkillSamplesPath(skillDir, skillName) {
85
+ const flatSkillPath = join(skillDir, `${skillName}.md`);
86
+ if (isExistingFile(flatSkillPath)) {
87
+ return findFlatSkillSamplesPath(skillDir, skillName);
88
+ }
89
+ const dirSkillRoot = join(skillDir, skillName);
90
+ if (isDirectorySkillRoot(dirSkillRoot)) {
91
+ return findSkillSamplesPath(dirSkillRoot);
92
+ }
93
+ return null;
94
+ }
95
+ function findSamplesForExistingSkillPath(path) {
96
+ if (isExistingDirectory(path)) {
97
+ return findSkillSamplesPath(path);
98
+ }
99
+ const parent = dirname(path);
100
+ if (basename(path) === 'SKILL.md') {
101
+ return findSkillSamplesPath(parent);
102
+ }
103
+ if (/\.md$/i.test(path)) {
104
+ const skillName = basename(path).replace(/\.md$/i, '');
105
+ return findFlatSkillSamplesPath(parent, skillName);
106
+ }
107
+ return findProjectSamplesFile(parent);
108
+ }
109
+ function findDeprecatedSamplesForExistingSkillPath(path) {
110
+ if (isExistingDirectory(path)) {
111
+ return findDeprecatedSkillSamplesHint(path);
112
+ }
113
+ if (basename(path) === 'SKILL.md') {
114
+ return findDeprecatedSkillSamplesHint(dirname(path));
115
+ }
116
+ return null;
117
+ }
118
+ export function findSingleTreatmentSamplesPath(treatmentExpr, skillDir, cwd = process.cwd()) {
119
+ const resolved = resolve(cwd, treatmentExpr);
120
+ if (existsSync(resolved)) {
121
+ const samplesPath = findSamplesForExistingSkillPath(resolved);
122
+ if (samplesPath)
123
+ return samplesPath;
124
+ }
125
+ return findNamedSkillSamplesPath(skillDir, treatmentExpr);
126
+ }
127
+ export function findSingleTreatmentDeprecatedSamplesHint(treatmentExpr, skillDir, cwd = process.cwd()) {
128
+ const resolved = resolve(cwd, treatmentExpr);
129
+ if (existsSync(resolved)) {
130
+ return findDeprecatedSamplesForExistingSkillPath(resolved);
131
+ }
132
+ const flatSkillPath = join(skillDir, `${treatmentExpr}.md`);
133
+ if (isExistingFile(flatSkillPath))
134
+ return null;
135
+ return findDeprecatedSkillSamplesHint(join(skillDir, treatmentExpr));
136
+ }
137
+ function projectSampleSearchDirs(target, cwd) {
138
+ const dirs = [];
139
+ const add = (dir) => {
140
+ const abs = resolve(dir);
141
+ if (!dirs.includes(abs))
142
+ dirs.push(abs);
143
+ };
144
+ if (target) {
145
+ const absTarget = resolve(target);
146
+ if (existsSync(absTarget)) {
147
+ if (isExistingDirectory(absTarget)) {
148
+ if (isDirectorySkillRoot(absTarget)) {
149
+ add(dirname(dirname(absTarget)));
150
+ }
151
+ else {
152
+ add(absTarget);
153
+ add(dirname(absTarget));
154
+ add(dirname(dirname(absTarget)));
155
+ }
156
+ }
157
+ else {
158
+ const parent = dirname(absTarget);
159
+ if (basename(absTarget) === 'SKILL.md') {
160
+ add(dirname(dirname(parent)));
161
+ }
162
+ else {
163
+ add(parent);
164
+ add(dirname(parent));
165
+ }
166
+ }
167
+ }
168
+ }
169
+ add(cwd);
170
+ return dirs;
171
+ }
172
+ export function findDoctorSamplesPath(target, cwd) {
173
+ if (target) {
174
+ const absTarget = resolve(target);
175
+ if (existsSync(absTarget)) {
176
+ const targetSamples = findSamplesForExistingSkillPath(absTarget);
177
+ if (targetSamples)
178
+ return targetSamples;
179
+ }
180
+ }
181
+ for (const dir of projectSampleSearchDirs(target, cwd)) {
182
+ const samplesPath = findProjectSamplesFile(dir);
183
+ if (samplesPath)
184
+ return samplesPath;
185
+ }
186
+ return null;
187
+ }
188
+ export function findDoctorDeprecatedSamplesHint(target, cwd) {
189
+ if (!target)
190
+ return null;
191
+ const absTarget = resolve(cwd, target);
192
+ if (!existsSync(absTarget))
193
+ return null;
194
+ return findDeprecatedSamplesForExistingSkillPath(absTarget);
195
+ }
@@ -5,6 +5,7 @@ import { execFileSync } from 'node:child_process';
5
5
  import { extractSkillHardRules, extractSkillWorkflows } from '../shared/hard-rules.js';
6
6
  import { hashArtifactSource, hashBytes, isDistributablePath } from './content-hash.js';
7
7
  import { materializeIsolatedCopy } from './materialize-copy.js';
8
+ import { findFlatSkillSamplesPath, findSkillSamplesPath } from './sample-locator.js';
8
9
  function parseFrontmatterPreflight(content) {
9
10
  const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
10
11
  if (!match)
@@ -388,14 +389,9 @@ export function discoverBatchSkills(skillDir) {
388
389
  const mdMatch = entry.endsWith('.md') && !entry.endsWith('.eval-samples.json');
389
390
  if (mdMatch) {
390
391
  const name = entry.slice(0, -3);
391
- const candidates = [
392
- join(skillDir, name, '.omk'),
393
- join(skillDir, `${name}.eval-samples.json`),
394
- join(skillDir, `${name}.eval-samples.yaml`),
395
- join(skillDir, `${name}.eval-samples.yml`),
396
- ].filter(existsSync);
397
- if (candidates.length > 0) {
398
- skills.push({ name, skillPath: join(skillDir, entry), samplesPath: candidates[0] });
392
+ const samplesPath = findFlatSkillSamplesPath(skillDir, name);
393
+ if (samplesPath) {
394
+ skills.push({ name, skillPath: join(skillDir, entry), samplesPath });
399
395
  }
400
396
  else {
401
397
  warned.push(name);
@@ -406,15 +402,9 @@ export function discoverBatchSkills(skillDir) {
406
402
  const skillMd = join(entryPath, 'SKILL.md');
407
403
  if (!existsSync(skillMd))
408
404
  continue;
409
- // .omk/ dir (loadSamples handles dir mode) > .omk/samples.json > eval-samples.{json,yaml,yml}
410
- const omkDir = join(entryPath, '.omk');
411
- const candidates = [
412
- ...(existsSync(omkDir) ? [omkDir] : []),
413
- join(entryPath, 'eval-samples.json'),
414
- join(entryPath, 'eval-samples.yaml'),
415
- join(entryPath, 'eval-samples.yml'),
416
- ];
417
- const samplesPath = candidates.find(existsSync);
405
+ if (existsSync(join(skillDir, `${entry}.md`)))
406
+ continue;
407
+ const samplesPath = findSkillSamplesPath(entryPath);
418
408
  if (samplesPath) {
419
409
  skills.push({ name: entry, skillPath: skillMd, samplesPath });
420
410
  }
@@ -2,13 +2,15 @@ import { createHash } from 'node:crypto';
2
2
  import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { OMK_HOME } from '../eval-core/default-dirs.js';
5
+ import { isReportFileName, reportFilePath } from '../eval-core/artifact-file-names.js';
6
+ import { migrateLegacyReportFiles } from '../eval-core/report-file-migration.js';
5
7
  import { extractGapSignalsFromTrace } from '../analysis/gap-analyzer.js';
6
8
  import { ccTracesToResultEntries } from './trace-adapter.js';
7
9
  import { isSearchToolCall, toolCallQuery } from '../shared/tool-search.js';
8
10
  import { durationMsBetween } from '../shared/time.js';
9
11
  import { buildObservationExperienceReport, normalizeObservationExperienceReport, } from './experience.js';
10
12
  // observe inbox(观测收件箱)产物根目录。导出名沿用 *_OBSERVATIONS_DIR 以少动 importer,
11
- // 但落盘目录已统一到 observe-inbox 词根(命令 omk observe inbox / 文件 *-observe-inbox.json / kind observe-inbox)。
13
+ // 但落盘目录已统一到 observe-inbox 词根(命令 omk observe inbox / kind observe-inbox)。
12
14
  // 项目级 .omk/observe-inbox 优先、全局兜底 —— 这套 project/global 归属是既有正常行为,本次只改名不改归属。
13
15
  export const DEFAULT_PROJECT_OBSERVATIONS_DIR = join(process.cwd(), '.omk', 'observe-inbox');
14
16
  export const DEFAULT_GLOBAL_OBSERVATIONS_DIR = join(OMK_HOME, 'observe-inbox');
@@ -529,10 +531,11 @@ function compareInboxItems(a, b) {
529
531
  }
530
532
  export function saveObservationInboxReport(report, outDir = DEFAULT_OBSERVATIONS_DIR) {
531
533
  mkdirSync(outDir, { recursive: true });
534
+ migrateLegacyReportFiles(outDir, 'observe-inbox');
532
535
  // 保留毫秒;同秒不同毫秒生成的两份 report 不应静默互相覆盖。
533
536
  // 例: '2026-05-07T12:00:00.999Z' → '2026-05-07T12-00-00-999'
534
537
  const stamp = report.meta.generatedAt.replace(/[:.]/g, '-').replace(/Z$/, '');
535
- const path = join(outDir, `${stamp}-observe-inbox.json`);
538
+ const path = reportFilePath(outDir, stamp);
536
539
  writeFileSync(path, JSON.stringify(report, null, 2));
537
540
  return path;
538
541
  }
@@ -543,8 +546,9 @@ export function loadObservationInboxReports(dir = DEFAULT_OBSERVATIONS_DIR) {
543
546
  }
544
547
  return [];
545
548
  }
549
+ migrateLegacyReportFiles(dir, 'observe-inbox');
546
550
  return readdirSync(dir)
547
- .filter((file) => file.endsWith('-observe-inbox.json'))
551
+ .filter(isReportFileName)
548
552
  .map((file) => {
549
553
  try {
550
554
  const report = normalizeObservationInboxReport(JSON.parse(readFileSync(join(dir, file), 'utf-8')));
@@ -149,9 +149,10 @@ export function renderRunList(runs, lang = DEFAULT_LANG) {
149
149
  // 顶部 verdict pill 主导视觉,id+date 是身份,scores 是成绩,底部 meta 是元数据,
150
150
  // delete 默认隐藏在 hover 出现避免误点。批量报告共用同一组件,batch pill 替代 verdict。
151
151
  const formatDateFromId = (id, fallbackTs) => {
152
- // 兼容两代 run id 后缀:旧 `…YYYYMMDD-HHmm`、新 `…YYYYMMDD-HHmmss-rand4`(含秒+随机)。
152
+ // 兼容三代 run id 后缀:旧 `…YYYYMMDD-HHmm`、过渡期 `…YYYYMMDD-HHmmss-rand4`、
153
+ // 统一后 `…YYYYMMDDTHHmmss-rand4`(含秒+随机)。
153
154
  // 都从 id 直接派生本地展示时间(确定性,不走时区敏感的 toLocaleString);新增的秒段 + 随机后缀可选匹配。
154
- const idMatch = id.match(/(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(?:\d{2}-[a-z0-9]+)?$/);
155
+ const idMatch = id.match(/(\d{4})(\d{2})(\d{2})(?:T|-)(\d{2})(\d{2})(?:\d{2})?(?:-[a-z0-9]+)?$/);
155
156
  if (idMatch)
156
157
  return `${idMatch[2]}/${idMatch[3]} ${idMatch[4]}:${idMatch[5]}`;
157
158
  return fallbackTs ? new Date(fallbackTs).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : '';