dsh-session-rollover 0.0.0-stage → 1.2.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 (69) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/LICENSE +201 -0
  3. package/LICENSE.upstream +21 -0
  4. package/NOTICE +9 -0
  5. package/README.md +157 -2
  6. package/assets/icon.png +0 -0
  7. package/assets/icon.svg +7 -0
  8. package/client.js +656 -0
  9. package/compatibility/policy.json +21 -0
  10. package/compatibility/runtime-0.2.2-rc.1.json +32 -0
  11. package/cordis.patch.yml +9 -0
  12. package/docs/COMPATIBILITY.md +70 -0
  13. package/docs/DETAIL.md +44 -0
  14. package/docs/DETAIL.zh-CN.md +49 -0
  15. package/docs/DEVELOPMENT.md +104 -0
  16. package/docs/PUBLISHING.md +80 -0
  17. package/docs/architecture.md +71 -0
  18. package/docs/live-host-validation.json +37 -0
  19. package/docs/marketplace-validation.md +22 -0
  20. package/docs/prototype-validation.json +10 -0
  21. package/docs/r3-demo.html +981 -0
  22. package/docs/r3-validation.md +59 -0
  23. package/docs/screenshots/help-dark.png +0 -0
  24. package/docs/screenshots/help-light.png +0 -0
  25. package/docs/screenshots/host-archived.png +0 -0
  26. package/docs/screenshots/host-dark.png +0 -0
  27. package/docs/screenshots/host-rollover.png +0 -0
  28. package/docs/screenshots/prototype-fixed.png +0 -0
  29. package/index.js +2 -0
  30. package/locale/en.json +7 -0
  31. package/locale/zh.json +7 -0
  32. package/marketplace/Oak-liuhj__dsh-session-rollover.yml +7 -0
  33. package/marketplace/SUBMISSION.md +37 -0
  34. package/package.json +103 -4
  35. package/screenshots.json +6 -0
  36. package/scripts/build-client.mjs +12 -0
  37. package/scripts/check-dsh-compatibility.mjs +44 -0
  38. package/scripts/check-package.mjs +51 -0
  39. package/scripts/check-release.mjs +76 -0
  40. package/scripts/dsh-contracts.mjs +69 -0
  41. package/src/application/handoff-message.mjs +53 -0
  42. package/src/application/rollover.mjs +236 -0
  43. package/src/assembly.mjs +4 -0
  44. package/src/client/rollover-ui.js +311 -0
  45. package/src/client/toast-client.js +342 -0
  46. package/src/cluster.mjs +3 -0
  47. package/src/domain/cluster-index.mjs +65 -0
  48. package/src/domain/handoff.mjs +67 -0
  49. package/src/domain/inflight.mjs +25 -0
  50. package/src/domain/mode.mjs +34 -0
  51. package/src/domain/naming.mjs +78 -0
  52. package/src/domain/summary.mjs +68 -0
  53. package/src/domain/todos.mjs +23 -0
  54. package/src/domain/turns.mjs +43 -0
  55. package/src/handoff.mjs +2 -0
  56. package/src/inflight.mjs +2 -0
  57. package/src/mode.mjs +2 -0
  58. package/src/naming.mjs +2 -0
  59. package/src/notices.mjs +2 -0
  60. package/src/server/compatibility.mjs +84 -0
  61. package/src/server/host-adapter.mjs +146 -0
  62. package/src/server/notice-route.mjs +104 -0
  63. package/src/server/plugin.mjs +447 -0
  64. package/src/storage/index-store.mjs +48 -0
  65. package/src/storage/notices.mjs +117 -0
  66. package/src/storage/rollover-state.mjs +75 -0
  67. package/src/summary.mjs +2 -0
  68. package/src/todos.mjs +2 -0
  69. package/src/turns.mjs +2 -0
@@ -0,0 +1,342 @@
1
+ // dsh-session-rollover · 浏览器半边(B6 一次性提示)。
2
+ // 通道:server 半把「该提示什么」落盘,这里同源 GET /api-dsh-session-rollover/pending 取走渲染。
3
+ // 不碰宿主 Toast 实现、不建 React hooks 面(D-36:bindInjectSources 会剥掉 hooks 字段),纯 DOM。
4
+ // 形态/时长按 UI 定稿:底部居中、距输入框上沿 12px、max-width 420px、
5
+ // 12.5px/18px、常规 5s、rollback-failed 8s、补显延迟 300ms、去重 (会话,原因)。
6
+ (function () {
7
+ if (typeof window === 'undefined' || !window.__ModuleLoader__) return;
8
+ window.__ModuleLoader__.load({
9
+ // 必须与 dsh.client 条目的 id 逐字一致,使用公开包名。
10
+ id: 'dsh-session-rollover',
11
+ factory: function (require) {
12
+ var module = {exports: {}};
13
+ var NS = 'dsh-session-rollover';
14
+ var API = '/api-dsh-session-rollover/pending';
15
+ var POLL_MS = 1000;
16
+ var REPLAY_DELAY_MS = 300;
17
+ var FADE_MS = 120;
18
+ var MAX_SHOWN = 500;
19
+
20
+ function debug(e) {
21
+ try {
22
+ if (window.console && console.debug) console.debug('[' + NS + '] toast skipped:', e);
23
+ } catch (_) { /* 静默 */
24
+ }
25
+ }
26
+
27
+ function durationFor(reason) {
28
+ return reason === 'rollback-failed' ? 8000 : 5000;
29
+ }
30
+
31
+ var CSS = [
32
+ '.dsr-toast-layer{position:fixed;left:50%;transform:translateX(-50%);bottom:96px;z-index:2147483000;display:flex;flex-direction:column;align-items:center;gap:8px;max-width:min(420px,calc(100vw - 32px));pointer-events:none;}',
33
+ '.dsr-toast{box-sizing:border-box;max-width:420px;padding:10px 14px;border-radius:8px;background:#FFFFFF;border:1px solid rgba(0,0,0,.08);box-shadow:0 6px 24px rgba(0,0,0,.12);color:#4B5563;font-size:12.5px;line-height:18px;text-align:left;white-space:pre-wrap;overflow-wrap:anywhere;opacity:0;transform:translateY(4px);transition:opacity 120ms ease,transform 120ms ease;}',
34
+ '.dsr-toast.is-in{opacity:1;transform:translateY(0);}',
35
+ '.dsr-toast.is-out{opacity:0;transform:translateY(4px);}',
36
+ 'html[data-theme="dark"] .dsr-toast,html.dark .dsr-toast{background:#1F2430;color:#D1D5DB;border-color:rgba(255,255,255,.10);box-shadow:0 6px 24px rgba(0,0,0,.5);}',
37
+ '@media (prefers-color-scheme: dark){.dsr-toast{background:#1F2430;color:#D1D5DB;border-color:rgba(255,255,255,.10);box-shadow:0 6px 24px rgba(0,0,0,.5);}}',
38
+ '@media (prefers-reduced-motion: reduce){.dsr-toast{transition:opacity 120ms ease;transform:none;}.dsr-toast.is-in,.dsr-toast.is-out{transform:none;}}',
39
+ ].join('\n');
40
+
41
+ function timersOf(env) {
42
+ var win = env.win || window;
43
+ return env.timers || {
44
+ setTimeout: function (fn, ms) {
45
+ return win.setTimeout(fn, ms);
46
+ },
47
+ clearTimeout: function (id) {
48
+ return win.clearTimeout(id);
49
+ },
50
+ };
51
+ }
52
+
53
+ /** 轻量 toaster:单例容器、每条一个 role=status 节点、到时自动摘除。 */
54
+ function createToaster(env) {
55
+ env = env || {};
56
+ var doc = env.doc || document;
57
+ var win = env.win || window;
58
+ var timers = timersOf(env);
59
+ var layer = null;
60
+ var items = [];
61
+ var cssInjected = false;
62
+
63
+ function injectCss() {
64
+ if (cssInjected || !doc.head) return;
65
+ try {
66
+ var style = doc.createElement('style');
67
+ style.setAttribute('data-dsh-plugin', NS);
68
+ style.textContent = CSS;
69
+ doc.head.appendChild(style);
70
+ cssInjected = true;
71
+ } catch (e) {
72
+ debug(e);
73
+ }
74
+ }
75
+
76
+ function ensureLayer() {
77
+ if (layer && layer.parentNode) return layer;
78
+ layer = doc.createElement('div');
79
+ layer.className = 'dsr-toast-layer';
80
+ doc.body.appendChild(layer);
81
+ return layer;
82
+ }
83
+
84
+ function place() {
85
+ try {
86
+ var ed = doc.querySelector('div[contenteditable="true"]');
87
+ var rect = ed && ed.getBoundingClientRect ? ed.getBoundingClientRect() : null;
88
+ if (rect && rect.top > 0) {
89
+ layer.style.bottom = Math.max(12, Math.round(win.innerHeight - rect.top + 12)) + 'px';
90
+ }
91
+ } catch (e) {
92
+ debug(e);
93
+ }
94
+ }
95
+
96
+ function remove(rec) {
97
+ for (var i = 0; i < rec.timers.length; i += 1) {
98
+ try {
99
+ timers.clearTimeout(rec.timers[i]);
100
+ } catch (_) { /* 已触发 */
101
+ }
102
+ }
103
+ try {
104
+ if (rec.el.parentNode) rec.el.parentNode.removeChild(rec.el);
105
+ } catch (_) { /* 已摘除 */
106
+ }
107
+ var at = items.indexOf(rec);
108
+ if (at >= 0) items.splice(at, 1);
109
+ if (layer && !layer.childNodes.length && layer.parentNode) {
110
+ try {
111
+ layer.parentNode.removeChild(layer);
112
+ } catch (_) { /* 已摘除 */
113
+ }
114
+ layer = null;
115
+ }
116
+ }
117
+
118
+ function show(notice) {
119
+ injectCss();
120
+ ensureLayer();
121
+ place();
122
+ var el = doc.createElement('div');
123
+ el.className = 'dsr-toast';
124
+ el.setAttribute('role', 'status');
125
+ el.setAttribute('aria-live', 'polite');
126
+ el.setAttribute('aria-atomic', 'true');
127
+ el.textContent = String((notice && notice.text) || '');
128
+ layer.appendChild(el);
129
+ var rec = {el: el, timers: []};
130
+ items.push(rec);
131
+ var duration = durationFor(notice && notice.reason);
132
+ rec.timers.push(timers.setTimeout(function () {
133
+ try {
134
+ el.className = 'dsr-toast is-in';
135
+ } catch (_) { /* 已摘除 */
136
+ }
137
+ }, 0));
138
+ rec.timers.push(timers.setTimeout(function () {
139
+ try {
140
+ el.className = 'dsr-toast is-out';
141
+ } catch (_) { /* 已摘除 */
142
+ }
143
+ }, Math.max(0, duration - FADE_MS)));
144
+ rec.timers.push(timers.setTimeout(function () {
145
+ remove(rec);
146
+ }, duration));
147
+ return el;
148
+ }
149
+
150
+ function destroy() {
151
+ items.slice().forEach(remove);
152
+ if (layer && layer.parentNode) {
153
+ try {
154
+ layer.parentNode.removeChild(layer);
155
+ } catch (_) { /* 已摘除 */
156
+ }
157
+ }
158
+ layer = null;
159
+ }
160
+
161
+ return {
162
+ show: show, destroy: destroy, count: function () {
163
+ return items.length;
164
+ }, _items: items
165
+ };
166
+ }
167
+
168
+ /** 当前会话:宿主 localStorage 的 dsh.sessions.current({sessionId}),URL ?session= 兜底。 */
169
+ function currentSessionId(env) {
170
+ var win = (env && env.win) || window;
171
+ try {
172
+ var raw = win.localStorage && win.localStorage.getItem('dsh.sessions.current');
173
+ if (raw) {
174
+ var parsed = JSON.parse(raw);
175
+ if (parsed && typeof parsed === 'object') {
176
+ if (parsed.sessionId) return String(parsed.sessionId);
177
+ if (parsed.id) return String(parsed.id);
178
+ } else if (typeof parsed === 'string' && parsed) return parsed;
179
+ }
180
+ } catch (e) {
181
+ debug(e);
182
+ }
183
+ try {
184
+ var m = /(?:^|[?&])session=([^&]+)/.exec((win.location && win.location.search) || '');
185
+ if (m) return decodeURIComponent(m[1]);
186
+ } catch (e) {
187
+ debug(e);
188
+ }
189
+ return '';
190
+ }
191
+
192
+ /** 轮询取件。会话切换后先等 300ms(补显不与首屏抢帧);页面隐藏不取,回来立即取。 */
193
+ function start(env, toaster) {
194
+ env = env || {};
195
+ var doc = env.doc || document;
196
+ var win = env.win || window;
197
+ var timers = timersOf(env);
198
+ var stopped = false;
199
+ var timer = null;
200
+ var session = '';
201
+ var shown = {};
202
+ var shownCount = 0;
203
+
204
+ function schedule(ms) {
205
+ if (stopped) return;
206
+ timer = timers.setTimeout(tick, ms);
207
+ }
208
+
209
+ function fetchPending(sid) {
210
+ var doFetch = env.fetch || (win && win.fetch);
211
+ if (typeof doFetch !== 'function') return;
212
+ var p;
213
+ try {
214
+ p = doFetch(API + '?session=' + encodeURIComponent(sid), {
215
+ cache: 'no-store',
216
+ credentials: 'same-origin'
217
+ });
218
+ } catch (e) {
219
+ debug(e);
220
+ return;
221
+ }
222
+ Promise.resolve(p).then(function (res) {
223
+ if (!res || !res.ok) return null;
224
+ return res.json();
225
+ }).then(function (body) {
226
+ if (stopped || !body || !Array.isArray(body.notices)) return;
227
+ body.notices.forEach(function (n) {
228
+ if (!n || !n.id || shown[n.id]) return;
229
+ shown[n.id] = 1;
230
+ shownCount += 1;
231
+ if (shownCount > MAX_SHOWN) {
232
+ shown = {};
233
+ shown[n.id] = 1;
234
+ shownCount = 1;
235
+ }
236
+ try {
237
+ toaster.show(n);
238
+ } catch (e) {
239
+ debug(e);
240
+ }
241
+ });
242
+ }).catch(function (e) {
243
+ debug(e);
244
+ });
245
+ }
246
+
247
+ function tick() {
248
+ timer = null;
249
+ if (stopped) return;
250
+ try {
251
+ if (doc.visibilityState === 'hidden') {
252
+ schedule(POLL_MS);
253
+ return;
254
+ }
255
+ var sid = currentSessionId(env);
256
+ if (!sid) {
257
+ schedule(POLL_MS);
258
+ return;
259
+ }
260
+ if (sid !== session) {
261
+ session = sid;
262
+ schedule(REPLAY_DELAY_MS);
263
+ return;
264
+ }
265
+ fetchPending(sid);
266
+ } catch (e) {
267
+ debug(e);
268
+ }
269
+ schedule(POLL_MS);
270
+ }
271
+
272
+ function onVisibility() {
273
+ if (!stopped && doc.visibilityState !== 'hidden') {
274
+ try {
275
+ tick();
276
+ } catch (e) {
277
+ debug(e);
278
+ }
279
+ }
280
+ }
281
+
282
+ function stop() {
283
+ stopped = true;
284
+ if (timer != null) {
285
+ try {
286
+ timers.clearTimeout(timer);
287
+ } catch (_) { /* 已触发 */
288
+ }
289
+ timer = null;
290
+ }
291
+ try {
292
+ doc.removeEventListener && doc.removeEventListener('visibilitychange', onVisibility);
293
+ } catch (_) { /* 无监听面 */
294
+ }
295
+ }
296
+
297
+ try {
298
+ doc.addEventListener && doc.addEventListener('visibilitychange', onVisibility);
299
+ } catch (e) {
300
+ debug(e);
301
+ }
302
+ tick();
303
+ return stop;
304
+ }
305
+
306
+ function apply(ctx) {
307
+ try {
308
+ var env = {doc: document, win: window};
309
+ var toaster = createToaster(env);
310
+ var stop = start(env, toaster);
311
+ var dispose = function () {
312
+ try {
313
+ stop();
314
+ } catch (_) { /* 已停 */
315
+ }
316
+ try {
317
+ toaster.destroy();
318
+ } catch (_) { /* 已清 */
319
+ }
320
+ };
321
+ if (ctx && typeof ctx.effect === 'function') ctx.effect(function () {
322
+ return dispose;
323
+ }, NS + ': toast');
324
+ else if (typeof window.addEventListener === 'function') window.addEventListener('pagehide', dispose);
325
+ } catch (e) {
326
+ debug(e);
327
+ }
328
+ }
329
+
330
+ module.exports = {
331
+ name: NS,
332
+ inject: [],
333
+ apply: apply,
334
+ __test: {
335
+ durationFor: durationFor, createToaster: createToaster, currentSessionId: currentSessionId,
336
+ start: start, API: API, REPLAY_DELAY_MS: REPLAY_DELAY_MS, POLL_MS: POLL_MS,
337
+ },
338
+ };
339
+ return module.exports;
340
+ },
341
+ });
342
+ })();
@@ -0,0 +1,3 @@
1
+ // 兼容旧索引入口;纯内存规则与磁盘存储分层维护。
2
+ export {FIELDS, createClusterIndex, rebuildFromTitles} from './domain/cluster-index.mjs';
3
+ export {resolveDataDir, attachFilePersistence} from './storage/index-store.mjs';
@@ -0,0 +1,65 @@
1
+ // 会话簇索引与标题对账:只操作内存,不访问宿主或磁盘。
2
+ import {parseVolume} from './naming.mjs';
3
+
4
+ export const FIELDS = ['cluster_id', 'parent_session_id', 'vol_no', 'name_suffix', 'rollover_state', 'readonly_by_rollover', 'summary_retry'];
5
+
6
+ /** 纯内存索引;serialize 输出由 storage 层负责持久化。 */
7
+ export function createClusterIndex(init = {}) {
8
+ /** sessions: { [sessionId]: {cluster_id, parent_session_id, vol_no, name_suffix, rollover_state, readonly_by_rollover, summary_retry} } */
9
+ const db = {sessions: {}, ...init};
10
+ return {
11
+ record({
12
+ sessionId,
13
+ clusterId,
14
+ parentSessionId,
15
+ volNo,
16
+ nameSuffix,
17
+ rolloverState = 'active',
18
+ readonlyByRollover = false,
19
+ summaryRetry = 0
20
+ }) {
21
+ if (!sessionId || !clusterId) throw new Error('cluster-index: sessionId & clusterId required');
22
+ const rec = {
23
+ cluster_id: clusterId, parent_session_id: parentSessionId ?? null, vol_no: volNo,
24
+ name_suffix: nameSuffix, rollover_state: rolloverState,
25
+ readonly_by_rollover: !!readonlyByRollover, summary_retry: summaryRetry,
26
+ };
27
+ for (const k of FIELDS) if (!(k in rec)) throw new Error('missing field ' + k);
28
+ db.sessions[sessionId] = rec; // 幂等:同 sessionId 覆盖
29
+ return rec;
30
+ },
31
+ get: (sessionId) => db.sessions[sessionId] ?? null,
32
+ cluster: (clusterId) => Object.entries(db.sessions).filter(([, r]) => r.cluster_id === clusterId).map(([id, r]) => ({sessionId: id, ...r})),
33
+ /** Q3 定稿:turn_counts 与簇索引同文件同原子提交(一个 JSON 一次 rename) */
34
+ setTurnCounts(map = {}) {
35
+ db.turn_counts = {...map};
36
+ return db.turn_counts;
37
+ },
38
+ turnCounts: () => db.turn_counts ?? {},
39
+ serialize: () => JSON.stringify(db, null, 2),
40
+ };
41
+ }
42
+
43
+ /** 对账重建(索引=缓存的兑现):从标题 -N 后缀 + 交接消息 cluster: 标记重建七字段记录。
44
+ * entries: [{sessionId,title}];markers: {sessionId: {clusterId,parentSessionId}}(交接正文⑦项解析所得,可缺) */
45
+ export function rebuildFromTitles(entries, markers = {}) {
46
+ const idx = createClusterIndex();
47
+ const groups = {}; // baseTitle -> {clusterId, members:[{sessionId,vol}]}
48
+ for (const {sessionId, title} of entries) {
49
+ const p = parseVolume(title); // 恒返回 {base, k},无后缀 k=0
50
+ (groups[p.base] ??= {members: []}).members.push({sessionId, vol: p.k});
51
+ }
52
+ for (const [base, g] of Object.entries(groups)) {
53
+ const marked = g.members.map((m) => markers[m.sessionId]).find(Boolean);
54
+ const clusterId = marked?.clusterId ?? `recon:${base}`;
55
+ const parent = marked?.parentSessionId ?? null;
56
+ for (const m of g.members) {
57
+ idx.record({
58
+ sessionId: m.sessionId, clusterId, parentSessionId: parent,
59
+ volNo: m.vol, nameSuffix: m.vol ? `-${m.vol}` : '',
60
+ rolloverState: 'reconstructed',
61
+ });
62
+ }
63
+ }
64
+ return idx;
65
+ }
@@ -0,0 +1,67 @@
1
+ // dsh-session-rollover · 交接七件套(接口契约 §3.2;v1.2 表 15 取数 + 八段排版,段名数组由调用方定,兼容 4/8 段裁定)
2
+ export const MAX_BYTES = 16 * 1024; // 16KB 硬预算(ralph 同款上限)
3
+
4
+ const encoder = new TextEncoder();
5
+
6
+ /** 摘要重试状态机(B5):生成失败→不分卷,记 summary-error,下轮重试 ≤2,超限只提醒不动作 */
7
+ export function createSummaryRetry(maxRetries = 2) {
8
+ let attempts = 0;
9
+ return {
10
+ get attempts() {
11
+ return attempts;
12
+ },
13
+ reset() {
14
+ attempts = 0;
15
+ },
16
+ recordFailure() {
17
+ attempts++;
18
+ return attempts <= maxRetries ? {action: 'retry', attempts} : {action: 'remind-only', attempts};
19
+ },
20
+ };
21
+ }
22
+
23
+ /**
24
+ * 组七项交接。summarySegments: [[段名, 文本]](四段/八段皆可,裁定后传对应表)。
25
+ * B4 无待办→「无结构化待办」;B3 active 多项逐项列;⑤ 抓不到约束→「未识别到显式约束」。
26
+ * @returns {{ok:true, payload:object, bytes:number} | {ok:false, reason:string, bytes?:number}}
27
+ */
28
+ export function buildHandoff(input) {
29
+ const {
30
+ summarySegments, todos, activeIndexes = [], currentStepIndex = null,
31
+ openJobs = [], constraints = [], changedFiles = [],
32
+ cluster, fromSession, toSessionTitle,
33
+ } = input;
34
+ if (!Array.isArray(summarySegments) || summarySegments.length === 0) return {ok: false, reason: 'summary-missing'}; // B5 前置:调用方失败即不分卷
35
+ const todoItems = Array.isArray(todos) ? todos : null;
36
+ const payload = {
37
+ rollover: 'v1',
38
+ from_session_id: input.fromSessionId ?? null,
39
+ cluster: cluster ?? {degraded_link: `from:${input.fromSessionId ?? 'unknown'}`}, // ⑦ 缺失降级单向链接
40
+ summary: Object.fromEntries(summarySegments),
41
+ todos: todoItems ?? '无结构化待办', // ② 双份之二(JSON 全表)
42
+ current_step: currentStepIndex == null ? '尚未开始' : {index: currentStepIndex, active_items: activeIndexes}, // ③
43
+ open_jobs: openJobs.map((j) => ({
44
+ id: j.id,
45
+ command: j.command,
46
+ status: j.status,
47
+ recoverable: j.recoverable !== false
48
+ })), // ④ 不可恢复标「需重跑」
49
+ constraints: constraints.length ? constraints : ['未识别到显式约束'], // ⑤ 原文摘录不总结
50
+ changed_files: changedFiles.map((f) => ({path: f.path, note: f.note, deleted: !!f.deleted})), // ⑥
51
+ to_session_title: toSessionTitle ?? null,
52
+ };
53
+ const json = JSON.stringify(payload);
54
+ const bytes = encoder.encode(json).length;
55
+ if (bytes > MAX_BYTES) return {ok: false, reason: 'oversize', bytes};
56
+ // 四段正文不含路径、代码与机器快照;关键内容超限时拒绝分卷,不能静默截断。
57
+ const prose = summarySegments.map(([, text]) => String(text).replace(/```[\s\S]*?```/g, '').replace(/(?:[\w.@-]+[\\/])+[\w.@-]+/g, '')).join('');
58
+ if ([...prose].length > 1500) return {ok: false, reason: 'summary-too-long', bytes};
59
+ return {ok: true, payload, bytes};
60
+ }
61
+
62
+ /** 人读列表(②双份之一):注入首条消息用的 Markdown 段 */
63
+ export function todosMarkdown(todos) {
64
+ if (!Array.isArray(todos) || todos.length === 0) return '无结构化待办';
65
+ const mark = {completed: '[x]', in_progress: '[~]', pending: '[ ]'};
66
+ return todos.map((t, i) => `${i + 1}. ${mark[t.status]} ${t.content}`).join('\n');
67
+ }
@@ -0,0 +1,25 @@
1
+ // 切点安全闸 S1:会话里仍在跑的工具调用 = 有 tool/call 但没有对应 tool/result。
2
+ // 事件形状(session.v4 实测,2026-11-05):
3
+ // tool/call -> data.callId
4
+ // tool/result -> data.toolCallId(并镜像在 data.message.toolCallId / data.message.source.callId)
5
+ // 只认 data.callId 会把已完成的调用全部算成 in-flight,第 10 轮触发时误判 unsafe-cut-point。
6
+
7
+ function typeOf(e) {
8
+ return e?.type ?? e?.kind;
9
+ }
10
+
11
+ export function callIdOf(e) {
12
+ return e?.data?.callId ?? e?.data?.toolCallId
13
+ ?? e?.data?.message?.toolCallId ?? e?.data?.message?.source?.callId
14
+ ?? e?.data?.message?.callId;
15
+ }
16
+
17
+ export function openToolCalls(events = []) {
18
+ const open = new Map();
19
+ for (const e of events) {
20
+ const callId = callIdOf(e);
21
+ if (typeOf(e) === 'tool/call') open.set(callId, {callId, name: e.data?.name});
22
+ else if (typeOf(e) === 'tool/result' && callId) open.delete(callId);
23
+ }
24
+ return [...open.values()];
25
+ }
@@ -0,0 +1,34 @@
1
+ // dsh-session-rollover · 模式降级(AC-R3-8):auto 检测到外部分段特征自动降 manual,配置可强制
2
+ // 检测对象 = 会话内出现的「[Checkpoint N]」/「[Handoff · part N]」类外部压缩-换段标记(billion-context 代理层产物)
3
+ export const SEGMENT_MARKER = /\[(?:Checkpoint \d+|Handoff(?: · part)? \d+)\]/;
4
+
5
+ export function markersFound(messages) {
6
+ return messages.some((m) => m && !m.internal && m.type !== 'system/message' && typeof m.text === 'string' && SEGMENT_MARKER.test(m.text));
7
+ }
8
+
9
+ /** 压缩正在执行时不并发建卷;forceAuto 只豁免标记降级,不豁免安全切点。 */
10
+ export function transitionInFlight(events) {
11
+ const open = new Map();
12
+ for (const e of events) {
13
+ const type = e.type ?? e.kind;
14
+ if (!/^(compaction|part)\/(start|end)$/.test(type)) continue;
15
+ const [kind, phase] = type.split('/');
16
+ const key = `${kind}:${e.data?.id ?? e.data?.turn ?? ''}`;
17
+ if (phase === 'start') open.set(key, true);
18
+ else open.delete(key);
19
+ }
20
+ return open.size > 0;
21
+ }
22
+
23
+ /**
24
+ * @param configured 'auto' | 'manual' | 'off'(rollover.mode)
25
+ * @param opts.forceAuto 配置强制 auto(不豁免外部分段告警展示,仅不降级)
26
+ * @returns {{mode:'auto'|'manual'|'off', downgraded:boolean}}
27
+ */
28
+ export function effectiveRolloverMode(configured = 'auto', opts = {}) {
29
+ if (configured === 'off') return {mode: 'off', downgraded: false};
30
+ if (configured === 'manual') return {mode: 'manual', downgraded: false};
31
+ if (opts.forceAuto) return {mode: 'auto', downgraded: false};
32
+ if (opts.hasMarkers) return {mode: 'manual', downgraded: true}; // 自动降 manual
33
+ return {mode: 'auto', downgraded: false};
34
+ }
@@ -0,0 +1,78 @@
1
+ // dsh-session-rollover · -N 命名与簇规则(接口契约 §3.2 第3步;纯逻辑)
2
+ // 规则:原标题+`-1`;已带 `-k` 则 k+1 不嵌套;重名序号递增到第一空位;同簇 -1..-9 封顶(B11);用户手动改名不覆盖(B8)
3
+ export const VOLUME_CAP = 9;
4
+ const SUFFIX = /^(.+)-(\d{1})$/; // 仅个位数视为卷号;"Q3-26" 这类两位数尾巴不当卷号,防误咬
5
+
6
+ export function parseVolume(title) {
7
+ const m = SUFFIX.exec(title ?? '');
8
+ if (!m) return {base: title, k: 0};
9
+ return {base: m[1], k: Number(m[2])};
10
+ }
11
+
12
+ /**
13
+ * @param currentTitle 现名
14
+ * @param existingTitles 同簇/全局已占用标题(Set 或数组)
15
+ * @param opts.userRenamed 本会话被用户手动改过名 → 不覆盖(B8)
16
+ * @returns {{ok:boolean, next?:string, k?:number, base?:string, reason?:string}}
17
+ */
18
+ export function nextVolumeTitle(currentTitle, existingTitles = [], opts = {}) {
19
+ if (opts.userRenamed) return {ok: false, reason: 'user-renamed'};
20
+ const {base, k: cur} = parseVolume(currentTitle);
21
+ let k = cur === 0 ? 1 : cur + 1;
22
+ if (k > VOLUME_CAP) return {ok: false, reason: 'cluster-cap'};
23
+ const taken = new Set(existingTitles);
24
+ while (k <= VOLUME_CAP && taken.has(`${base}-${k}`)) k++; // 重名递增到第一空位并写日志(B7)
25
+ if (k > VOLUME_CAP) return {ok: false, reason: 'cluster-cap'}; // B11:停自动、提示手动
26
+ return {ok: true, base, k, next: `${base}-${k}`};
27
+ }
28
+
29
+ /**
30
+ * 分卷事务的「一对名字」:旧卷改 `base-c`(c=第一空位)、新卷接 `base-nextK`。
31
+ * 已是卷(k>0)的旧会话不再改名(S6:连跑三次 -1/-2 标题不得被改写),只给新卷找 k+1 的第一空位。
32
+ * @returns {{ok:true, base:string, oldK:number, oldTarget:string|null, newK:number, newTarget:string, bumped:boolean} | {ok:false, reason:string}}
33
+ */
34
+ export function planVolumeTitles(currentTitle, existingTitles = [], opts = {}) {
35
+ if (opts.userRenamed) return {ok: false, reason: 'user-renamed'}; // B8:用户手动改名不覆盖
36
+ // 重名可使显示后缀超过 9;仅使用索引记录确认该后缀,避免把 Q3-26 等普通标题误拆。
37
+ const known = /^-\d+$/.test(opts.nameSuffix ?? '') && currentTitle.endsWith(opts.nameSuffix);
38
+ const {base, k: oldK} = known
39
+ ? {base: currentTitle.slice(0, -opts.nameSuffix.length), k: Number(opts.nameSuffix.slice(1))}
40
+ : parseVolume(currentTitle);
41
+ const taken = new Set(existingTitles);
42
+ const nameLimit = opts.volumeNo != null ? 100000 : VOLUME_CAP;
43
+ if (opts.volumeNo >= VOLUME_CAP) return {ok: false, reason: 'cluster-cap'};
44
+ taken.delete(currentTitle); // 自己的现名不算被占用
45
+ let oldTarget = null;
46
+ let bumped = false;
47
+ let cursor;
48
+ if (opts.preserveTitle) {
49
+ cursor = 1;
50
+ } else if (oldK === 0) {
51
+ let c = 1;
52
+ while (c <= nameLimit && taken.has(`${base}-${c}`)) {
53
+ c++;
54
+ bumped = true;
55
+ } // B7:重名递增到第一空位并记日志
56
+ if (c > nameLimit) return {ok: false, reason: 'cluster-cap'}; // B11
57
+ oldTarget = `${base}-${c}`;
58
+ taken.add(oldTarget);
59
+ cursor = c + 1;
60
+ } else {
61
+ cursor = oldK + 1;
62
+ }
63
+ while (cursor <= nameLimit && taken.has(`${base}-${cursor}`)) {
64
+ cursor++;
65
+ bumped = true;
66
+ }
67
+ if (cursor > nameLimit) return {ok: false, reason: 'cluster-cap'};
68
+ return {ok: true, base, oldK, oldTarget, newK: cursor, newTarget: `${base}-${cursor}`, bumped};
69
+ }
70
+
71
+ /** 簇内已有卷数(-1..k),供「-9 封顶」前置判断;手动换会话不计入簇上限由调用方走 force */
72
+ export function clusterVolumeCount(currentTitle, existingTitles) {
73
+ const {base} = parseVolume(currentTitle);
74
+ const taken = new Set(existingTitles);
75
+ let n = 0;
76
+ for (let k = 1; k <= VOLUME_CAP; k++) if (taken.has(`${base}-${k}`)) n++;
77
+ return n;
78
+ }