@mingmaple/dsh-handover 0.0.0-stage → 0.9.6
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/LICENSE +21 -0
- package/README.md +118 -2
- package/docs/migrate.md +61 -0
- package/docs/troubleshoot.md +48 -0
- package/package.json +55 -3
- package/scripts/blank-dsh.mjs +203 -0
- package/scripts/blank-smoke.mjs +820 -0
- package/scripts/build.mjs +58 -0
- package/scripts/check-docs.mjs +74 -0
- package/scripts/e2e-migration.mjs +127 -0
- package/scripts/lib/register.mjs +103 -0
- package/src/client.js +887 -0
- package/src/cordis.patch.yml +16 -0
- package/src/index.mjs +631 -0
- package/src/lib/absrefs.mjs +112 -0
- package/src/lib/api.mjs +234 -0
- package/src/lib/archive.mjs +198 -0
- package/src/lib/brief.mjs +74 -0
- package/src/lib/charter.mjs +28 -0
- package/src/lib/config.mjs +36 -0
- package/src/lib/consent.mjs +84 -0
- package/src/lib/drift.mjs +65 -0
- package/src/lib/events.mjs +89 -0
- package/src/lib/extra-prompt.mjs +41 -0
- package/src/lib/facts.mjs +70 -0
- package/src/lib/gates.mjs +148 -0
- package/src/lib/guide.mjs +99 -0
- package/src/lib/handover-io.mjs +49 -0
- package/src/lib/lint.mjs +152 -0
- package/src/lib/manual.mjs +124 -0
- package/src/lib/mover.mjs +663 -0
- package/src/lib/names.mjs +33 -0
- package/src/lib/output.mjs +11 -0
- package/src/lib/pack-ops.mjs +329 -0
- package/src/lib/peer.mjs +260 -0
- package/src/lib/phase.mjs +34 -0
- package/src/lib/profile.mjs +204 -0
- package/src/lib/registry.mjs +53 -0
- package/src/lib/retarget-helper.tmpl +46 -0
- package/src/lib/role.mjs +217 -0
- package/src/lib/rpc-carrier.mjs +65 -0
- package/src/lib/scaffold.mjs +118 -0
- package/src/lib/schedule.mjs +227 -0
- package/src/lib/session-ops.mjs +362 -0
- package/src/lib/sessions.mjs +142 -0
- package/src/lib/sevenzip.mjs +48 -0
- package/src/lib/status.mjs +171 -0
- package/src/lib/text.mjs +74 -0
- package/src/lib/webroute.mjs +130 -0
- package/src/lib/workspace.mjs +23 -0
- package/src/lib/zstd-log.mjs +65 -0
- package/src/selftest.mjs +84 -0
- package/src/templates/Changelog.skeleton.md +10 -0
- package/src/templates/Charter.skeleton.md +53 -0
- package/src/templates/Handover.global.skeleton.md +56 -0
- package/src/templates/Prompt_Templates.md +96 -0
- package/src/templates/Runtime_Manual.md +166 -0
- package/src/templates/rules/Handover_Rules.md +43 -0
- package/src/templates/rules/Migration_Rules.md +32 -0
- package/src/templates/rules/Misc_Task_Standard.md +23 -0
- package/src/templates/rules/Writing_Rules.md +24 -0
- package/src/tools/ho_brief.mjs +57 -0
- package/src/tools/ho_changelog.mjs +59 -0
- package/src/tools/ho_charter.mjs +89 -0
- package/src/tools/ho_checkpoint.mjs +125 -0
- package/src/tools/ho_diag.mjs +85 -0
- package/src/tools/ho_drift.mjs +41 -0
- package/src/tools/ho_gate.mjs +69 -0
- package/src/tools/ho_handover.mjs +83 -0
- package/src/tools/ho_init.mjs +82 -0
- package/src/tools/ho_pack.mjs +38 -0
- package/src/tools/ho_phase.mjs +37 -0
- package/src/tools/ho_profile.mjs +97 -0
- package/src/tools/ho_sessions.mjs +114 -0
- package/src/tools/ho_snapshot.mjs +103 -0
- package/src/tools/ho_tokens.mjs +57 -0
- package/src/tools/ho_workspace.mjs +213 -0
- package/src/tools/peer_list.mjs +37 -0
- package/src/tools/peer_new.mjs +75 -0
- package/src/tools/peer_peek.mjs +41 -0
- package/src/tools/peer_rename.mjs +31 -0
- package/src/tools/peer_send.mjs +52 -0
- package/src/tools/peer_status.mjs +46 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 工具名集中管理:ho_* 由 PREFIX 与 KEYS 生成,peer_* 由 PEER_KEYS 生成。
|
|
3
|
+
* 工具文件一律用 nameOf、peerNameOf 取名,index.mjs 启动时校验位置表,改名只需改这里。
|
|
4
|
+
*/
|
|
5
|
+
const PREFIX = 'ho';
|
|
6
|
+
|
|
7
|
+
export const nameOf = (key) => PREFIX + '_' + key;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* ho_* 16 个。合并过的家族(刻意保持旧名,参数里用 action 分流):
|
|
11
|
+
* ho_pack —— 打包 / 校验 / 预演 / 恢复(原 ho_pack_verify、ho_pack_restore、ho_pack_precheck)
|
|
12
|
+
* ho_sessions —— 清点 / 设定角色 / 建会话 / 出提示词 / 归档 / 解归档(原 ho_session_new、ho_prompt、ho_session_archive、ho_session_unarchive)
|
|
13
|
+
* ho_profile —— 骨架读取 / 注册 / 校验 / 预设
|
|
14
|
+
*/
|
|
15
|
+
const KEYS = [
|
|
16
|
+
'brief', 'gate', 'sessions', 'phase', 'charter',
|
|
17
|
+
'handover', 'checkpoint', 'snapshot', 'diag',
|
|
18
|
+
'drift', 'changelog', 'tokens',
|
|
19
|
+
'pack', 'workspace', 'init', 'profile',
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
export const NAMES = KEYS.reduce((acc, k) => { acc[k] = nameOf(k); return acc; }, {});
|
|
23
|
+
|
|
24
|
+
export const TOOL_NAMES = KEYS.map((k) => NAMES[k]);
|
|
25
|
+
|
|
26
|
+
/** 平级会话工具:名字固定为 peer_*,不走 PREFIX。 */
|
|
27
|
+
export const peerNameOf = (key) => 'peer_' + key;
|
|
28
|
+
|
|
29
|
+
const PEER_KEYS = ['list', 'status', 'send', 'new', 'rename', 'peek'];
|
|
30
|
+
|
|
31
|
+
export const PEER_NAMES = PEER_KEYS.map((k) => peerNameOf(k));
|
|
32
|
+
|
|
33
|
+
export const ALL_TOOL_NAMES = TOOL_NAMES.concat(PEER_NAMES);
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 打包、校验、预演、恢复的实现(原 ho_pack / ho_pack_verify / ho_pack_restore / ho_pack_precheck 的代码搬到这里)。
|
|
3
|
+
* 对外只经 ho_pack(action=…) 暴露,少 3 份 schema 的固定开销。
|
|
4
|
+
* 共同口径:只报告绝对路径、不改写文件内容;恢复默认预演、覆盖前备份、从不删除目标文件。
|
|
5
|
+
*/
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import crypto from 'node:crypto';
|
|
9
|
+
import { NL, isDir, isFile } from './text.mjs';
|
|
10
|
+
import { sevenZipBin, pack as z7pack, test as z7test, extract } from './sevenzip.mjs';
|
|
11
|
+
import { packDirTarGz, listTarGz, extractTarGz, detectFormat, safeRel } from './archive.mjs';
|
|
12
|
+
import { scanAbsoluteRefs, renderAbsoluteRefs } from './absrefs.mjs';
|
|
13
|
+
import { stampTz } from './brief.mjs';
|
|
14
|
+
import { scanSessions } from './sessions.mjs';
|
|
15
|
+
import { lintProject } from './lint.mjs';
|
|
16
|
+
|
|
17
|
+
const SKIP_DIRS = ['__pycache__', '.git', 'node_modules', '.obsidian'];
|
|
18
|
+
const SKIP_FILES = ['.DS_Store', 'Thumbs.db'];
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 选归档格式:auto(有 7-Zip 用 7z,否则用内置 tar.gz)、7z、tar。
|
|
22
|
+
* 内置归档器是**功能等价**的兜底:空白机器上没有 7-Zip 也必须能打包、校验、恢复。
|
|
23
|
+
*/
|
|
24
|
+
function chooseFormat(args, cfg) {
|
|
25
|
+
const want = String((args && args.format) || (cfg && cfg.packFormat) || 'auto').toLowerCase();
|
|
26
|
+
if (want === '7z') {
|
|
27
|
+
const bin = sevenZipBin(cfg && cfg.sevenZipPath);
|
|
28
|
+
if (!bin) throw new Error('指定了 format=7z,但本机没有 7-Zip。去掉 format 用 auto,或写 format="tar" 用内置归档器。');
|
|
29
|
+
return { fmt: '7z', bin };
|
|
30
|
+
}
|
|
31
|
+
if (want === 'tar' || want === 'targz' || want === 'tar.gz') return { fmt: 'tar', bin: '' };
|
|
32
|
+
const bin = sevenZipBin(cfg && cfg.sevenZipPath);
|
|
33
|
+
return bin ? { fmt: '7z', bin } : { fmt: 'tar', bin: '' };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 解包到 dir(按魔数判断格式,不看扩展名)。返回 { code, out }。 */
|
|
37
|
+
function unpack(packPath, dir) {
|
|
38
|
+
const fmt = detectFormat(packPath);
|
|
39
|
+
if (fmt === 'targz') {
|
|
40
|
+
try {
|
|
41
|
+
const r = extractTarGz(packPath, dir);
|
|
42
|
+
const out = 'tar.gz 解包:写入 ' + r.written + ' 个文件、' + r.dirs + ' 个目录' + (r.skipped.length ? ';跳过 ' + r.skipped.length + ' 条(' + r.skipped[0] + ')' : '');
|
|
43
|
+
return { code: 0, out };
|
|
44
|
+
} catch (e) {
|
|
45
|
+
return { code: -1, out: 'tar.gz 解包失败:' + (e && e.message ? e.message : e) };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (fmt === '7z') {
|
|
49
|
+
const bin = sevenZipBin('');
|
|
50
|
+
if (!bin) return { code: -1, out: '这个包是 7z 格式,但本机没有 7-Zip;装一个 7-Zip,或改用 tar.gz 包。' };
|
|
51
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
52
|
+
return run7zSafe(bin, packPath, dir);
|
|
53
|
+
}
|
|
54
|
+
return { code: -1, out: '认不出包格式(既不是 7z 魔数也不是 gzip):' + packPath };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** 包内路径必须相对且不越界:拒绝绝对路径、盘符、..(防止恢复时写到目标目录之外)。 */
|
|
58
|
+
function unsafeManifestPaths(manifest) {
|
|
59
|
+
const files = Array.isArray(manifest && manifest.files) ? manifest.files : [];
|
|
60
|
+
const bad = [];
|
|
61
|
+
for (const f of files) {
|
|
62
|
+
const rel = safeRel(f && f.path);
|
|
63
|
+
if (rel === null) bad.push(String(f && f.path));
|
|
64
|
+
}
|
|
65
|
+
return bad;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function run7zSafe(bin, packPath, dir) {
|
|
69
|
+
try {
|
|
70
|
+
return extract(bin, packPath, dir);
|
|
71
|
+
} catch (e) {
|
|
72
|
+
return { code: -1, out: String(e && e.message ? e.message : e) };
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function stampNow() {
|
|
77
|
+
const d = new Date();
|
|
78
|
+
const p = (n) => (n < 10 ? '0' + n : '' + n);
|
|
79
|
+
return d.getFullYear() + p(d.getMonth() + 1) + p(d.getDate()) + '-' + p(d.getHours()) + p(d.getMinutes());
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function sha256(file) {
|
|
83
|
+
return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function walk(root, rel, out) {
|
|
87
|
+
let names = [];
|
|
88
|
+
try { names = fs.readdirSync(path.join(root, rel)); } catch { return out; }
|
|
89
|
+
for (const name of names) {
|
|
90
|
+
const abs = path.join(root, rel, name);
|
|
91
|
+
const r = rel ? rel + '/' + name : name;
|
|
92
|
+
let st;
|
|
93
|
+
try { st = fs.statSync(abs); } catch { continue; }
|
|
94
|
+
if (st.isDirectory()) {
|
|
95
|
+
if (SKIP_DIRS.indexOf(name) !== -1) continue;
|
|
96
|
+
walk(root, r, out);
|
|
97
|
+
} else if (st.isFile()) {
|
|
98
|
+
if (SKIP_FILES.indexOf(name) !== -1) continue;
|
|
99
|
+
if (name.endsWith('.pyc')) continue;
|
|
100
|
+
out.push({ rel: r, abs, bytes: st.size });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ---------------------------------------------------------------- action: check
|
|
107
|
+
|
|
108
|
+
export function packCheck(env, args, exec) {
|
|
109
|
+
const ws = env.resolveWs(exec);
|
|
110
|
+
const items = Array.isArray(args.items) && args.items.length ? args.items : [];
|
|
111
|
+
if (!items.length) throw new Error('action=check 需要 items(项目名列表,写 global 表示全局)');
|
|
112
|
+
const minAge = (typeof args.min_age_min === 'number' ? args.min_age_min : (env.cfg.prepackMinAgeMin || 90)) * 60000;
|
|
113
|
+
const now = Date.now();
|
|
114
|
+
const scan = scanSessions({ dir: env.cacheDir(), meId: env.selfId(exec), warn: 32, silentHours: env.cfg.sessionScanHours });
|
|
115
|
+
const out = [];
|
|
116
|
+
let bad = 0;
|
|
117
|
+
for (const item of items) {
|
|
118
|
+
const why = [];
|
|
119
|
+
if (item !== 'global') {
|
|
120
|
+
const live = scan.rows.filter((r) => r.project === item);
|
|
121
|
+
if (live.length) {
|
|
122
|
+
const best = live.slice().sort((a, b) => b.mtime - a.mtime)[0];
|
|
123
|
+
if (best.state === 'running') why.push('会话 ' + best.sid + ' 正在跑');
|
|
124
|
+
if (now - best.mtime < minAge) why.push('最后活动距今 ' + Math.round((now - best.mtime) / 60000) + ' 分钟 < ' + Math.round(minAge / 60000));
|
|
125
|
+
}
|
|
126
|
+
const hand = path.join(ws, '_Project_Meta', item, 'Handover.md');
|
|
127
|
+
if (!isFile(hand)) why.push('缺交接:' + hand);
|
|
128
|
+
else {
|
|
129
|
+
const r = lintProject(hand);
|
|
130
|
+
if (r.verdict !== '通过' && r.verdict !== '缺节') why.push('交接体检 ' + r.verdict + (r.reasons.length ? ':' + r.reasons[0] : ''));
|
|
131
|
+
}
|
|
132
|
+
} else {
|
|
133
|
+
const hand = path.join(ws, '_Control_Center', 'records', 'Global_Handover.md');
|
|
134
|
+
if (!isFile(hand)) why.push('缺全局交接');
|
|
135
|
+
}
|
|
136
|
+
if (why.length) { bad++; out.push('未通过 ' + item + ':' + why.join(';')); }
|
|
137
|
+
else out.push('通过 ' + item + ':可打包');
|
|
138
|
+
}
|
|
139
|
+
out.push(bad ? '判定:' + bad + ' 项未就绪,应待会话静止并补齐交接后再打包' : '判定:全部就绪,可以打包');
|
|
140
|
+
return out.join(NL);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ---------------------------------------------------------------- action: pack
|
|
144
|
+
|
|
145
|
+
export function packNow(env, args, exec) {
|
|
146
|
+
const ws = env.resolveWs(exec);
|
|
147
|
+
const kind = args.kind || 'global';
|
|
148
|
+
const name = args.name || '';
|
|
149
|
+
const roots = [];
|
|
150
|
+
if (kind === 'global') {
|
|
151
|
+
roots.push(['_Control_Center', path.join(ws, '_Control_Center')]);
|
|
152
|
+
roots.push(['_Project_Meta', path.join(ws, '_Project_Meta')]);
|
|
153
|
+
} else if (kind === 'project') {
|
|
154
|
+
if (!name) throw new Error('kind=project 需要 name');
|
|
155
|
+
roots.push(['Projects/' + name, path.join(ws, 'Projects', name)]);
|
|
156
|
+
roots.push(['_Project_Meta/' + name, path.join(ws, '_Project_Meta', name)]);
|
|
157
|
+
roots.push(['_Archives/' + name + '/Handover', path.join(ws, '_Archives', name, 'Handover')]);
|
|
158
|
+
} else if (kind === 'task') {
|
|
159
|
+
if (!name) throw new Error('kind=task 需要 name');
|
|
160
|
+
roots.push(['_Misc/tasks/' + name, path.join(ws, '_Misc', 'tasks', name)]);
|
|
161
|
+
} else throw new Error('未知 kind:' + kind);
|
|
162
|
+
const entries = [];
|
|
163
|
+
for (const [label, abs] of roots) {
|
|
164
|
+
if (!isDir(abs)) continue;
|
|
165
|
+
for (const it of walk(abs, '', [])) entries.push({ rel: label + '/' + it.rel, abs: it.abs, bytes: it.bytes });
|
|
166
|
+
}
|
|
167
|
+
if (!entries.length) throw new Error('没有可打包的文件:' + roots.map((r) => r[0]).join('、'));
|
|
168
|
+
const total = entries.reduce((a, b) => a + b.bytes, 0);
|
|
169
|
+
const out = [];
|
|
170
|
+
out.push('目标 ' + kind + (name ? ':' + name : '') + ' → ' + entries.length + ' 个文件 / ' + (total / 1048576).toFixed(2) + ' MB');
|
|
171
|
+
if (args.dry_run) {
|
|
172
|
+
for (const e of entries.slice(0, 20)) out.push(' ' + e.rel + ' ' + e.bytes + ' B');
|
|
173
|
+
const pre = scanAbsoluteRefs(ws, entries.map((e) => e.rel));
|
|
174
|
+
out.push(...renderAbsoluteRefs(pre, { title: '绝对路径体检(源目录)', max: 5 }));
|
|
175
|
+
out.push('(dry_run:不打包)');
|
|
176
|
+
return out.join(NL);
|
|
177
|
+
}
|
|
178
|
+
const stamp = stampNow();
|
|
179
|
+
const label = kind === 'global' ? '全局' : name;
|
|
180
|
+
const stagingRoot = path.join(ws, '_Temp', '_Global', 'Scratch', 'pack-' + stamp);
|
|
181
|
+
const payload = path.join(stagingRoot, 'payload');
|
|
182
|
+
const files = [];
|
|
183
|
+
for (const e of entries) {
|
|
184
|
+
const dest = path.join(payload, e.rel.split('/').join(path.sep));
|
|
185
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
186
|
+
fs.copyFileSync(e.abs, dest);
|
|
187
|
+
files.push({ path: e.rel, bytes: e.bytes, sha256: sha256(dest) });
|
|
188
|
+
}
|
|
189
|
+
const abs = scanAbsoluteRefs(payload, files.map((f) => f.path));
|
|
190
|
+
const manifest = {
|
|
191
|
+
kind,
|
|
192
|
+
name: name || null,
|
|
193
|
+
createdAt: new Date().toISOString(),
|
|
194
|
+
createdAtLocal: stampTz(Date.now()),
|
|
195
|
+
sourcePlatform: process.platform,
|
|
196
|
+
fileCount: files.length,
|
|
197
|
+
bytes: total,
|
|
198
|
+
note: args.note || '',
|
|
199
|
+
absoluteRefs: { totalHits: abs.totalHits, files: abs.hits.length, scanned: abs.scanned, crlf: abs.crlf, lf: abs.lf, nonUtf8: abs.nonUtf8.length, detail: abs.hits },
|
|
200
|
+
files,
|
|
201
|
+
};
|
|
202
|
+
fs.writeFileSync(path.join(stagingRoot, 'manifest.json'), JSON.stringify(manifest, null, 2), 'utf8');
|
|
203
|
+
fs.writeFileSync(path.join(stagingRoot, 'CHECKSUMS.sha256'), files.map((f) => f.sha256 + ' ' + f.path).join(NL) + NL, 'utf8');
|
|
204
|
+
const absLines = renderAbsoluteRefs(abs, { title: '绝对路径体检(包内)', max: 200 });
|
|
205
|
+
const md = ['# 迁移包(最简版)', '', '- 目标: ' + kind + (name ? ':' + name : ''), '- 生成: ' + manifest.createdAt, '- 文件: ' + files.length + ' 个 / ' + total + ' B', '- 说明: ' + (args.note || '(无)'), '', ...absLines, '', '## 跨平台注意', '', '- **库内引用一律相对路径**:本包里有 ' + abs.totalHits + ' 处写死的绝对路径,插件**只报告、不改写**文件内容——迁移后需由人或项目会话自己改。', '- 源平台 ' + manifest.sourcePlatform + ';换行 CRLF ' + abs.crlf + ' 个 / LF ' + abs.lf + ' 个' + (abs.nonUtf8.length ? ';非 UTF-8 ' + abs.nonUtf8.length + ' 个' : '') + '。', '- 目标机若是别的系统(Windows ↔ macOS/Linux):盘符、路径分隔符、大小写敏感性、换行、编码都要项目会话自己核一遍。', '', '## 目标环境前置(先做这两件,再做恢复)', '', '1. 安装 dsh-handover 插件(否则本工具不存在);', '2. 打开本包随附的交接文件(_Project_Meta/<项目名>/Handover.md 或全局 _Control_Center/records/Global_Handover.md),按它恢复状态。', '', '## 恢复', '', 'ho_pack(action="restore", pack="<本包绝对路径>", dry_run=true) # 先预演,再加 apply=true', '', '> 会话本身**不迁移**:连续性只靠交接文档。'].join(NL);
|
|
206
|
+
fs.writeFileSync(path.join(stagingRoot, 'MIGRATION.md'), md, 'utf8');
|
|
207
|
+
const outDir = args.out || path.join(ws, '_Archives', '_Global', 'Backups');
|
|
208
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
209
|
+
const pick = chooseFormat(args, env.cfg);
|
|
210
|
+
const ext = pick.fmt === '7z' ? '.7z' : '.tar.gz';
|
|
211
|
+
const archive = path.join(outDir, label + '-migration-' + stamp + ext);
|
|
212
|
+
if (fs.existsSync(archive)) throw new Error('目标包已存在,不覆盖:' + archive);
|
|
213
|
+
if (pick.fmt === '7z') {
|
|
214
|
+
const a = z7pack(pick.bin, archive, stagingRoot);
|
|
215
|
+
if (a.code !== 0) throw new Error('7z 打包失败(' + a.code + '):' + a.error + String.fromCharCode(10) + a.out.slice(-400));
|
|
216
|
+
const t = z7test(pick.bin, archive);
|
|
217
|
+
if (t.code !== 0) throw new Error('7z 自检失败(' + t.code + '):' + t.out.slice(-400));
|
|
218
|
+
out.push('自检 7z t 通过');
|
|
219
|
+
} else {
|
|
220
|
+
const r = packDirTarGz(stagingRoot, archive);
|
|
221
|
+
const listed = listTarGz(archive);
|
|
222
|
+
const unsafe = listed.filter((x) => !x.safe);
|
|
223
|
+
if (listed.length < r.files) throw new Error('tar.gz 自检失败:回读条目 ' + listed.length + ' < 写入文件 ' + r.files);
|
|
224
|
+
if (unsafe.length) throw new Error('tar.gz 自检失败:含不安全路径 ' + unsafe[0].rel);
|
|
225
|
+
out.push('自检 tar.gz 通过(' + r.files + ' 文件、' + r.entries + ' 条目,内置归档器)');
|
|
226
|
+
}
|
|
227
|
+
try { fs.rmSync(stagingRoot, { recursive: true, force: true }); } catch { /* 留着也无害 */ }
|
|
228
|
+
out.push('已打包 ' + archive + '(' + fs.statSync(archive).size + ' B,格式 ' + pick.fmt + ')');
|
|
229
|
+
out.push('绝对路径体检:' + (abs.totalHits ? abs.totalHits + ' 处(清单见 MIGRATION.md,插件不改写文件内容)' : '无'));
|
|
230
|
+
return out.join(NL);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ---------------------------------------------------------------- action: verify
|
|
234
|
+
|
|
235
|
+
export function packVerify(env, args, exec) {
|
|
236
|
+
const ws = env.resolveWs(exec);
|
|
237
|
+
const packPath = args.pack;
|
|
238
|
+
if (!packPath) throw new Error('action=verify 需要 pack(包路径)');
|
|
239
|
+
if (!fs.existsSync(packPath)) throw new Error('包不存在:' + packPath);
|
|
240
|
+
const tmp = path.join(ws, '_Temp', '_Global', 'Scratch', 'verify-' + stampNow());
|
|
241
|
+
const ex = unpack(packPath, tmp);
|
|
242
|
+
if (ex.code !== 0) throw new Error('解包失败:' + String(ex.out).slice(-300));
|
|
243
|
+
const mf = path.join(tmp, 'manifest.json');
|
|
244
|
+
if (!fs.existsSync(mf)) throw new Error('包内缺 manifest.json:' + packPath);
|
|
245
|
+
const manifest = JSON.parse(fs.readFileSync(mf, 'utf8'));
|
|
246
|
+
const unsafe = unsafeManifestPaths(manifest);
|
|
247
|
+
if (unsafe.length) {
|
|
248
|
+
try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
249
|
+
throw new Error('包内路径不安全(必须是相对路径、不含 ..):' + unsafe.slice(0, 3).join(';'));
|
|
250
|
+
}
|
|
251
|
+
const same = [], drift = [], missing = [];
|
|
252
|
+
for (const f of manifest.files) {
|
|
253
|
+
const local = path.join(ws, f.path.split('/').join(path.sep));
|
|
254
|
+
if (!fs.existsSync(local)) { missing.push(f.path); continue; }
|
|
255
|
+
if (sha256(local) === f.sha256) same.push(f.path); else drift.push(f.path);
|
|
256
|
+
}
|
|
257
|
+
try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
258
|
+
const out = [];
|
|
259
|
+
out.push('包 ' + packPath);
|
|
260
|
+
out.push('文件 ' + manifest.files.length + ' 个;一致 ' + same.length + ' / 漂移 ' + drift.length + ' / 缺失 ' + missing.length);
|
|
261
|
+
for (const p of drift.slice(0, 10)) out.push(' 漂移:' + p);
|
|
262
|
+
for (const p of missing.slice(0, 10)) out.push(' 缺失:' + p);
|
|
263
|
+
out.push(drift.length === 0 && missing.length === 0 ? '判定:包内即现版,可直接用' : '判定:**包已作废**(盘上已变),必须重打');
|
|
264
|
+
return out.join(NL);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ---------------------------------------------------------------- action: restore
|
|
268
|
+
|
|
269
|
+
export function packRestore(env, args, exec) {
|
|
270
|
+
const ws = env.resolveWs(exec);
|
|
271
|
+
const target = args.target || ws;
|
|
272
|
+
const packPath = args.pack;
|
|
273
|
+
if (!packPath) throw new Error('action=restore 需要 pack(包路径)');
|
|
274
|
+
if (!fs.existsSync(packPath)) throw new Error('包不存在:' + packPath);
|
|
275
|
+
const apply = args.apply === true && args.dry_run !== true;
|
|
276
|
+
const tmp = path.join(ws, '_Temp', '_Global', 'Scratch', 'restore-' + stampNow());
|
|
277
|
+
const ex = unpack(packPath, tmp);
|
|
278
|
+
if (ex.code !== 0) throw new Error('解包失败:' + String(ex.out).slice(-300));
|
|
279
|
+
const mf = path.join(tmp, 'manifest.json');
|
|
280
|
+
if (!fs.existsSync(mf)) throw new Error('包内缺 manifest.json');
|
|
281
|
+
const manifest = JSON.parse(fs.readFileSync(mf, 'utf8'));
|
|
282
|
+
const unsafe = unsafeManifestPaths(manifest);
|
|
283
|
+
if (unsafe.length) {
|
|
284
|
+
try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
285
|
+
throw new Error('包内路径不安全(必须是相对路径、不含 ..),已中止、未写任何文件:' + unsafe.slice(0, 3).join(';'));
|
|
286
|
+
}
|
|
287
|
+
const bad = [];
|
|
288
|
+
for (const f of manifest.files) {
|
|
289
|
+
const src = path.join(tmp, 'payload', f.path.split('/').join(path.sep));
|
|
290
|
+
if (!fs.existsSync(src)) { bad.push('缺文件 ' + f.path); continue; }
|
|
291
|
+
if (sha256(src) !== f.sha256) bad.push('哈希不符 ' + f.path);
|
|
292
|
+
}
|
|
293
|
+
if (bad.length) {
|
|
294
|
+
try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
295
|
+
throw new Error('校验未通过,已中止(未复制任何文件):' + bad.slice(0, 5).join(';'));
|
|
296
|
+
}
|
|
297
|
+
const abs = scanAbsoluteRefs(path.join(tmp, 'payload'), manifest.files.map((f) => f.path));
|
|
298
|
+
const crossPlatform = manifest.sourcePlatform && manifest.sourcePlatform !== process.platform;
|
|
299
|
+
const backupDir = path.join(ws, '_Temp', '_Global', 'Snapshots', 'restore-' + stampNow());
|
|
300
|
+
const adds = [], overwrites = [];
|
|
301
|
+
for (const f of manifest.files) {
|
|
302
|
+
const dest = path.join(target, f.path.split('/').join(path.sep));
|
|
303
|
+
if (fs.existsSync(dest)) overwrites.push(f.path); else adds.push(f.path);
|
|
304
|
+
}
|
|
305
|
+
if (apply) {
|
|
306
|
+
for (const f of manifest.files) {
|
|
307
|
+
const src = path.join(tmp, 'payload', f.path.split('/').join(path.sep));
|
|
308
|
+
const dest = path.join(target, f.path.split('/').join(path.sep));
|
|
309
|
+
if (fs.existsSync(dest)) {
|
|
310
|
+
const bak = path.join(backupDir, f.path.split('/').join(path.sep));
|
|
311
|
+
fs.mkdirSync(path.dirname(bak), { recursive: true });
|
|
312
|
+
fs.copyFileSync(dest, bak);
|
|
313
|
+
}
|
|
314
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
315
|
+
fs.copyFileSync(src, dest);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
319
|
+
const out = [];
|
|
320
|
+
out.push('包 ' + packPath + ',目标 ' + target);
|
|
321
|
+
out.push('文件 ' + manifest.files.length + ' 个;新增 ' + adds.length + ' / 覆盖 ' + overwrites.length);
|
|
322
|
+
for (const p of overwrites.slice(0, 10)) out.push(' 覆盖:' + p);
|
|
323
|
+
if (crossPlatform) out.push('注意:源平台 ' + manifest.sourcePlatform + ' ≠ 本机 ' + process.platform + ':路径分隔符、盘符、大小写、换行、编码都需要项目会话自己核一遍。');
|
|
324
|
+
out.push(...renderAbsoluteRefs(abs, { title: '绝对路径体检(恢复前)', max: 20 }));
|
|
325
|
+
if (abs.totalHits) out.push(' ⇒ 这些位置插件**不会**改;迁移后由人或项目会话按新机器改,库内引用统一用相对路径。');
|
|
326
|
+
if (!apply) out.push('**预演**:未写入任何文件。确认无误后用 apply=true 再调一次。');
|
|
327
|
+
else out.push('已写入;被覆盖的旧文件备份在 ' + backupDir);
|
|
328
|
+
return out.join(NL);
|
|
329
|
+
}
|
package/src/lib/peer.mjs
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 平级会话共享逻辑(原 peer-link 插件的本地只读部分,原样搬进来)。
|
|
3
|
+
* 数据源与 peer-link 完全一致,不引入任何第三方依赖:
|
|
4
|
+
* ~/.dsh/storages/session_projcache/sessions/<session id>.json 水位 / 运行状态 / 项目名
|
|
5
|
+
* ~/.dsh/storages/workspace.json 工作区 id / 标题 / 路径
|
|
6
|
+
* ~/.dsh/sessions/<slug>/<session id>/session.v4.jsonl.zstd 会话日志(peer_peek 用)
|
|
7
|
+
* 平级会话的判据:投影缓存里 **没有** record.rows.subagent.identity(子会话才有)。
|
|
8
|
+
*/
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import os from 'node:os';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import zlib from 'node:zlib';
|
|
13
|
+
|
|
14
|
+
/** 收到的消息必须能自报来源:宿主把投递消息记成 source.kind === "user",不加标记就与用户消息无法区分。 */
|
|
15
|
+
const MARK_HEAD = '【peer-link ← ';
|
|
16
|
+
|
|
17
|
+
export function peerMarker(senderId) {
|
|
18
|
+
return MARK_HEAD + (typeof senderId === 'string' && senderId !== '' ? senderId : 'unknown') + '】';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------- 本地只读状态
|
|
22
|
+
|
|
23
|
+
/** DSH 主目录候选(环境变量优先,回退 ~/.dsh)。 */
|
|
24
|
+
function dshRoots() {
|
|
25
|
+
const roots = [];
|
|
26
|
+
const env = process.env.DSH_HOME;
|
|
27
|
+
if (typeof env === 'string' && env.trim() !== '') roots.push(env.trim());
|
|
28
|
+
roots.push(path.join(os.homedir(), '.dsh'));
|
|
29
|
+
return roots;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** 在候选根下找第一个存在的目录;找不到返回 null(不抛)。 */
|
|
33
|
+
function findDir(relative) {
|
|
34
|
+
for (const root of dshRoots()) {
|
|
35
|
+
const p = path.join(root, relative);
|
|
36
|
+
try {
|
|
37
|
+
if (fs.statSync(p).isDirectory()) return p;
|
|
38
|
+
} catch (error) {
|
|
39
|
+
void error;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 读 storages 下的一个文件;找不到返回 null(不抛)。 */
|
|
46
|
+
function findStateFile(relative) {
|
|
47
|
+
for (const root of dshRoots()) {
|
|
48
|
+
const p = path.join(root, relative);
|
|
49
|
+
try {
|
|
50
|
+
if (fs.statSync(p).isFile()) return p;
|
|
51
|
+
} catch (error) {
|
|
52
|
+
void error;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 安全地按路径取值;无法获取返回 undefined(与 peer-link 一致,区别于 lib/text.mjs 的 null)。 */
|
|
59
|
+
export function pick(node, ...rest) {
|
|
60
|
+
let cur = node;
|
|
61
|
+
for (const key of rest) {
|
|
62
|
+
if (cur === null || typeof cur !== 'object' || !(key in cur)) return undefined;
|
|
63
|
+
cur = cur[key];
|
|
64
|
+
}
|
|
65
|
+
return cur;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 把「工作区 id / 标题 / 路径」解析成工作区 id。
|
|
70
|
+
* 数据源:<DSH_HOME>/storages/workspace.json 的 tables.workspaces[<id>] = { path, title, sessionIds }。
|
|
71
|
+
* 找不到返回 null(不抛);读不到文件时原样返回入参,交给 session.create 去报 workspace/not-found。
|
|
72
|
+
*/
|
|
73
|
+
export function resolveWorkspaceId(key) {
|
|
74
|
+
const raw = String(key).trim();
|
|
75
|
+
if (raw === '') return null;
|
|
76
|
+
const file = findStateFile(path.join('storages', 'workspace.json'));
|
|
77
|
+
if (file === null) return raw;
|
|
78
|
+
let table;
|
|
79
|
+
try {
|
|
80
|
+
table = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
81
|
+
} catch (error) {
|
|
82
|
+
void error;
|
|
83
|
+
return raw;
|
|
84
|
+
}
|
|
85
|
+
const workspaces = pick(table, 'tables', 'workspaces');
|
|
86
|
+
if (workspaces === undefined) return raw;
|
|
87
|
+
if (workspaces[raw] !== undefined) return raw;
|
|
88
|
+
const trimSlash = (v) => String(v).replace(/\/+$/, '');
|
|
89
|
+
const want = trimSlash(raw);
|
|
90
|
+
for (const id of Object.keys(workspaces)) {
|
|
91
|
+
const ws = workspaces[id];
|
|
92
|
+
if (ws === null || typeof ws !== 'object') continue;
|
|
93
|
+
if (ws.title === raw) return id;
|
|
94
|
+
if (ws.path !== undefined) {
|
|
95
|
+
if (trimSlash(ws.path) === want) return id;
|
|
96
|
+
// 注册表存的是真实路径,而入参可能是 /root/<项目名> 这类软链接 —— 两条都认
|
|
97
|
+
try {
|
|
98
|
+
if (trimSlash(fs.realpathSync(ws.path)) === trimSlash(fs.realpathSync(raw))) return id;
|
|
99
|
+
} catch (error) {
|
|
100
|
+
void error;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** 读一个会话的投影缓存(水位 / 运行状态 / 项目名的来源)。读不到返回 null。 */
|
|
108
|
+
export function readProjCache(sessionId) {
|
|
109
|
+
const dir = findDir('storages/session_projcache/sessions');
|
|
110
|
+
if (dir === null) return null;
|
|
111
|
+
try {
|
|
112
|
+
return JSON.parse(fs.readFileSync(path.join(dir, sessionId + '.json'), 'utf8'));
|
|
113
|
+
} catch (error) {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** 上下文水位(百分比);读不到返回 null。 */
|
|
119
|
+
export function pressurePct(cache) {
|
|
120
|
+
const used = pick(cache, 'record', 'rows', 'contextPressure', 'val', 'pressureTokens');
|
|
121
|
+
const win = pick(cache, 'record', 'rows', 'contextPressure', 'val', 'contextWindow');
|
|
122
|
+
if (!Number.isFinite(used) || !Number.isFinite(win) || win === 0) return null;
|
|
123
|
+
return (used / win) * 100;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** 子会话才有 record.rows.subagent.identity;没有就是平级会话。 */
|
|
127
|
+
export function isSubagent(cache) {
|
|
128
|
+
return (
|
|
129
|
+
pick(cache, 'record', 'rows', 'subagent', 'val', 'identity') !== undefined ||
|
|
130
|
+
pick(cache, 'record', 'rows', 'subagent', 'identity') !== undefined
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** 项目名:先看首条提示里的 Projects/<名>/ 或「<名>」项目,再退回标题。 */
|
|
135
|
+
export function projectName(cache) {
|
|
136
|
+
const text = pick(cache, 'record', 'rows', 'titleInput', 'val', 'first', 'text');
|
|
137
|
+
if (typeof text === 'string') {
|
|
138
|
+
const a = text.match(/Projects\/([^\/\s]+)\//);
|
|
139
|
+
if (a !== null) return a[1];
|
|
140
|
+
const b = text.match(/「([^」]+)」项目/);
|
|
141
|
+
if (b !== null) return b[1];
|
|
142
|
+
}
|
|
143
|
+
const title = pick(cache, 'record', 'rows', 'title', 'val');
|
|
144
|
+
return typeof title === 'string' && title.trim() !== '' ? title.trim() : '-';
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** openStep 非空 = 正在跑一个步骤。 */
|
|
148
|
+
export function isBusy(cache) {
|
|
149
|
+
if (cache === null) return null;
|
|
150
|
+
const openStep = pick(cache, 'record', 'rows', 'sessionStats', 'val', 'openStep');
|
|
151
|
+
return openStep !== null && openStep !== undefined;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** 把时间戳格式化成 MM-DD HH:MM(本地时区)。 */
|
|
155
|
+
export function stamp(ms) {
|
|
156
|
+
if (!Number.isFinite(ms)) return '--';
|
|
157
|
+
const d = new Date(ms);
|
|
158
|
+
const p = (n) => String(n).padStart(2, '0');
|
|
159
|
+
return p(d.getMonth() + 1) + '-' + p(d.getDate()) + ' ' + p(d.getHours()) + ':' + p(d.getMinutes());
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** 一行式摘要(给 peer_list 用)。 */
|
|
163
|
+
export function rowLine(sessionId, cache, fallback) {
|
|
164
|
+
const pct = cache === null ? null : pressurePct(cache);
|
|
165
|
+
const state = isBusy(cache) === true ? 'running' : fallback === undefined ? 'idle' : fallback;
|
|
166
|
+
const proj = cache === null ? '-' : projectName(cache);
|
|
167
|
+
const kind = cache === null ? '' : isSubagent(cache) ? ' [子会话]' : ' [平级]';
|
|
168
|
+
const water = pct === null ? '水位 ?' : '水位 ' + pct.toFixed(1) + '%';
|
|
169
|
+
return sessionId + ' | ' + proj + ' | ' + water + ' | ' + state + kind;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* 会话清单:controller.list 的结果按 origin 过滤。
|
|
174
|
+
* 冷会话在这一步就会被服务端从持久化里恢复成可寻址的行,所以 peer_list 本身不花 token。
|
|
175
|
+
*/
|
|
176
|
+
export async function sessionRows(controller, includeSubagents) {
|
|
177
|
+
const value = await controller.list({}, new AbortController().signal);
|
|
178
|
+
return value.items.filter((row) => includeSubagents === true || row.origin !== 'subagent');
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ---------------------------------------------------------------- 会话日志(peer_peek)
|
|
182
|
+
|
|
183
|
+
/** 解 zstd **多帧**日志并按行解析成事件数组;本 Node 构建没有 zstd 时返回 null。 */
|
|
184
|
+
export function decodeSessionLog(file) {
|
|
185
|
+
if (typeof zlib.zstdDecompressSync !== 'function') return null;
|
|
186
|
+
const buf = fs.readFileSync(file);
|
|
187
|
+
const magic = Buffer.from([0x28, 0xb5, 0x2f, 0xfd]);
|
|
188
|
+
const starts = [];
|
|
189
|
+
let idx = 0;
|
|
190
|
+
for (;;) {
|
|
191
|
+
const at = buf.indexOf(magic, idx);
|
|
192
|
+
if (at < 0) break;
|
|
193
|
+
starts.push(at);
|
|
194
|
+
idx = at + 4;
|
|
195
|
+
}
|
|
196
|
+
const parts = [];
|
|
197
|
+
for (let i = 0; i < starts.length; i++) {
|
|
198
|
+
const end = i + 1 < starts.length ? starts[i + 1] : buf.length;
|
|
199
|
+
try {
|
|
200
|
+
parts.push(zlib.zstdDecompressSync(buf.subarray(starts[i], end)).toString('utf8'));
|
|
201
|
+
} catch (error) {
|
|
202
|
+
void error;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return parts
|
|
206
|
+
.join('')
|
|
207
|
+
.split(String.fromCharCode(10))
|
|
208
|
+
.filter(Boolean)
|
|
209
|
+
.map((line) => {
|
|
210
|
+
try {
|
|
211
|
+
return JSON.parse(line);
|
|
212
|
+
} catch (error) {
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
})
|
|
216
|
+
.filter((x) => x !== null);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** 在 <DSH_HOME>/sessions/<任意 slug>/<session id>/ 下找日志文件(不猜 slug)。 */
|
|
220
|
+
export function findSessionLog(sessionId) {
|
|
221
|
+
const root = findDir('sessions');
|
|
222
|
+
if (root === null) return null;
|
|
223
|
+
let buckets = [];
|
|
224
|
+
try {
|
|
225
|
+
buckets = fs.readdirSync(root);
|
|
226
|
+
} catch (error) {
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
for (const bucket of buckets) {
|
|
230
|
+
const file = path.join(root, bucket, sessionId, 'session.v4.jsonl.zstd');
|
|
231
|
+
try {
|
|
232
|
+
if (fs.statSync(file).isFile()) return file;
|
|
233
|
+
} catch (error) {
|
|
234
|
+
void error;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** 从事件流里抽出「人/会话说得出口的话」,跳过运行时上下文与技能目录注入。 */
|
|
241
|
+
export function conversation(events, limit) {
|
|
242
|
+
const skip = { 'runtime-context': true, 'skill-catalog': true };
|
|
243
|
+
const items = [];
|
|
244
|
+
for (const e of events) {
|
|
245
|
+
if (e.type === 'user/message') {
|
|
246
|
+
const kind = pick(e, 'data', 'source', 'kind');
|
|
247
|
+
if (typeof kind === 'string' && skip[kind] === true) continue;
|
|
248
|
+
const blocks = pick(e, 'data', 'content');
|
|
249
|
+
const text = Array.isArray(blocks) ? blocks.map((c) => (c && c.text) || '').join(' ') : '';
|
|
250
|
+
if (text.trim() !== '') items.push({ t: e.time, who: typeof kind === 'string' ? kind : 'user', text });
|
|
251
|
+
} else if (e.type === 'assistant/message') {
|
|
252
|
+
const blocks = pick(e, 'data', 'message', 'content');
|
|
253
|
+
const text = Array.isArray(blocks)
|
|
254
|
+
? blocks.filter((b) => b && b.type === 'text').map((b) => b.text || '').join(' ')
|
|
255
|
+
: '';
|
|
256
|
+
if (text.trim() !== '') items.push({ t: e.time, who: 'assistant', text });
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return items.slice(-limit);
|
|
260
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** 阶段决策:纯函数,便于单测。 */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @param {{ratio:number|null, compactionsUsed:number, cfg:object, lockState:string}} input
|
|
5
|
+
* @returns {{action:string, reason:string}}
|
|
6
|
+
* action ∈ continue | pause | checkpoint | new_session | force_compact | blocked | unknown
|
|
7
|
+
*/
|
|
8
|
+
export function decide(input) {
|
|
9
|
+
const cfg = input.cfg || {};
|
|
10
|
+
const warn = cfg.warnRatio === undefined ? 0.32 : cfg.warnRatio;
|
|
11
|
+
const handover = cfg.handoverRatio === undefined ? 0.35 : cfg.handoverRatio;
|
|
12
|
+
const hard = cfg.hardRatio === undefined ? 0.85 : cfg.hardRatio;
|
|
13
|
+
const maxComp = cfg.maxCompactions === undefined ? 3 : cfg.maxCompactions;
|
|
14
|
+
const used = typeof input.compactionsUsed === 'number' ? input.compactionsUsed : 0;
|
|
15
|
+
if (input.lockState === 'orphaned') {
|
|
16
|
+
return { action: 'blocked', reason: '检测到未闭合的压缩锁(compaction/start 无配对 end):先处理再排新压缩' };
|
|
17
|
+
}
|
|
18
|
+
if (input.ratio === null || input.ratio === undefined) {
|
|
19
|
+
return { action: 'unknown', reason: '水位读不到:人工判断,不要盲目压缩' };
|
|
20
|
+
}
|
|
21
|
+
if (input.ratio >= hard) {
|
|
22
|
+
if (used >= maxComp) return { action: 'new_session', reason: '已到硬顶且压缩额度用尽,应写交接并建新会话' };
|
|
23
|
+
return { action: 'force_compact', reason: '已到硬顶(' + Math.round(hard * 100) + '%):强制压缩兜底,哪怕任务中途' };
|
|
24
|
+
}
|
|
25
|
+
if (input.ratio >= handover) {
|
|
26
|
+
if (used >= maxComp) return { action: 'new_session', reason: '已压缩 ' + used + '/' + maxComp + ' 次:写交接并建新会话(现行流程)' };
|
|
27
|
+
return { action: 'checkpoint', reason: '过交接线(' + Math.round(handover * 100) + '%):写交接,随后压缩,再读交接续跑' };
|
|
28
|
+
}
|
|
29
|
+
if (input.ratio >= warn) {
|
|
30
|
+
if (used >= maxComp) return { action: 'new_session', reason: '已压缩 ' + used + '/' + maxComp + ' 次:到线即走换会话' };
|
|
31
|
+
return { action: 'pause', reason: '过提醒线(' + Math.round(warn * 100) + '%):建议在阶段完成处调 ho_checkpoint(关键步骤返回 pause)' };
|
|
32
|
+
}
|
|
33
|
+
return { action: 'continue', reason: '水位正常' };
|
|
34
|
+
}
|