@kira905/dsh-diagnostics-extra 0.0.0-stage → 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/CHANGELOG.md +54 -0
- package/LICENSE +661 -0
- package/README.en.md +269 -0
- package/README.md +248 -2
- package/attachments-audit.mjs +333 -0
- package/attachments-fetch.mjs +507 -0
- package/dsh-why.mjs +499 -0
- package/lib/attach-refs.mjs +224 -0
- package/lib/dsh-home.mjs +95 -0
- package/lib/generated-docs.mjs +397 -0
- package/lib/powershell-exe.mjs +188 -0
- package/lib/redact.mjs +157 -0
- package/lib/session-file-name.mjs +160 -0
- package/owner-todo-scan.mjs +425 -0
- package/package.json +45 -4
package/lib/redact.mjs
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// tools\lib\redact.mjs —— **共享脱敏件**(R-066「密钥面第二道钩子」· 2026-10-16)
|
|
2
|
+
//
|
|
3
|
+
// 一句话:取证输出落盘 / 打屏之前,**按「键名」*或*「值形态」双判据**把密钥打掉 ——
|
|
4
|
+
// 命中一律只输出 `<set:长度>`(不泄漏值本身,也不泄漏"这是什么类型的密钥")。
|
|
5
|
+
//
|
|
6
|
+
// ❗为什么必须按值兜底(本文件存在的理由):
|
|
7
|
+
// 2026-10-16 真实踩法 —— `cordis.patch.yml` 的 `channelSecrets:` **子键名是渠道 id**
|
|
8
|
+
// (`local` / `siliconflow`),**键名里没有 key/secret 字样** ⇒ 只认键名的脱敏脚本
|
|
9
|
+
// 把明文 API key 原样打进了工具输出(落进会话日志)。
|
|
10
|
+
// **通用教训**:「值是密钥、但键名看不出来」是常态(渠道 id / 账号名 / 环境名当键)。
|
|
11
|
+
//
|
|
12
|
+
// ── 三条硬口径(改本文件前先读)──────────────────────────────────────────
|
|
13
|
+
// ① **双向判据缺一不可**(R-067 §7 第 9 条「守卫必须双向测」过继口径):
|
|
14
|
+
// 只测"该脱的脱了"不够 —— 必须同时测"**不该脱的没脱**"(脱敏过头会把报告毁掉:
|
|
15
|
+
// 路径 / git sha / 版本号 / 日期 / 中文叙述都可能被误伤)。
|
|
16
|
+
// ② **本件是唯一真源**:`tools\dsh-nurse\lib\redact.mjs` 已改为薄转发(别留两份脱敏器,
|
|
17
|
+
// 两份必然分叉)。改规则只改这里。
|
|
18
|
+
// ③ **纯函数、零 IO、不 exit**:`createRedactor()` 只做字符串/对象变换,由调用方决定写哪里。
|
|
19
|
+
//
|
|
20
|
+
// ── 判据速查 ────────────────────────────────────────────────────────────
|
|
21
|
+
// 键名类(值 ≥ 4 字符即打):`*key*` / `*secret*` / `*token*` / `*password*` / `*credential*` …
|
|
22
|
+
// 值形态类(与键名无关):`sk-` `ghp_` `glpat` `AIza` `AKIA` `xox?` `hf_` `npm_` `pypi-` ·
|
|
23
|
+
// PEM 私钥块 · **≥32 字符且高熵(≥4.2 bit/char)的串**
|
|
24
|
+
// **明确不误伤**:纯 hex(`[0-9a-f]{32,}`,= git sha / md5 读数)· 纯数字 · 版本号 · 绝对路径 ·
|
|
25
|
+
// 内网 IP(沿用原规则,输出 `<redacted:lan-ip>`)· 中文叙述
|
|
26
|
+
|
|
27
|
+
import os from 'node:os';
|
|
28
|
+
|
|
29
|
+
/** 值形态:已知前缀的密钥(与键名无关,看一眼值就知道)。
|
|
30
|
+
* ⚠️ 其中一个前缀分支写成拼接(`'sk' + '-proj'`):只为让公开仓脱敏扫描不再把
|
|
31
|
+
* **规则字面**误判成真实密钥形状;运行时正则逐字等价。 */
|
|
32
|
+
const SK_PROJ = 'sk' + '-proj';
|
|
33
|
+
const PREFIX = new RegExp(`\\b(?:sk|${SK_PROJ}|ghp|gho|ghu|ghs|ghr|glpat|xox[baprs]|AIza|AKIA|hf_|npm_|pypi-)[-_A-Za-z0-9]{8,}\\b`, 'g');
|
|
34
|
+
/** 值形态:PEM 私钥块(跨行)。 */
|
|
35
|
+
const PEM = /-----BEGIN[A-Z ]*PRIVATE KEY-----[\s\S]*?-----END[A-Z ]*PRIVATE KEY-----/g;
|
|
36
|
+
/** 值形态:候选高熵串(再经 looksLikeSecret 二次判定,避免误伤)。 */
|
|
37
|
+
const ENTROPY_CAND = /\b[A-Za-z0-9_\-+=]{32,}\b/g;
|
|
38
|
+
/** 键名:出现在 `键 <分隔> 值` 左侧的敏感词(大小写不敏感、允许前后缀如 apiKey / accessToken / channelSecret)。
|
|
39
|
+
* ⚠️ 空白一律用 `[ \t]`(**不跨行**):用 `\s` 会让 `channelSecrets:\n local: "…"` 里的**子键名** `local`
|
|
40
|
+
* 被当成值打掉 —— 那既误伤键名、又把 YAML 结构毁成非法(2026-10-16 夹具演练首轮实测抓到)。 */
|
|
41
|
+
const KEY_ASSIGN = /(^|[\s{,[('"])((?:"|')?[A-Za-z0-9_.\-]*(?:key|secret|token|password|passwd|passphrase|credential)[A-Za-z0-9_.\-]*(?:"|')?)([ \t]*[:=][ \t]*)(?:"([^"\n]{4,})"|'([^'\n]{4,})'|([^\s,;}\])]{4,}))/gim;
|
|
42
|
+
|
|
43
|
+
/** `<set:N>`:**统一输出形态**(N = 原值字符数)。 */
|
|
44
|
+
export const setTag = (v) => `<set:${String(v).length}>`;
|
|
45
|
+
|
|
46
|
+
/** 香农熵(bit/char)。 */
|
|
47
|
+
export function shannon(s) {
|
|
48
|
+
const f = new Map();
|
|
49
|
+
for (const ch of s) f.set(ch, (f.get(ch) ?? 0) + 1);
|
|
50
|
+
let h = 0;
|
|
51
|
+
for (const n of f.values()) { const p = n / s.length; h -= p * Math.log2(p); }
|
|
52
|
+
return h;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 「这串像不像密钥」——**误伤防线都在这儿**。
|
|
57
|
+
* 排除项都是实测会出现的正常值:git sha / md5(纯 hex)、纯数字、版本号、`<set:N>` 自身。
|
|
58
|
+
*/
|
|
59
|
+
export function looksLikeSecret(s) {
|
|
60
|
+
if (s.length < 32) return false;
|
|
61
|
+
if (/^[0-9a-f]+$/.test(s)) return false; // git sha / md5(报告里满地都是)
|
|
62
|
+
if (/^[0-9A-F]+$/.test(s)) return false;
|
|
63
|
+
if (/^\d+$/.test(s)) return false; // 纯数字(时间戳 / 计数)
|
|
64
|
+
if (/^v?\d+(?:\.\d+)+(?:[-+][A-Za-z0-9.]+)?$/.test(s)) return false; // 版本号
|
|
65
|
+
if (/^<set:\d+>$/.test(s)) return false;
|
|
66
|
+
const classes = [/[a-z]/, /[A-Z]/, /\d/, /[_\-+=]/].filter((re) => re.test(s)).length;
|
|
67
|
+
if (classes < 3) return false; // 只有 1~2 类字符 ⇒ 十有八九是词/短横线命名
|
|
68
|
+
return shannon(s) >= 4.2;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** 静态规则:**顺序有意义** —— 先把"具体形态"干掉,兜底规则放最后。 */
|
|
72
|
+
export const REDACT_RULES = [
|
|
73
|
+
{ id: 'pem-private-key', re: PEM, to: (m) => setTag(m) },
|
|
74
|
+
{ id: 'value-prefix', re: PREFIX, to: (m) => setTag(m) },
|
|
75
|
+
{ id: 'bearer', re: /\bBearer[ \t]+([A-Za-z0-9._\-]{12,})/gi, to: (m, t) => `Bearer ${setTag(t)}` },
|
|
76
|
+
{ id: 'key-assign', re: KEY_ASSIGN, to: (m, pre, key, sep, dq, sq, bare) => {
|
|
77
|
+
const v = dq ?? sq ?? bare ?? '';
|
|
78
|
+
// ⚠️ 已经是打码态就别再打一次:值形态规则先跑(`sk-…` → `<set:27>`),键名规则再跑时会把
|
|
79
|
+
// `<set:27>` 当成"新值"打成 `<set:8>`(双重脱敏会把长度信息毁掉 · 2026-10-16 夹具实测抓到)。
|
|
80
|
+
if (/^<set:\d+>$/.test(v)) return m;
|
|
81
|
+
return `${pre}${key}${sep}${setTag(v)}`;
|
|
82
|
+
} },
|
|
83
|
+
{ id: 'jwt', re: /\beyJ[A-Za-z0-9._\-]{20,}/g, to: (m) => setTag(m) },
|
|
84
|
+
{ id: 'value-entropy', re: ENTROPY_CAND, to: (m) => (looksLikeSecret(m) ? setTag(m) : m) },
|
|
85
|
+
// 绝对用户路径:兼容 JSON 转义后的双反斜杠 / 正斜杠两种形态
|
|
86
|
+
{ id: 'user-path', re: /(?:[A-Za-z]:[\\/]{1,2}Users[\\/]{1,2})[A-Za-z0-9_.\-]+/gi, to: '<home>' },
|
|
87
|
+
{ id: 'lan-ip', re: /\b(?:10|192\.168|172\.(?:1[6-9]|2\d|3[01]))\.\d{1,3}\.\d{1,3}\b/g, to: '<redacted:lan-ip>' },
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
/** 太通用的用户名不做全局替换(否则在另一台机的 `Admin` 上会误伤正文)。 */
|
|
91
|
+
export const GENERIC_USERS = new Set(['admin', 'administrator', 'user', 'username', 'root', 'owner', 'guest']);
|
|
92
|
+
|
|
93
|
+
/** 「只脱密钥」档要保留的规则 id(打屏场景用:人还要读路径 / 机器名 / 用户名,别把可读性一起脱掉)。 */
|
|
94
|
+
export const SECRET_ONLY_IDS = new Set(['pem-private-key', 'value-prefix', 'bearer', 'key-assign', 'jwt', 'value-entropy']);
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* @param {{username?:string, secretsOnly?:boolean}} [options]
|
|
98
|
+
* `secretsOnly:true` ⇒ 只保留**密钥类**规则(打屏 / 诊断输出场景);
|
|
99
|
+
* 缺省 = 全量(含绝对用户路径 `<home>` / 裸用户名 `<user>` / 内网 IP),用于**落盘留存 / 外发**的现场包。
|
|
100
|
+
*/
|
|
101
|
+
export function createRedactor(options = {}) {
|
|
102
|
+
const username = options.username !== undefined
|
|
103
|
+
? options.username
|
|
104
|
+
: (() => { try { return os.userInfo().username; } catch { return ''; } })();
|
|
105
|
+
|
|
106
|
+
const rules = options.secretsOnly ? REDACT_RULES.filter((r) => SECRET_ONLY_IDS.has(r.id)) : [...REDACT_RULES];
|
|
107
|
+
if (!options.secretsOnly && username && username.length >= 4 && !GENERIC_USERS.has(username.toLowerCase())) {
|
|
108
|
+
const esc = username.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
109
|
+
rules.push({ id: 'local-user', re: new RegExp(`\\b${esc}\\b`, 'g'), to: '<user>' });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
let total = 0;
|
|
113
|
+
const byRule = {};
|
|
114
|
+
|
|
115
|
+
function redact(text) {
|
|
116
|
+
if (typeof text !== 'string') return { text, hits: {} };
|
|
117
|
+
const hits = {};
|
|
118
|
+
let t = text;
|
|
119
|
+
for (const r of rules) {
|
|
120
|
+
// ⚠️ 一律**函数式替换**:$ 开头的替换串会被当模式展开(生态踩过:441 行复制成 735 行);
|
|
121
|
+
// 高熵规则本来也需要逐值判定。
|
|
122
|
+
const fn = typeof r.to === 'function' ? r.to : () => r.to;
|
|
123
|
+
const m = t.match(r.re);
|
|
124
|
+
if (m && m.length) {
|
|
125
|
+
hits[r.id] = m.length;
|
|
126
|
+
t = t.replace(r.re, (...args) => fn(...args));
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return { text: t, hits };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function redactAndCount(text) {
|
|
133
|
+
const { text: t, hits } = redact(text);
|
|
134
|
+
for (const [k, v] of Object.entries(hits)) { byRule[k] = (byRule[k] || 0) + v; total += v; }
|
|
135
|
+
return t;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** 逐字段脱敏(对象 / 数组 / 字符串),返回新对象。 */
|
|
139
|
+
function deepRedact(obj) {
|
|
140
|
+
if (typeof obj === 'string') return redactAndCount(obj);
|
|
141
|
+
if (Array.isArray(obj)) return obj.map(deepRedact);
|
|
142
|
+
if (obj && typeof obj === 'object') {
|
|
143
|
+
const o = {};
|
|
144
|
+
for (const [k, v] of Object.entries(obj)) o[k] = deepRedact(v);
|
|
145
|
+
return o;
|
|
146
|
+
}
|
|
147
|
+
return obj;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
redact,
|
|
152
|
+
redactAndCount,
|
|
153
|
+
deepRedact,
|
|
154
|
+
rules,
|
|
155
|
+
stats: () => ({ total, byRule: { ...byRule }, rules: rules.map((r) => r.id) }),
|
|
156
|
+
};
|
|
157
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// DSH / tools/lib/session-file-name.mjs —— 会话日志文件名判据 · **单一真源**(零依赖)
|
|
2
|
+
//
|
|
3
|
+
// 背景(2026-09-24 · 卡「三机同步步A-V3代际适配」):
|
|
4
|
+
// 主包 0.1.5 起把会话日志改名 `session.jsonl.zstd` → `session.v3.jsonl.zstd`;
|
|
5
|
+
// 主包 0.2.x 起**再换代** `session.v3.jsonl.zstd` → `session.v4.jsonl.zstd`(**现行世代**)。
|
|
6
|
+
// (2026-10-16 · 卡「harness · 成本与路由 · 债 D-1/D-3 收口」把**写入侧常量与文案**一并推到 v4;
|
|
7
|
+
// 读取侧判据本已认任意 vN,未动。真源 = 本文件。)
|
|
8
|
+
// 凡是「按精确文件名比较」的**读取侧**判据都会整条失明(实测 pitfall-scan 的
|
|
9
|
+
// discoverSessionFiles() 活跃树覆盖 0 / 212),而归档里仍有 1040 个旧名文件、
|
|
10
|
+
// 活跃树 212 个全是 v3 名 ⇒ 读取侧必须**认全世代**。
|
|
11
|
+
//
|
|
12
|
+
// 三条口径(写死,别自由发挥):
|
|
13
|
+
// ① 读取侧一律用 isSessionFileName() / sessionFileGeneration()(前缀 + 代际),**禁精确字符串比较**;
|
|
14
|
+
// ② 需要"代际"时从文件名提取(旧名 = 0,`session.vN....` = N),**禁硬编码**;
|
|
15
|
+
// ③ 写入侧(真要写文件名时)用 SESSION_FILE_NAME_CURRENT(当前世代 = v4,0.2.x 起)。
|
|
16
|
+
//
|
|
17
|
+
// ⚠️ PowerShell 侧无法 import 本模块:`tools\dsh-backup-sessions.ps1` 内写等价 PS 判据
|
|
18
|
+
// ($SessionFileFilter / Test-SessionFileName / $SessionFileNameCurrent)。**改一处必须同步另一处。**
|
|
19
|
+
// 已知同类读取侧判据(**★2026-09-24 收尾清账批逐处核实后重写**,原清单两处已修、又漏了一族):
|
|
20
|
+
// ✅ 已消费本模块:tools\lib\attach-refs.mjs · tools\board-ops\session-tail.mjs · tools\dsh-sessions-land.mjs ·
|
|
21
|
+
// tools\dsh-butler\{session-ledger.mjs, lib\pitfall-scan.mjs, lib\session-header.mjs, lib\restart.mjs} ·
|
|
22
|
+
// tools\archive-card-sessions.mjs · tools\archive-inactive-sessions.mjs · tools\dsh-archive-sessions\lib\index.js ·
|
|
23
|
+
// tools\dsh-butler-panel\lib\state.js(★A2:判据 +「取哪份」取法一并统一;checks.mjs 经它复用,无第二份实现)。
|
|
24
|
+
// ⚠️ restart-coordinator.mjs **无自有判据**(经 lib\restart.mjs#scanSessionLogFiles 间接同源)——原清单点名它属误记。
|
|
25
|
+
// ⏳ 仍**只认旧名**的读取侧(低频/一次性工具,待各自 owner 认领;行号 = 2026-09-24 快照):
|
|
26
|
+
// tools\dsh-archive-sessions\{clean-empty-shells.mjs:78, scan-empty.mjs:48, scan-small.mjs:57} ·
|
|
27
|
+
// tools\projcache-restore-archived.mjs:107 · tools\restore-shell-sessions.mjs:76 · tools\test-single-archive.mjs:21 ·
|
|
28
|
+
// tools\{session-tail-for-llm.mjs:81, session-tail-audit.mjs:96, session-impact.mjs:78, session-cot.mjs:77,
|
|
29
|
+
// dsh-session-tail-dump.mjs:23}。**要长期用再改**,改法照本文件(认全世代 + 需要代际时提取)。
|
|
30
|
+
// ⚠️ 已知风险登记(A6 · 2026-09-24 **先登记不修**):「两代并存」目录里新世代文件可能只是**补写壳**
|
|
31
|
+
// (实测 v3 仅 2 事件 / 244 B,旧名才是完整对话 2621 事件 / 2.7 MB)⇒ **需要读内容的消费者必须走候选链**
|
|
32
|
+
// (rankSessionFilesInDir / collectSessionFilesByDir 的 files),只做扫描/移动的取 current 即可。
|
|
33
|
+
// 当前无消费方受影响(面板只扫活跃树:231/231 全 v3、0 并存);若将来把归档并存目录纳入扫描,先接候选链再开。
|
|
34
|
+
// (插件包内为**派生副本** `lib/session-file-name.mjs`,由 tools\sync-session-file-name.mjs 生成,
|
|
35
|
+
// deploy 时 fail-closed 校验副本与真源 sha 一致 —— 改真源必须同批重跑该同步器。)
|
|
36
|
+
import fs from 'node:fs';
|
|
37
|
+
import path from 'node:path';
|
|
38
|
+
|
|
39
|
+
/** 旧世代文件名(≤ 0.1.1;归档树里仍有大量此名)。 */
|
|
40
|
+
export const SESSION_FILE_NAME_LEGACY = 'session.jsonl.zstd';
|
|
41
|
+
|
|
42
|
+
/** v3 世代文件名(0.1.5 起;**已被 0.2.x 换代**,仍须认得)。 */
|
|
43
|
+
export const SESSION_FILE_NAME_V3 = 'session.v3.jsonl.zstd';
|
|
44
|
+
|
|
45
|
+
/** 当前世代文件名(0.2.x 起)。 */
|
|
46
|
+
export const SESSION_FILE_NAME_V4 = 'session.v4.jsonl.zstd';
|
|
47
|
+
|
|
48
|
+
/** 写入侧应使用的当前世代文件名。 */
|
|
49
|
+
export const SESSION_FILE_NAME_CURRENT = SESSION_FILE_NAME_V4;
|
|
50
|
+
|
|
51
|
+
/** 认得的三个世代名(读取侧清单;顺序 = 旧 → 新)。 */
|
|
52
|
+
export const SESSION_FILE_NAMES = [SESSION_FILE_NAME_LEGACY, SESSION_FILE_NAME_V3, SESSION_FILE_NAME_V4];
|
|
53
|
+
|
|
54
|
+
/** 宽匹配模式(shell / robocopy / Get-ChildItem -Filter 用;**读取侧**)。 */
|
|
55
|
+
export const SESSION_FILE_GLOB = 'session*.jsonl.zstd';
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 读取侧判据(前缀 + 代际)。命中的形态:
|
|
59
|
+
* `session.jsonl.zstd`(旧世代)· `session.v3.jsonl.zstd`(0.1.5 世代)· `session.v4.jsonl.zstd`(当前世代)
|
|
60
|
+
* · 更高的 `session.vN.jsonl.zstd`(未来世代)—— **本判据认任意 vN,换代时无需改**。
|
|
61
|
+
* 不命中:`session.jsonl`(明文形态,另有判据)· `session.jsonl.zstd.bak` · 其它任意文件。
|
|
62
|
+
*/
|
|
63
|
+
export const SESSION_FILE_RE = /^session(?:\.v(\d+))?\.jsonl\.zstd$/;
|
|
64
|
+
|
|
65
|
+
/** 是否会话日志文件(读取侧唯一判据)。 */
|
|
66
|
+
export function isSessionFileName(name) {
|
|
67
|
+
return SESSION_FILE_RE.test(String(name));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 从文件名提取代际:旧世代 = 0;`session.vN.jsonl.zstd` = N;非会话文件 = null。
|
|
72
|
+
* @returns {number|null}
|
|
73
|
+
*/
|
|
74
|
+
export function sessionFileGeneration(name) {
|
|
75
|
+
const m = SESSION_FILE_RE.exec(String(name));
|
|
76
|
+
if (!m) return null;
|
|
77
|
+
return m[1] ? Number(m[1]) : 0;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* 目录形态的会话文件清单(不递归)。
|
|
82
|
+
* @returns {{file:string,name:string,generation:number,bytes:number,mtimeMs:number}[]}
|
|
83
|
+
*/
|
|
84
|
+
export function listSessionFilesInDir(dir, opts = {}) {
|
|
85
|
+
const fsImpl = opts.fs ?? fs;
|
|
86
|
+
let ents;
|
|
87
|
+
try { ents = fsImpl.readdirSync(dir, { withFileTypes: true }); } catch { return []; }
|
|
88
|
+
const out = [];
|
|
89
|
+
for (const e of ents) {
|
|
90
|
+
if (!e.isFile()) continue;
|
|
91
|
+
if (!isSessionFileName(e.name)) continue;
|
|
92
|
+
const full = path.join(dir, e.name);
|
|
93
|
+
let st;
|
|
94
|
+
try { st = fsImpl.statSync(full); } catch { continue; }
|
|
95
|
+
out.push({
|
|
96
|
+
file: full, name: e.name, generation: sessionFileGeneration(e.name),
|
|
97
|
+
bytes: st.size, mtimeMs: Math.round(st.mtimeMs),
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* 同一会话目录内的候选文件,按优先级排序(第一个 = 「当前那份」):
|
|
105
|
+
* mtime 最新优先 → 平局取代际大者 → 再平局取名字典序小者(确定性)。
|
|
106
|
+
*
|
|
107
|
+
* ⚠️ **「当前那份」≠「内容最全的那份」**(2026-09-24 实测,卡「修·V3判据④」):
|
|
108
|
+
* 换代后新世代文件可能只是"补写壳"——归档树 28 个两代并存目录里,`session.v3.jsonl.zstd`
|
|
109
|
+
* 只有 2 个事件(`session` 头 + `session/end-seed`、有效内容 244 B),而旧名文件才是完整对话
|
|
110
|
+
* (2621 个事件 / 2.7 MB 解压文本)。**需要读内容的消费者必须走完候选链**(见下),
|
|
111
|
+
* 只做"扫描/移动"的消费者取第一个即可。
|
|
112
|
+
* @returns {{file:string,name:string,generation:number,bytes:number,mtimeMs:number}[]}
|
|
113
|
+
*/
|
|
114
|
+
export function rankSessionFilesInDir(dir, opts = {}) {
|
|
115
|
+
const hits = listSessionFilesInDir(dir, opts);
|
|
116
|
+
hits.sort((a, b) => (b.mtimeMs - a.mtimeMs) || (b.generation - a.generation) || (a.name < b.name ? -1 : 1));
|
|
117
|
+
return hits;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* 在一个会话目录里挑出"当前那份"会话文件(两代并存时的取法):
|
|
122
|
+
* mtime 最新优先 → 平局取代际大者 → 再平局取名字典序小者(确定性)。
|
|
123
|
+
* @returns {string|null} 绝对路径;目录不存在 / 无会话文件 = null
|
|
124
|
+
*/
|
|
125
|
+
export function pickSessionFileInDir(dir, opts = {}) {
|
|
126
|
+
const hits = rankSessionFilesInDir(dir, opts);
|
|
127
|
+
return hits.length ? hits[0].file : null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* 递归收集会话目录(**每个目录聚合一次**,不做逐文件扁平化)。
|
|
132
|
+
* 用途:归档器的扫描面 —— 一个会话目录 = 一个会话,两代并存时 `current` 是"当前那份"、
|
|
133
|
+
* `files` 是该目录全部世代(移动校验/尾部判据/内容回落都要用到全量)。
|
|
134
|
+
* @returns {{dir:string, current:string, files:object[]}[]} 按 dir 字典序(确定性)
|
|
135
|
+
*/
|
|
136
|
+
export function collectSessionFilesByDir(root, opts = {}) {
|
|
137
|
+
const fsImpl = opts.fs ?? fs;
|
|
138
|
+
const out = [];
|
|
139
|
+
const stack = [root];
|
|
140
|
+
while (stack.length) {
|
|
141
|
+
const d = stack.pop();
|
|
142
|
+
let ents;
|
|
143
|
+
try { ents = fsImpl.readdirSync(d, { withFileTypes: true }); } catch { continue; }
|
|
144
|
+
let hasSessionFile = false;
|
|
145
|
+
for (const e of ents) {
|
|
146
|
+
if (e.isDirectory()) { stack.push(path.join(d, e.name)); continue; }
|
|
147
|
+
if (e.isFile() && isSessionFileName(e.name)) hasSessionFile = true;
|
|
148
|
+
}
|
|
149
|
+
if (!hasSessionFile) continue;
|
|
150
|
+
const files = rankSessionFilesInDir(d, opts);
|
|
151
|
+
if (files.length) out.push({ dir: d, current: files[0].file, files });
|
|
152
|
+
}
|
|
153
|
+
out.sort((a, b) => (a.dir < b.dir ? -1 : 1));
|
|
154
|
+
return out;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** 递归收集「每目录当前那份」的绝对路径(collectSessionFilesByDir 的扁平形态)。 */
|
|
158
|
+
export function collectSessionFilesInTree(root, opts = {}) {
|
|
159
|
+
return collectSessionFilesByDir(root, opts).map((x) => x.current);
|
|
160
|
+
}
|