dsh-compact-relay 0.4.2 → 0.5.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 +57 -0
- package/ROADMAP.md +44 -2
- package/lib/actions/handoff.js +50 -1
- package/lib/actions/recover.js +49 -0
- package/lib/actions/state.js +21 -3
- package/lib/adapters/http.js +17 -4
- package/lib/client.js +789 -0
- package/lib/core/relay.js +83 -6
- package/lib/dsh/handoff-archive.js +179 -0
- package/lib/dsh/relay.js +39 -1
- package/lib/dsh/sessions.js +206 -15
- package/package.json +2 -2
package/lib/core/relay.js
CHANGED
|
@@ -8,6 +8,45 @@
|
|
|
8
8
|
/** 会话日志里记录 relay 的类型(与压缩判断记录/pin 的 plugin record 同前缀)。 */
|
|
9
9
|
export const RELAY_RECORD_TYPE = 'plugin:compact-relay/relay';
|
|
10
10
|
|
|
11
|
+
/** 历史标题前缀(0.5.0 命名反转:历史 N:<原名>)。 */
|
|
12
|
+
export const HISTORY_PREFIX = '历史';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 会话标题的 UTF-8 字节预算 —— 与 DSH `session-title` 的默认 `maxTitleBytes` 一致。
|
|
16
|
+
*
|
|
17
|
+
* 宿主 `sessionTitle.rename()` 会按这个预算把标题从尾部截断;前缀也算预算。
|
|
18
|
+
* 插件先自己按同一口径缩 base,保证「历史 N:」前缀不会被宿主的尾部截断吃掉。
|
|
19
|
+
*/
|
|
20
|
+
export const MAX_TITLE_BYTES = 80;
|
|
21
|
+
|
|
22
|
+
/** 「历史 N:」标题的正则;空白/半角冒号的容忍度与下面的「交接第 N 次:」一致。 */
|
|
23
|
+
const HISTORY_TITLE_RE = new RegExp(`^${HISTORY_PREFIX}\\s*(\\d+)\\s*[::]\\s*([\\s\\S]*)$`);
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 按 UTF-8 字节预算截断标题(不切坏多字节字符 / 代理对)。
|
|
27
|
+
*
|
|
28
|
+
* 与 DSH `session-title` 的 `truncateTitleUtf8` 同口径:取「能放进预算的最长前缀」,
|
|
29
|
+
* 不补省略号(补了会占预算,也会让前缀解析多一个尾巴)。
|
|
30
|
+
* maxBytes 非法/缺失时原样返回(纯函数层保持旧行为,预算只在调用方显式传入时生效)。
|
|
31
|
+
* @param {string} input
|
|
32
|
+
* @param {number} [maxBytes]
|
|
33
|
+
* @returns {string}
|
|
34
|
+
*/
|
|
35
|
+
export function truncateTitleUtf8(input, maxBytes) {
|
|
36
|
+
if (typeof input !== 'string') return '';
|
|
37
|
+
if (!Number.isInteger(maxBytes) || maxBytes <= 0) return input;
|
|
38
|
+
if (Buffer.byteLength(input, 'utf8') <= maxBytes) return input;
|
|
39
|
+
let used = 0;
|
|
40
|
+
let output = '';
|
|
41
|
+
for (const character of input) {
|
|
42
|
+
const bytes = Buffer.byteLength(character, 'utf8');
|
|
43
|
+
if (used + bytes > maxBytes) break;
|
|
44
|
+
output += character;
|
|
45
|
+
used += bytes;
|
|
46
|
+
}
|
|
47
|
+
return output;
|
|
48
|
+
}
|
|
49
|
+
|
|
11
50
|
/**
|
|
12
51
|
* 标题统一格式:交接第 N 次:<原始标题>。
|
|
13
52
|
* @param {number} index
|
|
@@ -19,20 +58,58 @@ export function relayTitleOf(index, baseTitle) {
|
|
|
19
58
|
return `交接第 ${index} 次:${base}`;
|
|
20
59
|
}
|
|
21
60
|
|
|
61
|
+
/**
|
|
62
|
+
* 历史标题统一格式:历史 N:<原始标题>。
|
|
63
|
+
*
|
|
64
|
+
* 空格位置/全角冒号/序号写法与 relayTitleOf 完全一致(序号不补零、不换算);
|
|
65
|
+
* 原名先 trim,空/非字符串回落「会话」。
|
|
66
|
+
*
|
|
67
|
+
* 0.5.0 可选第三参 `maxBytes`:给 UTF-8 字节预算时,**前缀先计入预算、超长只缩 base**,
|
|
68
|
+
* 保证宿主按 maxTitleBytes 截断时不会把「历史 N:」本身截掉。
|
|
69
|
+
* 不传时保持 0.4.3 的纯函数行为(不截断)。
|
|
70
|
+
* @param {number} index
|
|
71
|
+
* @param {string} [baseTitle]
|
|
72
|
+
* @param {number} [maxBytes]
|
|
73
|
+
* @returns {string}
|
|
74
|
+
*/
|
|
75
|
+
export function historyTitleOf(index, baseTitle, maxBytes) {
|
|
76
|
+
const base = typeof baseTitle === 'string' && baseTitle.trim().length > 0 ? baseTitle.trim() : '会话';
|
|
77
|
+
if (!Number.isInteger(maxBytes) || maxBytes <= 0) return `${HISTORY_PREFIX} ${index}:${base}`;
|
|
78
|
+
const prefix = `${HISTORY_PREFIX} ${index}:`;
|
|
79
|
+
const budget = maxBytes - Buffer.byteLength(prefix, 'utf8');
|
|
80
|
+
// 预算连 base 都放不下时只保留前缀(宿主最多再截一次前缀,至少不会切坏字符)。
|
|
81
|
+
if (budget <= 0) return prefix;
|
|
82
|
+
const fitted = truncateTitleUtf8(base, budget).trimEnd() || truncateTitleUtf8('会话', budget);
|
|
83
|
+
return `${prefix}${fitted}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
22
86
|
/**
|
|
23
87
|
* 从任意文本里剥掉现代/历史交接前缀,拿到原始标题。
|
|
24
88
|
* 旧格式(「交接:」/「接棒:」可堆叠)只在读旧标题时兼容,不删除会话标题。
|
|
89
|
+
*
|
|
90
|
+
* 0.4.3 F1:返回值新增 `scheme`(只增不改,baseTitle/handedOff 的名字与语义不变):
|
|
91
|
+
* - 'handoff-n':「交接第 N 次:」;
|
|
92
|
+
* - 'history-n':「历史 N:」;
|
|
93
|
+
* - 'legacy':其余(旧「交接:」/「接棒:」堆叠、无前缀、空/脏输入回落)。
|
|
25
94
|
* @param {string|undefined} title
|
|
26
|
-
* @returns {{baseTitle:string, handedOff:number}}
|
|
95
|
+
* @returns {{baseTitle:string, handedOff:number, scheme:'handoff-n'|'history-n'|'legacy'}}
|
|
27
96
|
*/
|
|
28
97
|
export function splitHandoffTitle(title) {
|
|
29
|
-
if (typeof title !== 'string' || title.trim().length === 0)
|
|
30
|
-
|
|
98
|
+
if (typeof title !== 'string' || title.trim().length === 0) {
|
|
99
|
+
return { baseTitle: '会话', handedOff: 0, scheme: 'legacy' };
|
|
100
|
+
}
|
|
101
|
+
const trimmed = title.trim();
|
|
102
|
+
const modern = /^交接第\s*(\d+)\s*次[::]\s*([\s\S]*)$/.exec(trimmed);
|
|
31
103
|
if (modern !== null) {
|
|
32
104
|
const base = modern[2].trim();
|
|
33
|
-
return { baseTitle: base.length > 0 ? base : '会话', handedOff: Number.parseInt(modern[1], 10) };
|
|
105
|
+
return { baseTitle: base.length > 0 ? base : '会话', handedOff: Number.parseInt(modern[1], 10), scheme: 'handoff-n' };
|
|
106
|
+
}
|
|
107
|
+
const history = HISTORY_TITLE_RE.exec(trimmed);
|
|
108
|
+
if (history !== null) {
|
|
109
|
+
const base = history[2].trim();
|
|
110
|
+
return { baseTitle: base.length > 0 ? base : '会话', handedOff: Number.parseInt(history[1], 10), scheme: 'history-n' };
|
|
34
111
|
}
|
|
35
|
-
let base =
|
|
112
|
+
let base = trimmed;
|
|
36
113
|
let handedOff = 0;
|
|
37
114
|
for (;;) {
|
|
38
115
|
const match = /^(?:交接|接棒)[::]\s*/.exec(base);
|
|
@@ -41,5 +118,5 @@ export function splitHandoffTitle(title) {
|
|
|
41
118
|
handedOff += 1;
|
|
42
119
|
}
|
|
43
120
|
const clean = base.trim();
|
|
44
|
-
return { baseTitle: clean.length > 0 ? clean : '会话', handedOff };
|
|
121
|
+
return { baseTitle: clean.length > 0 ? clean : '会话', handedOff, scheme: 'legacy' };
|
|
45
122
|
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 0.5.0:交接成功后归档旧会话的宿主接缝(best effort,绝不阻断交接)。
|
|
3
|
+
*
|
|
4
|
+
* 宿主实证(DeepSeek Harness 0.2.1-rc.3 app.asar):
|
|
5
|
+
* - `@deepseek-ai/dsh-workspace/lib/index.js`:`WorkspaceRegistry.archiveSession(sessionId, options)`
|
|
6
|
+
* 不带 `stopActivity` 时先跑 `workspace/session-activity` waterfall;任何 provider
|
|
7
|
+
* (运行中的 turn / subagent / job / schedule)报活都会在写盘前抛
|
|
8
|
+
* `WorkspaceActiveSessionError`(带 `activity` 数组)。
|
|
9
|
+
* - 带 `stopActivity: true` 时跳过活动检查、先写归档集合,再 `workspace/session-stop`
|
|
10
|
+
* 把旧会话的 turn/subagent/job/schedule 停掉。**自动交接不替用户做这个决定**:
|
|
11
|
+
* 静默取消后台任务比「旧会话多留一行」破坏性大得多,所以本插件永远不传
|
|
12
|
+
* `stopActivity`;只对「正在收尾的 turn」做有限次等待重试,仍活跃就降级为不归档。
|
|
13
|
+
* - `WorkspaceRegistry.unarchiveSession(sessionId)` 幂等,且不做存在性探测;
|
|
14
|
+
* 不在归档集合里的 id 直接 no-op。恢复入口可以放心重复调用。
|
|
15
|
+
* - `archivedSessionIds` 是 registry 全局归档集合;归档不改变 workspace 成员关系,
|
|
16
|
+
* 恢复后回到原位(dsh-workspace/lib/index.js 的注释与 README 同口径)。
|
|
17
|
+
*
|
|
18
|
+
* 插件侧访问路径:`ctx.get('workspaceRegistry')`。本插件 `lib/dsh/sessions.js`
|
|
19
|
+
* 建子会话时已经用同一个服务 `create(cwd)`/`attachSession(id)`,不是新接缝。
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** 宿主归档集合里是否有这个会话;服务不可用/形状异常 → null(不知道,不猜 false)。 */
|
|
23
|
+
export function archivedSessionIdsOf(ctx) {
|
|
24
|
+
try {
|
|
25
|
+
const registry = ctx?.get?.('workspaceRegistry');
|
|
26
|
+
const ids = registry?.archivedSessionIds;
|
|
27
|
+
return Array.isArray(ids) ? ids : null;
|
|
28
|
+
} catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 旧会话当前是否在归档集合里。
|
|
35
|
+
* @param {object} ctx
|
|
36
|
+
* @param {string} sessionId
|
|
37
|
+
* @returns {boolean|null} null = 宿主没提供可读的归档状态
|
|
38
|
+
*/
|
|
39
|
+
export function isSessionArchived(ctx, sessionId) {
|
|
40
|
+
if (typeof sessionId !== 'string' || sessionId.length === 0) return null;
|
|
41
|
+
const ids = archivedSessionIdsOf(ctx);
|
|
42
|
+
return ids === null ? null : ids.includes(sessionId);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 取 workspace registry;拿不到/没有 archiveSession 时 null。
|
|
47
|
+
* @param {object} ctx
|
|
48
|
+
* @returns {object|null}
|
|
49
|
+
*/
|
|
50
|
+
export function workspaceRegistryOf(ctx) {
|
|
51
|
+
try {
|
|
52
|
+
const registry = ctx?.get?.('workspaceRegistry');
|
|
53
|
+
return registry !== null && registry !== undefined && typeof registry === 'object' ? registry : null;
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** 错误里的活动族(turn/subagent/job/schedule…);只用于提示,不参与归档决策。 */
|
|
60
|
+
function activityKindsOf(error) {
|
|
61
|
+
if (error === null || typeof error !== 'object' || !Array.isArray(error.activity)) return [];
|
|
62
|
+
const kinds = [];
|
|
63
|
+
for (const entry of error.activity) {
|
|
64
|
+
const kind = entry !== null && typeof entry === 'object' ? entry.kind : undefined;
|
|
65
|
+
if (typeof kind === 'string' && kind.length > 0 && !kinds.includes(kind)) kinds.push(kind);
|
|
66
|
+
}
|
|
67
|
+
return kinds;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function errorMessageOf(error) {
|
|
71
|
+
return error instanceof Error ? error.message : String(error);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function wait(ms) {
|
|
75
|
+
if (!Number.isFinite(ms) || ms <= 0) return Promise.resolve();
|
|
76
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function isActiveError(error) {
|
|
80
|
+
if (error === null || typeof error !== 'object') return false;
|
|
81
|
+
return (
|
|
82
|
+
error.name === 'WorkspaceActiveSessionError' ||
|
|
83
|
+
error.code === 'workspace/session-active' ||
|
|
84
|
+
Array.isArray(error.activity)
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* 归档交接后的旧会话(best effort)。
|
|
90
|
+
*
|
|
91
|
+
* 语义:
|
|
92
|
+
* - 已归档 → `already`(幂等;不重复写盘);
|
|
93
|
+
* - 成功 → `archived`;
|
|
94
|
+
* - 旧会话还有活(turn/subagent/job/schedule)→ 等短暂的空闲重试机会,仍活跃就
|
|
95
|
+
* `active`,**不 stopActivity**;
|
|
96
|
+
* - 宿主没有 registry/archiveSession → `unavailable`;
|
|
97
|
+
* - 宿主找不到会话 / 写盘失败 → `unknown` / `failed`。
|
|
98
|
+
* 任何分支都不抛错;调用方只需要把返回结果写进提示/日志。
|
|
99
|
+
*
|
|
100
|
+
* @param {object} ctx 根上下文
|
|
101
|
+
* @param {string} sessionId 旧会话 id
|
|
102
|
+
* @param {{log?:{warn?:Function}, attempts?:number, delayMs?:number}} [options]
|
|
103
|
+
* @returns {Promise<{status:'already'|'archived'|'unavailable'|'active'|'unknown'|'failed', archived:boolean, activityKinds:string[]}>}
|
|
104
|
+
*/
|
|
105
|
+
export async function archiveHandoffSource(ctx, sessionId, { log, attempts = 2, delayMs = 150 } = {}) {
|
|
106
|
+
const failed = (status, activityKinds = []) => ({ status, archived: false, activityKinds });
|
|
107
|
+
if (typeof sessionId !== 'string' || sessionId.length === 0) return failed('unknown');
|
|
108
|
+
|
|
109
|
+
const registry = workspaceRegistryOf(ctx);
|
|
110
|
+
if (registry === null || typeof registry.archiveSession !== 'function') {
|
|
111
|
+
log?.warn?.('compact-relay: 旧会话未归档:宿主没有 workspaceRegistry.archiveSession(交接已完成)');
|
|
112
|
+
return failed('unavailable');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// 幂等:重复交接/上一次已经归档成功时不再写盘,也不触发活动检查。
|
|
116
|
+
if (isSessionArchived(ctx, sessionId) === true) return { status: 'already', archived: true, activityKinds: [] };
|
|
117
|
+
|
|
118
|
+
const totalAttempts = Number.isInteger(attempts) && attempts > 0 ? attempts : 1;
|
|
119
|
+
let lastError = null;
|
|
120
|
+
for (let attempt = 1; attempt <= totalAttempts; attempt += 1) {
|
|
121
|
+
try {
|
|
122
|
+
await registry.archiveSession(sessionId);
|
|
123
|
+
return { status: 'archived', archived: true, activityKinds: [] };
|
|
124
|
+
} catch (error) {
|
|
125
|
+
lastError = error;
|
|
126
|
+
const active = isActiveError(error);
|
|
127
|
+
const kinds = activityKindsOf(error);
|
|
128
|
+
// 只对「还在跑」等一等:交接命令刚返回时,旧会话里可能有一个 turn 正在收尾;
|
|
129
|
+
// 默认等 150ms 再试一次,仍活跃就放弃,绝不自动 stopActivity。
|
|
130
|
+
if (active && attempt < totalAttempts) {
|
|
131
|
+
await wait(delayMs * attempt);
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (active) {
|
|
135
|
+
const label = kinds.length > 0 ? kinds.join('/') : 'active';
|
|
136
|
+
log?.warn?.(
|
|
137
|
+
`compact-relay: 旧会话 ${sessionId} 归档失败:仍有任务在跑(${label});不自动停止任务,旧会话保持可见(交接已完成)`,
|
|
138
|
+
);
|
|
139
|
+
return failed('active', kinds);
|
|
140
|
+
}
|
|
141
|
+
if (error !== null && typeof error === 'object' && error.name === 'WorkspaceUnknownSessionError') {
|
|
142
|
+
log?.warn?.(`compact-relay: 旧会话 ${sessionId} 归档失败:宿主里找不到该会话(${errorMessageOf(error)});交接已完成`);
|
|
143
|
+
return failed('unknown');
|
|
144
|
+
}
|
|
145
|
+
log?.warn?.(`compact-relay: 旧会话 ${sessionId} 归档失败:${errorMessageOf(error)}(交接已完成)`);
|
|
146
|
+
return failed('failed');
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
// 理论上循环里已经返回;留一个不抛错的兜底。
|
|
150
|
+
log?.warn?.(`compact-relay: 旧会话 ${sessionId} 归档失败:${errorMessageOf(lastError)}(交接已完成)`);
|
|
151
|
+
return failed(isActiveError(lastError) ? 'active' : 'failed', activityKindsOf(lastError));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* 归档结果 → 交接完成 message 里的一行(成功/失败都不丢信息)。
|
|
156
|
+
* 成功时明确告诉用户恢复入口;失败时固定写「归档失败,不影响交接」。
|
|
157
|
+
* @param {{status:string, archived:boolean, activityKinds?:string[]}} result
|
|
158
|
+
* @returns {string}
|
|
159
|
+
*/
|
|
160
|
+
export function handoffArchiveNoteOf(result) {
|
|
161
|
+
const status = result !== null && typeof result === 'object' ? result.status : 'failed';
|
|
162
|
+
if (status === 'archived') {
|
|
163
|
+
return '旧会话已归档(可在新会话输入框上方「已接力自 …」→「恢复旧会话」找回;侧栏视图选「全部会话」也能取消归档)。';
|
|
164
|
+
}
|
|
165
|
+
if (status === 'already') {
|
|
166
|
+
return '旧会话此前已在归档中(可在新会话输入框上方「已接力自 …」→「恢复旧会话」找回)。';
|
|
167
|
+
}
|
|
168
|
+
const kinds = Array.isArray(result?.activityKinds) && result.activityKinds.length > 0 ? result.activityKinds.join('/') : null;
|
|
169
|
+
if (status === 'active') {
|
|
170
|
+
return `旧会话归档失败,不影响交接(仍有任务在跑:${kinds ?? 'active'};不自动停止任务,旧会话仍在列表里)。`;
|
|
171
|
+
}
|
|
172
|
+
if (status === 'unavailable') {
|
|
173
|
+
return '旧会话归档失败,不影响交接(当前宿主没有 workspace 归档接口;旧会话仍在列表里)。';
|
|
174
|
+
}
|
|
175
|
+
if (status === 'unknown') {
|
|
176
|
+
return '旧会话归档失败,不影响交接(宿主里找不到该会话)。';
|
|
177
|
+
}
|
|
178
|
+
return '旧会话归档失败,不影响交接(旧会话仍在列表里)。';
|
|
179
|
+
}
|
package/lib/dsh/relay.js
CHANGED
|
@@ -7,14 +7,23 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { RELAY_RECORD_TYPE } from '../core/relay.js';
|
|
9
9
|
|
|
10
|
+
/** relay 记录里允许的标题方案(0.5.0 命名反转的两种新标题;legacy 不写进记录)。 */
|
|
11
|
+
const RECORD_SCHEMES = new Set(['handoff-n', 'history-n']);
|
|
12
|
+
|
|
10
13
|
/**
|
|
11
14
|
* 合法化一条 relay 记录;不合法返回 null。
|
|
12
15
|
*
|
|
13
16
|
* 0.4 P2-2(接力链元数据补全):canonical 字段为 `parentId` / `childId` / `ordinal` / `at` / `atIso`;
|
|
14
17
|
* 同时保留 0.3 的旧字段 `parentSessionId` / `index`(旧记录只读兼容,新写入两者都带)。
|
|
15
18
|
*
|
|
19
|
+
* 0.4.3 F2(无痕 handoff 地基):白名单放行可选字段 `scheme` / `titleSeq` / `historyTitle`。
|
|
20
|
+
* 旧记录没有这三个字段 → 一律读成 null;其余字段与旧记录读取语义完全不变。
|
|
21
|
+
*
|
|
22
|
+
* 0.5.0 命名反转起,父/子记录都写 `scheme`(本次链 `history-n`/`handoff-n`)与 `titleSeq`;
|
|
23
|
+
* 新链父记录另写 `historyTitle`(旧会话改名后的「历史 N」)。
|
|
24
|
+
*
|
|
16
25
|
* @param {unknown} data
|
|
17
|
-
* @returns {{parentId:string, childId:string, ordinal:number, at:number, atIso:string|null, parentSessionId:string, index:number, path?:string, planId?:string|null, title?:string, baseTitle?:string}|null}
|
|
26
|
+
* @returns {{parentId:string, childId:string, ordinal:number, at:number, atIso:string|null, parentSessionId:string, index:number, path?:string, planId?:string|null, title?:string, baseTitle?:string, scheme:'handoff-n'|'history-n'|null, titleSeq:number|null, historyTitle:string|null}|null}
|
|
18
27
|
*/
|
|
19
28
|
export function normalizeRelayRecord(data) {
|
|
20
29
|
if (data === null || typeof data !== 'object') return null;
|
|
@@ -43,6 +52,10 @@ export function normalizeRelayRecord(data) {
|
|
|
43
52
|
planId: typeof data.planId === 'string' ? data.planId : null,
|
|
44
53
|
...(typeof data.title === 'string' ? { title: data.title } : {}),
|
|
45
54
|
...(typeof data.baseTitle === 'string' ? { baseTitle: data.baseTitle } : {}),
|
|
55
|
+
// 0.4.3 F2:新字段只做白名单放行,缺失/非法一律 null(不猜、不补默认值)。
|
|
56
|
+
scheme: RECORD_SCHEMES.has(data.scheme) ? data.scheme : null,
|
|
57
|
+
titleSeq: Number.isInteger(data.titleSeq) && data.titleSeq > 0 ? data.titleSeq : null,
|
|
58
|
+
historyTitle: typeof data.historyTitle === 'string' && data.historyTitle.length > 0 ? data.historyTitle : null,
|
|
46
59
|
};
|
|
47
60
|
}
|
|
48
61
|
|
|
@@ -63,6 +76,31 @@ export function relayRecordOf(session) {
|
|
|
63
76
|
return null;
|
|
64
77
|
}
|
|
65
78
|
|
|
79
|
+
/**
|
|
80
|
+
* 0.5.0 F4:把一条 relay 记录投影成只读的「接力链节点」(交给 readState 下发)。
|
|
81
|
+
*
|
|
82
|
+
* 只是投影,不是解析:字段全部取自记录本身,不读标题、不补默认序号、不猜 scheme。
|
|
83
|
+
* - 记录里没有的字段 → null(0.4 及更早的记录没有 scheme/baseTitle);
|
|
84
|
+
* - path 与记录写法一致:有才带,没有就不出现这个键(不塞 null 占位);
|
|
85
|
+
* - 不是对象 → null(表示「没有链信息」,客户端按无链接口处理)。
|
|
86
|
+
*
|
|
87
|
+
* @param {object|null|undefined} record normalizeRelayRecord 的返回值
|
|
88
|
+
* @returns {{ordinal:number|null, baseTitle:string|null, parentId:string|null, childId:string|null, scheme:'handoff-n'|'history-n'|null, path?:string}|null}
|
|
89
|
+
*/
|
|
90
|
+
export function relayChainOf(record) {
|
|
91
|
+
if (record === null || typeof record !== 'object') return null;
|
|
92
|
+
const text = (value) => (typeof value === 'string' && value.length > 0 ? value : null);
|
|
93
|
+
const path = text(record.path);
|
|
94
|
+
return {
|
|
95
|
+
ordinal: Number.isInteger(record.ordinal) && record.ordinal > 0 ? record.ordinal : null,
|
|
96
|
+
baseTitle: text(record.baseTitle),
|
|
97
|
+
parentId: text(record.parentId),
|
|
98
|
+
childId: text(record.childId),
|
|
99
|
+
scheme: text(record.scheme),
|
|
100
|
+
...(path === null ? {} : { path }),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
66
104
|
/**
|
|
67
105
|
* 写一条 relay 记录;失败只返回 false(交给调用方决定是否告警),不影响交接结果。
|
|
68
106
|
* @param {{appendPluginRecord?: Function}} engine
|
package/lib/dsh/sessions.js
CHANGED
|
@@ -12,15 +12,25 @@
|
|
|
12
12
|
* childAgent.followup(交接单) → 消息成为新会话第一条消息并启动 turn
|
|
13
13
|
* (DSH 0.2 里没有 turn/start 的会话在界面上是「空白 Hero」,不会显示
|
|
14
14
|
* 历史;followup 让交接内容真的显示出来。失败则退回纯 append。)
|
|
15
|
+
* childSession 写 relay 记录 → 子会话自带链号(ordinal/baseTitle/scheme/titleSeq)
|
|
16
|
+
* 与交接单 path(0.5.0:调用方把已落盘的交接单文件路径传进来;子会话的
|
|
17
|
+
* 「查看交接单」不再依赖回查父会话)
|
|
15
18
|
*
|
|
16
19
|
* 全部失败点都做「能降级就降级、不能降级就带 childId 报错」,绝不静默。
|
|
17
20
|
*/
|
|
18
21
|
import { randomUUID } from 'node:crypto';
|
|
19
22
|
import { actionError } from '../core/errors.js';
|
|
20
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
MAX_TITLE_BYTES,
|
|
25
|
+
historyTitleOf,
|
|
26
|
+
relayTitleOf,
|
|
27
|
+
splitHandoffTitle,
|
|
28
|
+
truncateTitleUtf8,
|
|
29
|
+
} from '../core/relay.js';
|
|
21
30
|
import { HANDOFF_PREAMBLE } from '../core/prompts.js';
|
|
31
|
+
import { localIso } from './diag-counters.js';
|
|
22
32
|
import { cacheControls, routeOf } from './route.js';
|
|
23
|
-
import { relayRecordOf } from './relay.js';
|
|
33
|
+
import { relayRecordOf, writeRelayRecord } from './relay.js';
|
|
24
34
|
|
|
25
35
|
/** 插件在消息 source 上的标识。 */
|
|
26
36
|
export const PLUGIN_NAME = 'compact-relay';
|
|
@@ -29,6 +39,11 @@ function errorMessage(error) {
|
|
|
29
39
|
return error instanceof Error ? error.message : String(error);
|
|
30
40
|
}
|
|
31
41
|
|
|
42
|
+
/** 标题比较用的宿主口径折叠(连续空白 → 单空格 + trim)。 */
|
|
43
|
+
function sameNormalizedTitle(left, right) {
|
|
44
|
+
return left.replace(/\s+/gu, ' ').trim() === right.replace(/\s+/gu, ' ').trim();
|
|
45
|
+
}
|
|
46
|
+
|
|
32
47
|
/**
|
|
33
48
|
* 读原会话的 agent preset id(0.2 里优先会话头,其次 projection)。
|
|
34
49
|
* @param {object} ctx
|
|
@@ -68,29 +83,81 @@ export function sourceTitleOf(ctx, session) {
|
|
|
68
83
|
}
|
|
69
84
|
|
|
70
85
|
/**
|
|
71
|
-
*
|
|
86
|
+
* 按 scheme 组装标题计划(纯函数,不读 ctx)。
|
|
87
|
+
*
|
|
88
|
+
* - `handoff-n`(旧链):保持「交接第 N 次:<原名>」原样,旧链一个字符不改;
|
|
89
|
+
* - `history-n`(0.5.0 新链):新会话继承干净原名,旧会话由调用方改名「历史 N:<原名>」。
|
|
90
|
+
* 两个标题都按 `MAX_TITLE_BYTES` 先缩 base(前缀计入预算),避免宿主截断吃掉前缀。
|
|
91
|
+
* @param {'handoff-n'|'history-n'} scheme
|
|
92
|
+
* @param {number} index
|
|
93
|
+
* @param {string} baseTitle
|
|
94
|
+
* @param {boolean} viaRecord
|
|
95
|
+
* @returns {{index:number, baseTitle:string, title:string, historyTitle:string|null, scheme:'handoff-n'|'history-n', viaRecord:boolean}}
|
|
96
|
+
*/
|
|
97
|
+
function titlePlanOf(scheme, index, baseTitle, viaRecord) {
|
|
98
|
+
if (scheme === 'handoff-n') {
|
|
99
|
+
return {
|
|
100
|
+
index,
|
|
101
|
+
baseTitle,
|
|
102
|
+
title: relayTitleOf(index, baseTitle),
|
|
103
|
+
historyTitle: null,
|
|
104
|
+
scheme,
|
|
105
|
+
viaRecord,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
const base = typeof baseTitle === 'string' && baseTitle.trim().length > 0 ? baseTitle.trim() : '会话';
|
|
109
|
+
const fitted = truncateTitleUtf8(base, MAX_TITLE_BYTES).trimEnd() || '会话';
|
|
110
|
+
return {
|
|
111
|
+
index,
|
|
112
|
+
baseTitle: base,
|
|
113
|
+
// 新会话标题 = 原名(不带序号);旧会话标题 = 历史 N:原名。
|
|
114
|
+
title: fitted,
|
|
115
|
+
historyTitle: historyTitleOf(index, base, MAX_TITLE_BYTES),
|
|
116
|
+
scheme,
|
|
117
|
+
viaRecord,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* 下一次交接的标题计划:序号 + 原始标题 + 本次链用哪种命名方案。
|
|
72
123
|
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
124
|
+
* 序号优先读会话自己的 relay 记录(结构化数据,不依赖标题文案;新链子会话标题
|
|
125
|
+
* 继承原名后标题里已没有链号)。没有记录才回退标题解析:
|
|
126
|
+
* - `历史 N:` → 继续新链(record 丢失时靠标题兜底);
|
|
127
|
+
* - `交接第 N 次:` / 旧「交接:」「接棒:」堆叠 → 旧链,保持旧命名(链级 cutover);
|
|
128
|
+
* - 干净标题(无前缀)→ 新链,本次为第 1 次;
|
|
129
|
+
* - 标题读不到 → 保守按旧链处理(不生成可能与旧链混合的新标题)。
|
|
130
|
+
*
|
|
131
|
+
* 记录存在时方案以记录为准(`scheme='history-n'` 才走新链;0.3/0.4 旧记录
|
|
132
|
+
* scheme 为 null → 旧链,不迁移)。
|
|
75
133
|
* @param {object} ctx
|
|
76
134
|
* @param {object} session
|
|
77
|
-
* @returns {{index:number, baseTitle:string, title:string, viaRecord:boolean}}
|
|
135
|
+
* @returns {{index:number, baseTitle:string, title:string, historyTitle:string|null, scheme:'handoff-n'|'history-n', viaRecord:boolean}}
|
|
78
136
|
*/
|
|
79
137
|
export function handoffTitlePlanOf(ctx, session) {
|
|
138
|
+
const currentTitle = sourceTitleOf(ctx, session);
|
|
139
|
+
const split = splitHandoffTitle(currentTitle);
|
|
80
140
|
const record = relayRecordOf(session);
|
|
81
141
|
if (record !== null) {
|
|
82
|
-
const
|
|
83
|
-
const baseTitle = typeof record.baseTitle === 'string' && record.baseTitle.length > 0 ? record.baseTitle : fallback.baseTitle;
|
|
142
|
+
const baseTitle = typeof record.baseTitle === 'string' && record.baseTitle.length > 0 ? record.baseTitle : split.baseTitle;
|
|
84
143
|
const index = (Number.isInteger(record.ordinal) ? record.ordinal : record.index) + 1;
|
|
85
|
-
|
|
144
|
+
const scheme = record.scheme === 'history-n' ? 'history-n' : 'handoff-n';
|
|
145
|
+
return titlePlanOf(scheme, index, baseTitle, true);
|
|
86
146
|
}
|
|
87
|
-
const split = splitHandoffTitle(sourceTitleOf(ctx, session));
|
|
88
147
|
const index = split.handedOff + 1;
|
|
89
|
-
|
|
148
|
+
const scheme =
|
|
149
|
+
currentTitle === undefined
|
|
150
|
+
? 'handoff-n'
|
|
151
|
+
: split.scheme === 'history-n' || (split.scheme === 'legacy' && split.handedOff === 0)
|
|
152
|
+
? 'history-n'
|
|
153
|
+
: 'handoff-n';
|
|
154
|
+
return titlePlanOf(scheme, index, split.baseTitle, false);
|
|
90
155
|
}
|
|
91
156
|
|
|
92
157
|
/**
|
|
93
|
-
*
|
|
158
|
+
* 给新会话起可辨识的标题。
|
|
159
|
+
* - 旧链:交接第 N 次:<原题>;
|
|
160
|
+
* - 新链(0.5.0 命名反转):直接继承干净原名。
|
|
94
161
|
* 具体序号/原题由 handoffTitlePlanOf 决定(优先 relay 记录,其次标题解析)。
|
|
95
162
|
* @param {object} ctx
|
|
96
163
|
* @param {object} session
|
|
@@ -100,6 +167,93 @@ export function childTitleOf(ctx, session) {
|
|
|
100
167
|
return handoffTitlePlanOf(ctx, session).title;
|
|
101
168
|
}
|
|
102
169
|
|
|
170
|
+
/**
|
|
171
|
+
* 0.5.0 命名反转:把旧会话改名「历史 N:<原名>」。
|
|
172
|
+
*
|
|
173
|
+
* 只在 scheme='history-n' 的**新链**上执行;旧链(handoff-n)原样返回,一个字符不改
|
|
174
|
+
* (链级 cutover)。调用方必须在子会话建好之后调用:改名是可见副作用,任何失败都只
|
|
175
|
+
* warn、不阻断交接(子会话已经可用)。
|
|
176
|
+
*
|
|
177
|
+
* 幂等 guard:当前标题已经是本次目标(或宿主按更小预算截断后的同一前缀)时跳过,
|
|
178
|
+
* 同一链重复执行不会重复 append rename,也不会叠成「历史 2:历史 1:X」。
|
|
179
|
+
*
|
|
180
|
+
* 改名成功后 `await ctx.sessions.flush(session)` 收窄持久化窗口(宿主 rename 是同步写日志,
|
|
181
|
+
* flush 失败也只 warn)。
|
|
182
|
+
*
|
|
183
|
+
* @param {object} ctx
|
|
184
|
+
* @param {object} session 旧会话(rename 要求 live;手动 /handoff 时它正是当前会话)
|
|
185
|
+
* @param {{index:number, baseTitle:string, scheme?:'handoff-n'|'history-n', maxBytes?:number, log?:{warn?:Function}}} input
|
|
186
|
+
* @returns {Promise<{renamed:boolean, title:string|null, titleSeq:number|null, reason:'old-chain'|'unavailable'|'already'|'failed'|null}>}
|
|
187
|
+
*/
|
|
188
|
+
export async function renameHistoryOf(
|
|
189
|
+
ctx,
|
|
190
|
+
session,
|
|
191
|
+
{ index, baseTitle, scheme = 'history-n', maxBytes = MAX_TITLE_BYTES, log } = {},
|
|
192
|
+
) {
|
|
193
|
+
const result = { renamed: false, title: null, titleSeq: null, reason: null };
|
|
194
|
+
if (scheme !== 'history-n') {
|
|
195
|
+
result.reason = 'old-chain';
|
|
196
|
+
return result;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const target = historyTitleOf(index, baseTitle, maxBytes);
|
|
200
|
+
result.title = target;
|
|
201
|
+
const sessionId = session?.id ?? '';
|
|
202
|
+
|
|
203
|
+
let titles;
|
|
204
|
+
try {
|
|
205
|
+
titles = ctx?.get?.('sessionTitle');
|
|
206
|
+
} catch {
|
|
207
|
+
titles = undefined;
|
|
208
|
+
}
|
|
209
|
+
if (titles === undefined || titles === null || typeof titles.rename !== 'function') {
|
|
210
|
+
result.reason = 'unavailable';
|
|
211
|
+
log?.warn?.(
|
|
212
|
+
`compact-relay: 旧会话 ${sessionId} 改名「${target}」失败:sessionTitle 服务不可用(交接已完成,标题保持原样)`,
|
|
213
|
+
);
|
|
214
|
+
return result;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// 幂等 guard:读得到当前标题时,已是本次目标就跳过(含宿主按更小预算截断的前缀)。
|
|
218
|
+
// 按宿主 `session-title` 的口径折叠空白再比:宿主会把连续空白压成一个空格,
|
|
219
|
+
// 不折叠的话同一目标会被误判成「还没改」而重复 append。
|
|
220
|
+
try {
|
|
221
|
+
const current = titles.get?.(session)?.title;
|
|
222
|
+
if (typeof current === 'string' && current.length > 0) {
|
|
223
|
+
const parsed = splitHandoffTitle(current);
|
|
224
|
+
const sameHistory = parsed.scheme === 'history-n' && parsed.handedOff === index;
|
|
225
|
+
if (sameNormalizedTitle(current, target) || (sameHistory && target.startsWith(current))) {
|
|
226
|
+
result.reason = 'already';
|
|
227
|
+
return result;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
} catch {
|
|
231
|
+
/* 读不到当前标题就继续 rename(rename 自己会校验 live/空标题) */
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
let renamed;
|
|
235
|
+
try {
|
|
236
|
+
renamed = titles.rename(session, target);
|
|
237
|
+
} catch (error) {
|
|
238
|
+
result.reason = 'failed';
|
|
239
|
+
log?.warn?.(
|
|
240
|
+
`compact-relay: 旧会话 ${sessionId} 改名「${target}」失败:${errorMessage(error)}(交接已完成,标题保持原样)`,
|
|
241
|
+
);
|
|
242
|
+
return result;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// 与子会话 rename 一样:快照里的 eventSeq 是链记录要的序号,取不到就留 null(不猜)。
|
|
246
|
+
if (Number.isInteger(renamed?.eventSeq) && renamed.eventSeq > 0) result.titleSeq = renamed.eventSeq;
|
|
247
|
+
result.renamed = true;
|
|
248
|
+
|
|
249
|
+
try {
|
|
250
|
+
await ctx?.sessions?.flush?.(session);
|
|
251
|
+
} catch (error) {
|
|
252
|
+
log?.warn?.(`compact-relay: 旧会话 ${sessionId} 改名后落盘失败:${errorMessage(error)}`);
|
|
253
|
+
}
|
|
254
|
+
return result;
|
|
255
|
+
}
|
|
256
|
+
|
|
103
257
|
/**
|
|
104
258
|
* 解析子会话要挂的 agent preset,并装配 setup(系统提示/工具/技能组成)。
|
|
105
259
|
* 与官方 composeAgent 同构;preset 解析失败时退回默认 preset。
|
|
@@ -176,10 +330,12 @@ export function childAgentOptionsOf(ctx, session, agent) {
|
|
|
176
330
|
* @param {object} [input.agent] 原 Agent
|
|
177
331
|
* @param {string} input.doc 交接单正文(已含元信息头)
|
|
178
332
|
* @param {string} input.title 新会话标题
|
|
333
|
+
* @param {string} [input.path] 交接单文件路径(已落盘;传进子会话 relay 记录,拿不到就不写这个键)
|
|
334
|
+
* @param {object} [input.plan] handoffTitlePlanOf 的结果(不传则现场计算;串起文档/双写记录用同一份)
|
|
179
335
|
* @param {{warn?: Function}} [input.log]
|
|
180
336
|
* @returns {Promise<{childId: string, title: string, workspaceAttached: boolean, started: boolean, docSeq?: number}>}
|
|
181
337
|
*/
|
|
182
|
-
export async function createHandoffSession({ ctx, engine, session, agent, doc, title, log }) {
|
|
338
|
+
export async function createHandoffSession({ ctx, engine, session, agent, doc, title, path, plan, log }) {
|
|
183
339
|
if (session === undefined) throw actionError('handoff', '没有可交接的会话');
|
|
184
340
|
|
|
185
341
|
const childId = `session-${randomUUID()}`;
|
|
@@ -228,10 +384,16 @@ export async function createHandoffSession({ ctx, engine, session, agent, doc, t
|
|
|
228
384
|
}
|
|
229
385
|
|
|
230
386
|
// 2) 可辨识标题(sessionTitle 服务可选)。
|
|
387
|
+
// 0.4.4 F3:rename 返回的是折叠后的标题快照(DSH 0.2 形如
|
|
388
|
+
// { title, messageSeqs, source, eventSeq, updatedAt }),不是日志事件。
|
|
389
|
+
// 记下快照里的 eventSeq 供子会话 relay 记录使用;旧宿主/替身没有 eventSeq
|
|
390
|
+
// 就留 null(不猜)。eventSeq=0 也不收:relay 白名单只认正整数序号。
|
|
391
|
+
let titleSeq = null;
|
|
231
392
|
try {
|
|
232
393
|
const titles = ctx.get?.('sessionTitle');
|
|
233
394
|
if (titles !== undefined && titles !== null && typeof titles.rename === 'function') {
|
|
234
|
-
titles.rename(childSession, title);
|
|
395
|
+
const renamed = titles.rename(childSession, title);
|
|
396
|
+
if (Number.isInteger(renamed?.eventSeq) && renamed.eventSeq > 0) titleSeq = renamed.eventSeq;
|
|
235
397
|
}
|
|
236
398
|
} catch (error) {
|
|
237
399
|
log?.warn?.(`compact-relay: 新会话 ${childId} 改标题失败:${errorMessage(error)}`);
|
|
@@ -271,7 +433,36 @@ export async function createHandoffSession({ ctx, engine, session, agent, doc, t
|
|
|
271
433
|
}
|
|
272
434
|
}
|
|
273
435
|
|
|
274
|
-
// 4)
|
|
436
|
+
// 4) 子会话自己带一条接力链记录(自带链号,不再靠标题解析)。
|
|
437
|
+
// 0.5.0 会把旧会话改名「历史 N:<原名>」、新会话继承原名;那时子会话标题里
|
|
438
|
+
// 没有「交接第 N 次」,splitHandoffTitle 会算出 handedOff=0,第 2 次交接序号
|
|
439
|
+
// 就会重复。所以 ordinal/baseTitle/scheme/titleSeq 必须落在子会话自己的日志里。
|
|
440
|
+
// 写点:followup 之后(新会话首条可见消息仍是交接单)、flush 之前(重启不丢);
|
|
441
|
+
// 走 plugin record 通道:ignorable,不进模型上下文、不算 token。
|
|
442
|
+
// scheme 与父记录同源(本次链是 handoff-n 还是 history-n),titleSeq 是**子会话**
|
|
443
|
+
// rename 返回的 eventSeq;父记录由 lib/actions/handoff.js 另外写,这里只写子会话。
|
|
444
|
+
// 0.5.0 补:交接单已由调用方落盘,path 一并写进子记录 —— 接力条在子会话里直接
|
|
445
|
+
// 拿到「查看交接单」的路径,父会话未加载/已归档时不再退化为「暂无交接单」。
|
|
446
|
+
// path 拿不到(旧调用方/失败)就不写这个键,不猜、不写空串。
|
|
447
|
+
const titlePlan = plan ?? handoffTitlePlanOf(ctx, session);
|
|
448
|
+
const relayAt = Date.now();
|
|
449
|
+
const relayWrote = writeRelayRecord(engine, childSession, {
|
|
450
|
+
parentId: session.id,
|
|
451
|
+
childId,
|
|
452
|
+
ordinal: titlePlan.index,
|
|
453
|
+
baseTitle: titlePlan.baseTitle,
|
|
454
|
+
title,
|
|
455
|
+
scheme: titlePlan.scheme,
|
|
456
|
+
titleSeq,
|
|
457
|
+
...(typeof path === 'string' && path.length > 0 ? { path } : {}),
|
|
458
|
+
at: relayAt,
|
|
459
|
+
atIso: localIso(new Date(relayAt)),
|
|
460
|
+
});
|
|
461
|
+
if (!relayWrote) {
|
|
462
|
+
log?.warn?.(`compact-relay: 新会话 ${childId} 接力链记录写入失败(下次从该会话交接将回退到标题解析)`);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// 5) 尽力落盘:新会话的事件尽快持久化,重启后不丢(含上面那条接力链记录)。
|
|
275
466
|
try {
|
|
276
467
|
await ctx.sessions?.flush?.(childSession);
|
|
277
468
|
} catch (error) {
|