dsh-compact-relay 0.5.1 → 0.5.3
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 +128 -1
- package/README.en.md +3 -0
- package/README.md +29 -3
- package/TUTORIAL.md +2 -0
- package/lib/actions/better-compact.js +38 -4
- package/lib/actions/compact-run.js +54 -5
- package/lib/actions/favorite.js +435 -0
- package/lib/actions/force-archive.js +161 -0
- package/lib/actions/handoff.js +29 -23
- package/lib/actions/recover.js +17 -4
- package/lib/actions/state.js +20 -3
- package/lib/adapters/commands.js +36 -0
- package/lib/adapters/http.js +118 -1
- package/lib/client.js +1038 -106
- package/lib/config.js +8 -0
- package/lib/core/config-schema.js +25 -2
- package/lib/core/errors.js +9 -3
- package/lib/core/favorite-score.js +220 -0
- package/lib/core/favorites.js +167 -0
- package/lib/core/relay-record.js +131 -0
- package/lib/dsh/favorite-scorer.js +91 -0
- package/lib/dsh/favorite-watch.js +201 -0
- package/lib/dsh/favorites-file.js +349 -0
- package/lib/dsh/handoff-writer.js +1 -1
- package/lib/dsh/model-task.js +14 -10
- package/lib/dsh/relay.js +3 -0
- package/lib/dsh/route.js +19 -0
- package/lib/dsh/select-range.js +2 -2
- package/lib/dsh/sessions.js +19 -12
- package/lib/index.js +13 -2
- package/package.json +2 -2
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 推荐收藏 · 模型打分调用(0.5.3 P1-6)。
|
|
3
|
+
*
|
|
4
|
+
* 位置:`dsh/favorite-watch.js` 在助手消息落地后异步调这里,只判**最后一条**回复。
|
|
5
|
+
* 口径(2026-10-28 验证脚本 v3 定稿):flash 档、关思考、temperature 0、只回 JSON 分数。
|
|
6
|
+
*
|
|
7
|
+
* 红线:这里只返回分数,**绝不写收藏**;失败一律返回 null(调用方按「不亮」处理,
|
|
8
|
+
* 不抛错、不重试——推荐只是锦上添花,不能因为模型抖动影响主流程)。
|
|
9
|
+
*
|
|
10
|
+
* 与摘要/交接的区别:那两个任务失败要让用户知道(有明确错误码);推荐失败是静默降级,
|
|
11
|
+
* 所以不套 model-task 的重试/预算循环,只借 callStream 的超时与取消。
|
|
12
|
+
*/
|
|
13
|
+
import { buildFavoriteScoreSystemPrompt, buildFavoriteScoreUserText, parseFavoriteScore } from '../core/favorite-score.js';
|
|
14
|
+
import { callStream } from './llm-call.js';
|
|
15
|
+
import { routeOf } from './route.js';
|
|
16
|
+
|
|
17
|
+
/** 打分输出极短,给 160 token 足够(提示词要求只回一个 JSON)。 */
|
|
18
|
+
export const FAVORITE_SCORE_MAX_TOKENS = 160;
|
|
19
|
+
|
|
20
|
+
/** 单条打分超时;超过就不亮(比主对话慢太多就失去「即时提示」的意义)。 */
|
|
21
|
+
export const FAVORITE_SCORE_TIMEOUT_MS = 8_000;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 从 BlockAssembler 里取纯文本(只拼 text block;忽略 reason/tool)。
|
|
25
|
+
* @param {object} assembler
|
|
26
|
+
* @returns {string}
|
|
27
|
+
*/
|
|
28
|
+
function textOfAssembler(assembler) {
|
|
29
|
+
if (assembler === null || typeof assembler !== 'object') return '';
|
|
30
|
+
const blocks = typeof assembler.blocks === 'function' ? assembler.blocks() : [];
|
|
31
|
+
if (!Array.isArray(blocks)) return '';
|
|
32
|
+
const parts = [];
|
|
33
|
+
for (const block of blocks) {
|
|
34
|
+
if (block !== null && typeof block === 'object' && block.type === 'text' && typeof block.text === 'string') {
|
|
35
|
+
parts.push(block.text);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return parts.join('\n').trim();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 给一条助手回复打分(0-100)。任何失败返回 null(不亮),不抛错。
|
|
43
|
+
*
|
|
44
|
+
* @param {{ctx:object, engine:object, log?:object}} deps
|
|
45
|
+
* @param {{agent?:object, text?:string}} input
|
|
46
|
+
* @returns {Promise<{score:number, reason:string, provider:string, model:string}|null>}
|
|
47
|
+
*/
|
|
48
|
+
export async function scoreFavoriteMessage(deps, { agent, text } = {}) {
|
|
49
|
+
const body = typeof text === 'string' ? text.trim() : '';
|
|
50
|
+
if (body.length === 0) return null;
|
|
51
|
+
const session = agent !== null && typeof agent === 'object' ? agent.session : undefined;
|
|
52
|
+
if (session === undefined || session === null) return null;
|
|
53
|
+
const target = routeOf(deps.ctx, session, agent);
|
|
54
|
+
if (target === null) return null;
|
|
55
|
+
|
|
56
|
+
const options = {
|
|
57
|
+
provider: target.provider,
|
|
58
|
+
model: target.model,
|
|
59
|
+
reasoningEffort: 'off',
|
|
60
|
+
temperature: 0,
|
|
61
|
+
maxTokens: FAVORITE_SCORE_MAX_TOKENS,
|
|
62
|
+
messages: [
|
|
63
|
+
{ role: 'system', content: [{ type: 'text', text: buildFavoriteScoreSystemPrompt() }] },
|
|
64
|
+
{ role: 'user', content: [{ type: 'text', text: buildFavoriteScoreUserText(body) }] },
|
|
65
|
+
],
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
let assembler;
|
|
69
|
+
try {
|
|
70
|
+
assembler = await callStream({
|
|
71
|
+
ctx: deps.ctx,
|
|
72
|
+
engine: deps.engine,
|
|
73
|
+
options,
|
|
74
|
+
timeoutMs: FAVORITE_SCORE_TIMEOUT_MS,
|
|
75
|
+
label: '推荐收藏打分',
|
|
76
|
+
retry: false,
|
|
77
|
+
});
|
|
78
|
+
} catch (error) {
|
|
79
|
+
deps.log?.warn?.(`推荐收藏打分失败(忽略,不亮):${error instanceof Error ? error.message : String(error)}`);
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const parsed = parseFavoriteScore(textOfAssembler(assembler));
|
|
84
|
+
if (parsed === null) {
|
|
85
|
+
deps.log?.warn?.('推荐收藏打分:模型输出解析失败(忽略,不亮)');
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
return { score: parsed.score, reason: parsed.reason, provider: target.provider, model: target.model };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export default scoreFavoriteMessage;
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 推荐收藏 · 事件钩子(0.5.3 P1-6)。
|
|
3
|
+
*
|
|
4
|
+
* 职责:监听 `session/event`,在**每轮最后一条助手回复**落地约 1.2s 后异步打分,
|
|
5
|
+
* 保存「建议点亮」投影(≤3 条 / 会话),由 readState 下发、客户端星标高亮。
|
|
6
|
+
*
|
|
7
|
+
* 三个约束(定稿):
|
|
8
|
+
* 1. **只判最后一条**:助手消息可能分步到达(工具调用中间态),用去抖把同一轮的
|
|
9
|
+
* 多次 assistant/message 收成一次,只对最后一条打分;
|
|
10
|
+
* 2. **每回合最多一次**:同一会话同时只有一个打分在跑;
|
|
11
|
+
* 3. **红线:绝不收藏**。这里只写内存建议,不碰 `dcr-favorites` 文件。
|
|
12
|
+
*
|
|
13
|
+
* 失败静默降级(不亮),不重试、不阻塞事件处理。
|
|
14
|
+
*/
|
|
15
|
+
import { decideFavoriteSuggest, FAVORITE_SCORE_MIN_CHARS } from '../core/favorite-score.js';
|
|
16
|
+
import { favoriteSeqsOf } from '../core/favorites.js';
|
|
17
|
+
import { favoriteMapOf } from './favorites-file.js';
|
|
18
|
+
import { scoreFavoriteMessage } from './favorite-scorer.js';
|
|
19
|
+
|
|
20
|
+
/** 同一轮内多条助手消息的合并窗口:在这之后还没新消息才算「这一轮的最后一条」。 */
|
|
21
|
+
const WATCH_DEBOUNCE_MS = 1_200;
|
|
22
|
+
|
|
23
|
+
/** 每个会话最多保留几条建议(readState 帧预算)。 */
|
|
24
|
+
export const FAVORITE_SUGGEST_MAX = 3;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 从一个事件里抠助手消息纯文本。
|
|
28
|
+
* @param {object|undefined} event
|
|
29
|
+
* @returns {string}
|
|
30
|
+
*/
|
|
31
|
+
export function assistantTextOf(event) {
|
|
32
|
+
const data = event !== null && typeof event === 'object' ? event.data : undefined;
|
|
33
|
+
const message = data?.message;
|
|
34
|
+
const content = message?.content ?? data?.content;
|
|
35
|
+
if (typeof content === 'string') return content;
|
|
36
|
+
if (!Array.isArray(content)) return '';
|
|
37
|
+
const parts = [];
|
|
38
|
+
for (const block of content) {
|
|
39
|
+
if (block !== null && typeof block === 'object' && block.type === 'text' && typeof block.text === 'string') {
|
|
40
|
+
parts.push(block.text);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return parts.join('\n');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @param {{ctx:object, engine:object, cfg:object, log?:object}} deps
|
|
48
|
+
*/
|
|
49
|
+
export function createFavoriteWatcher(deps) {
|
|
50
|
+
/** @type {Map<string, ReturnType<typeof setTimeout>>} sessionId → 去抖定时器 */
|
|
51
|
+
const timers = new Map();
|
|
52
|
+
/** @type {Set<string>} 正在打分的会话(每回合最多一次) */
|
|
53
|
+
const inflight = new Set();
|
|
54
|
+
/** @type {Map<string, object[]>} sessionId → 建议列表(新的在前) */
|
|
55
|
+
const suggestions = new Map();
|
|
56
|
+
/** @type {{refreshAll:Function}|null} */
|
|
57
|
+
let hub = null;
|
|
58
|
+
|
|
59
|
+
const favoriteConfig = () => {
|
|
60
|
+
const cfg = deps.cfg?.favorite;
|
|
61
|
+
return cfg !== null && typeof cfg === 'object' ? cfg : {};
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** 当前会话已收藏的 seq 集合(判断「已收藏的不再推荐」)。 */
|
|
65
|
+
const favoritedSeqsOf = (session) => {
|
|
66
|
+
try {
|
|
67
|
+
return favoriteSeqsOf(favoriteMapOf(deps.ctx, session), session.id);
|
|
68
|
+
} catch {
|
|
69
|
+
return [];
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const pushSuggestion = (sessionId, item) => {
|
|
74
|
+
const list = suggestions.get(sessionId) ?? [];
|
|
75
|
+
const next = [item, ...list.filter((entry) => entry.seq !== item.seq)];
|
|
76
|
+
suggestions.set(sessionId, next.slice(0, FAVORITE_SUGGEST_MAX));
|
|
77
|
+
hub?.refreshAll?.();
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const scoreLater = (session, event) => {
|
|
81
|
+
const sessionId = session?.id;
|
|
82
|
+
if (typeof sessionId !== 'string' || sessionId.length === 0) return;
|
|
83
|
+
const existing = timers.get(sessionId);
|
|
84
|
+
if (existing !== undefined) clearTimeout(existing);
|
|
85
|
+
const timer = setTimeout(() => {
|
|
86
|
+
timers.delete(sessionId);
|
|
87
|
+
void runScore(session, event);
|
|
88
|
+
}, WATCH_DEBOUNCE_MS);
|
|
89
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
90
|
+
timers.set(sessionId, timer);
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const runScore = async (session, event) => {
|
|
94
|
+
const sessionId = session?.id;
|
|
95
|
+
if (typeof sessionId !== 'string' || sessionId.length === 0) return;
|
|
96
|
+
if (inflight.has(sessionId)) return;
|
|
97
|
+
const cfg = favoriteConfig();
|
|
98
|
+
if (cfg.enabled === false) return;
|
|
99
|
+
|
|
100
|
+
const text = assistantTextOf(event);
|
|
101
|
+
if ([...text.trim()].length < FAVORITE_SCORE_MIN_CHARS) return;
|
|
102
|
+
const seq = Number.isFinite(event?.seq) ? event.seq : null;
|
|
103
|
+
const messageId = typeof event?.data?.message?.id === 'string' ? event.data.message.id : null;
|
|
104
|
+
if (seq === null || messageId === null) return;
|
|
105
|
+
if (favoritedSeqsOf(session).includes(seq)) return; // 已收藏:不再推荐
|
|
106
|
+
|
|
107
|
+
const level = cfg.level;
|
|
108
|
+
const mustWords = cfg.mustWords;
|
|
109
|
+
const muteWords = cfg.muteWords;
|
|
110
|
+
|
|
111
|
+
// 先做不花钱的判定:太短/屏蔽词/必亮词都不需要模型。
|
|
112
|
+
const cheap = decideFavoriteSuggest({ text, score: null, level, mustWords, muteWords });
|
|
113
|
+
if (cheap.reason === 'mute-word' || cheap.reason === 'too-short') return;
|
|
114
|
+
if (cheap.reason === 'must-word') {
|
|
115
|
+
pushSuggestion(sessionId, {
|
|
116
|
+
seq,
|
|
117
|
+
messageId,
|
|
118
|
+
score: null,
|
|
119
|
+
reason: 'must-word',
|
|
120
|
+
matched: cheap.matched,
|
|
121
|
+
at: Date.now(),
|
|
122
|
+
});
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
inflight.add(sessionId);
|
|
127
|
+
try {
|
|
128
|
+
const scored = await scoreFavoriteMessage(deps, { agent: { id: sessionId, session }, text });
|
|
129
|
+
if (scored === null) return; // 模型失败:不亮(fail closed)
|
|
130
|
+
const decision = decideFavoriteSuggest({
|
|
131
|
+
text,
|
|
132
|
+
score: scored.score,
|
|
133
|
+
level,
|
|
134
|
+
mustWords,
|
|
135
|
+
muteWords,
|
|
136
|
+
});
|
|
137
|
+
if (decision.suggest !== true) return;
|
|
138
|
+
pushSuggestion(sessionId, {
|
|
139
|
+
seq,
|
|
140
|
+
messageId,
|
|
141
|
+
score: decision.score,
|
|
142
|
+
reason: decision.reason,
|
|
143
|
+
matched: decision.matched,
|
|
144
|
+
at: Date.now(),
|
|
145
|
+
});
|
|
146
|
+
} finally {
|
|
147
|
+
inflight.delete(sessionId);
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* 会话事件入口。只关心助手消息;其它事件直接返回。
|
|
153
|
+
* @param {object} session
|
|
154
|
+
* @param {object} event
|
|
155
|
+
*/
|
|
156
|
+
const onEvent = (session, event) => {
|
|
157
|
+
if (event === null || typeof event !== 'object') return;
|
|
158
|
+
if (event.type !== 'assistant/message') return;
|
|
159
|
+
scoreLater(session, event);
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* readState 用的建议投影(只下发 seq/messageId/分数/原因,守帧预算)。
|
|
164
|
+
* @param {string|null|undefined} sessionId
|
|
165
|
+
* @returns {{seq:number, messageId:string, score:number|null, reason:string, matched:string|null}[]}
|
|
166
|
+
*/
|
|
167
|
+
const suggestionOf = (sessionId) => {
|
|
168
|
+
if (typeof sessionId !== 'string' || sessionId.length === 0) return [];
|
|
169
|
+
const list = suggestions.get(sessionId) ?? [];
|
|
170
|
+
return list.map((item) => ({
|
|
171
|
+
seq: item.seq,
|
|
172
|
+
messageId: item.messageId,
|
|
173
|
+
score: item.score,
|
|
174
|
+
reason: item.reason,
|
|
175
|
+
matched: item.matched,
|
|
176
|
+
}));
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const setHub = (nextHub) => {
|
|
180
|
+
hub = nextHub ?? null;
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const clear = (sessionId) => {
|
|
184
|
+
if (typeof sessionId === 'string' && sessionId.length > 0) {
|
|
185
|
+
suggestions.delete(sessionId);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
suggestions.clear();
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const close = () => {
|
|
192
|
+
for (const timer of timers.values()) clearTimeout(timer);
|
|
193
|
+
timers.clear();
|
|
194
|
+
inflight.clear();
|
|
195
|
+
suggestions.clear();
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
return { onEvent, suggestionOf, setHub, clear, close };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export default createFavoriteWatcher;
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 收藏的文件层 —— **一条交接链一个 JSONL**(照 dsh/ledger.js 的范式写)。
|
|
3
|
+
*
|
|
4
|
+
* 红线(与轻账本一致):
|
|
5
|
+
* - **不影响本体**:所有 IO 都 try/catch 吞掉,写失败只返回 {ok:false};
|
|
6
|
+
* - **损坏静默降级**:读侧跳过坏行,不让一行坏数据毁掉全部收藏;
|
|
7
|
+
* - **有界**:超过 FAVORITE_MAX 时整文件重写保留最近 N 条(先写 tmp 再 rename)。
|
|
8
|
+
*
|
|
9
|
+
* 文件按「工作」分(一项工作 = 一条交接链;交接不换文件):
|
|
10
|
+
* `$DSH_HOME/dcr-favorites/<工作名>-<根会话短id>.jsonl`。
|
|
11
|
+
* 链根 = 入站 relay 记录的 rootId → 老链的 header.parentSession → 自己。
|
|
12
|
+
* 交接产生新会话时 rootId 不变 → 天然同一个文件(转交不换文件、继承上一个)。
|
|
13
|
+
*
|
|
14
|
+
* 快照:每次压缩把「本段被压掉且被收藏」的消息原文追加到
|
|
15
|
+
* `$DSH_HOME/dcr-favorites/snapshots/<根会话短id>.md`——日志被清理后仍可读回。
|
|
16
|
+
*/
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import { defaultDshHome } from '../core/config-schema.js';
|
|
20
|
+
import { FAVORITE_MAX, applyFavoriteOps, countFavorites, favoriteSeqsOf } from '../core/favorites.js';
|
|
21
|
+
import { legacyChainParentIdOf, relayInboundRecordOf } from './relay.js';
|
|
22
|
+
import { sourceTitleOf } from './sessions.js';
|
|
23
|
+
|
|
24
|
+
/** 收藏文件目录名(放 DSH_HOME 下,与 compact-relay.json 同级)。 */
|
|
25
|
+
export const FAVORITES_DIRNAME = 'dcr-favorites';
|
|
26
|
+
|
|
27
|
+
/** 压缩快照子目录。 */
|
|
28
|
+
export const SNAPSHOT_DIRNAME = 'snapshots';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 一条交接链的根会话 id(同步可算)。
|
|
32
|
+
* @param {object} session
|
|
33
|
+
* @returns {string|null}
|
|
34
|
+
*/
|
|
35
|
+
export function rootSessionIdOf(session) {
|
|
36
|
+
if (session === null || session === undefined) return null;
|
|
37
|
+
const inbound = relayInboundRecordOf(session);
|
|
38
|
+
if (inbound !== null && typeof inbound.rootId === 'string' && inbound.rootId.length > 0) return inbound.rootId;
|
|
39
|
+
const legacyParent = legacyChainParentIdOf(session);
|
|
40
|
+
if (legacyParent !== null) return legacyParent;
|
|
41
|
+
const id = session.id;
|
|
42
|
+
return typeof id === 'string' && id.length > 0 ? id : null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function titleOf(ctx, session) {
|
|
46
|
+
try {
|
|
47
|
+
const title = sourceTitleOf(ctx, session);
|
|
48
|
+
if (typeof title === 'string' && title.trim().length > 0) return title.trim();
|
|
49
|
+
} catch {
|
|
50
|
+
/* 标题服务不可用就用兜底 */
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** 会话 id 的短形式:去掉 `session-` 前缀再取 8 位(否则所有文件都叫 `...-session-.jsonl`)。 */
|
|
56
|
+
function shortIdOf(id) {
|
|
57
|
+
const text = typeof id === 'string' && id.length > 0 ? id : 'unknown';
|
|
58
|
+
return text.replace(/^session-/, '').slice(0, 8) || 'unknown';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** 文件名安全化:去路径分隔符与控制字符,折叠空白,截断。 */
|
|
62
|
+
function slugOf(text, max = 40) {
|
|
63
|
+
if (typeof text !== 'string' || text.length === 0) return 'session';
|
|
64
|
+
const cleaned = text
|
|
65
|
+
.replace(/[\\/:*?"<>|\u0000-\u001f]/gu, ' ')
|
|
66
|
+
.replace(/\s+/gu, ' ')
|
|
67
|
+
.trim();
|
|
68
|
+
if (cleaned.length === 0) return 'session';
|
|
69
|
+
return cleaned.length > max ? cleaned.slice(0, max) : cleaned;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** 目录:`$DSH_HOME/dcr-favorites`(缺省 ~/.dsh)。 */
|
|
73
|
+
export function favoritesDirOf(dshHome = defaultDshHome()) {
|
|
74
|
+
return path.join(dshHome, FAVORITES_DIRNAME);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* 新规范文件名:`<根会话短id>.jsonl`——**只按链根定位,不含标题**。
|
|
79
|
+
* (旧命名带「工作名」,标题一改就换文件;无活会话时也拿不到标题。)
|
|
80
|
+
*/
|
|
81
|
+
export function canonicalFavoritesFileOf(rootId, dshHome = defaultDshHome()) {
|
|
82
|
+
return path.join(favoritesDirOf(dshHome), `${shortIdOf(rootId)}.jsonl`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** 旧文件名(0.5.x 及之前):`<工作名>-<根会话短id>.jsonl`;返回全部匹配(升序)。 */
|
|
86
|
+
export function legacyFavoritesFilesOf(rootId, dshHome = defaultDshHome()) {
|
|
87
|
+
const dir = favoritesDirOf(dshHome);
|
|
88
|
+
const suffix = `-${shortIdOf(rootId)}.jsonl`;
|
|
89
|
+
try {
|
|
90
|
+
return fs
|
|
91
|
+
.readdirSync(dir)
|
|
92
|
+
.filter((name) => name.endsWith(suffix))
|
|
93
|
+
.sort()
|
|
94
|
+
.map((name) => path.join(dir, name));
|
|
95
|
+
} catch {
|
|
96
|
+
return [];
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* 读/写用的收藏文件:新名优先;只有旧名就用旧名(**不迁移、不拆文件**)。
|
|
102
|
+
* @param {string} rootId 链根会话 id
|
|
103
|
+
* @param {string} [dshHome]
|
|
104
|
+
* @returns {string}
|
|
105
|
+
*/
|
|
106
|
+
export function favoritesFileForRoot(rootId, dshHome = defaultDshHome()) {
|
|
107
|
+
const canonical = canonicalFavoritesFileOf(rootId, dshHome);
|
|
108
|
+
if (fs.existsSync(canonical)) return canonical;
|
|
109
|
+
const legacy = legacyFavoritesFilesOf(rootId, dshHome);
|
|
110
|
+
return legacy.length > 0 ? legacy[legacy.length - 1] : canonical;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* 无活会话时按会话 id 解析链根:`workspaceRegistry.readSessionHeader(id)` 沿 parentSession 往上游。
|
|
115
|
+
*
|
|
116
|
+
* 宿主实证(0.2.1 app.asar `dsh-workspace/lib/index.js`):registry 公开
|
|
117
|
+
* `readSessionHeader(id)`,对**没有活 agent 的已存会话**也能读(走 sessionPersistence 索引);
|
|
118
|
+
* header 里 `parentSession` 是 fork/交接来源。读不到就退回 sessionId 本身(不猜、不抛)。
|
|
119
|
+
* @param {object} ctx
|
|
120
|
+
* @param {string} sessionId
|
|
121
|
+
* @returns {Promise<string|null>}
|
|
122
|
+
*/
|
|
123
|
+
export async function rootSessionIdOfId(ctx, sessionId) {
|
|
124
|
+
if (typeof sessionId !== 'string' || sessionId.length === 0) return null;
|
|
125
|
+
let current = sessionId;
|
|
126
|
+
const seen = new Set();
|
|
127
|
+
try {
|
|
128
|
+
const registry = ctx?.get?.('workspaceRegistry');
|
|
129
|
+
if (registry === null || registry === undefined || typeof registry.readSessionHeader !== 'function') return current;
|
|
130
|
+
for (let depth = 0; depth < 64; depth += 1) {
|
|
131
|
+
if (seen.has(current)) return current;
|
|
132
|
+
seen.add(current);
|
|
133
|
+
const header = await registry.readSessionHeader(current);
|
|
134
|
+
const parent =
|
|
135
|
+
header !== null && typeof header === 'object' && typeof header.parentSession === 'string' && header.parentSession.length > 0
|
|
136
|
+
? header.parentSession
|
|
137
|
+
: null;
|
|
138
|
+
if (parent === null) return current;
|
|
139
|
+
current = parent;
|
|
140
|
+
}
|
|
141
|
+
} catch {
|
|
142
|
+
return current;
|
|
143
|
+
}
|
|
144
|
+
return current;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* 某会话所属交接链的收藏文件路径。
|
|
149
|
+
* @param {object} ctx
|
|
150
|
+
* @param {object} session
|
|
151
|
+
* @param {string} [dshHome]
|
|
152
|
+
* @returns {string}
|
|
153
|
+
*/
|
|
154
|
+
export function favoritesFileOf(ctx, session, dshHome = defaultDshHome()) {
|
|
155
|
+
const rootId = rootSessionIdOf(session);
|
|
156
|
+
if (rootId !== null) return favoritesFileForRoot(rootId, dshHome);
|
|
157
|
+
const title = slugOf(titleOf(ctx, session) ?? 'session');
|
|
158
|
+
return path.join(favoritesDirOf(dshHome), `${title}-unknown.jsonl`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* 读文件里的全部记录(只做 JSON.parse,坏行由 core 的 normalize 过滤)。
|
|
163
|
+
* @param {string} file
|
|
164
|
+
* @returns {object[]}
|
|
165
|
+
*/
|
|
166
|
+
export function readFavoritesFile(file) {
|
|
167
|
+
try {
|
|
168
|
+
const text = fs.readFileSync(file, 'utf8');
|
|
169
|
+
const records = [];
|
|
170
|
+
for (const line of text.split('\n')) {
|
|
171
|
+
const trimmed = line.trim();
|
|
172
|
+
if (trimmed.length === 0) continue;
|
|
173
|
+
try {
|
|
174
|
+
records.push(JSON.parse(trimmed));
|
|
175
|
+
} catch {
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return records;
|
|
180
|
+
} catch {
|
|
181
|
+
return [];
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* 追加一条收藏记录;超过上限时重写保留最近 FAVORITE_MAX 条。永不抛。
|
|
187
|
+
* @param {object} record normalizeFavoriteRecord 的产物
|
|
188
|
+
* @param {string} file
|
|
189
|
+
* @returns {{ok:boolean}}
|
|
190
|
+
*/
|
|
191
|
+
export function appendFavoriteRecord(record, file) {
|
|
192
|
+
try {
|
|
193
|
+
if (record === null || typeof record !== 'object') return { ok: false };
|
|
194
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
195
|
+
fs.appendFileSync(file, `${JSON.stringify(record)}\n`, 'utf8');
|
|
196
|
+
const lines = fs.readFileSync(file, 'utf8').split('\n').filter((line) => line.trim().length > 0);
|
|
197
|
+
if (lines.length > FAVORITE_MAX * 2) {
|
|
198
|
+
const kept = lines.slice(-FAVORITE_MAX).join('\n');
|
|
199
|
+
const tmp = `${file}.tmp`;
|
|
200
|
+
fs.writeFileSync(tmp, `${kept}\n`, 'utf8');
|
|
201
|
+
fs.renameSync(tmp, file);
|
|
202
|
+
}
|
|
203
|
+
return { ok: true };
|
|
204
|
+
} catch {
|
|
205
|
+
return { ok: false };
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* 某会话所在交接链的收藏集合。
|
|
211
|
+
* @param {object} ctx
|
|
212
|
+
* @param {object} session
|
|
213
|
+
* @param {string} [dshHome]
|
|
214
|
+
* @returns {Map<string, Set<number>>}
|
|
215
|
+
*/
|
|
216
|
+
export function favoriteMapOf(ctx, session, dshHome = defaultDshHome()) {
|
|
217
|
+
if (ctx === undefined || session === null || session === undefined) return new Map();
|
|
218
|
+
return applyFavoriteOps(readFavoritesFile(favoritesFileOf(ctx, session, dshHome)));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* 按链根 id 读收藏集合(无活会话的标记路径用;不依赖 session 对象)。
|
|
223
|
+
* @param {string} rootId
|
|
224
|
+
* @param {string} [dshHome]
|
|
225
|
+
* @returns {Map<string, Set<number>>}
|
|
226
|
+
*/
|
|
227
|
+
export function favoriteMapForRoot(rootId, dshHome = defaultDshHome()) {
|
|
228
|
+
if (typeof rootId !== 'string' || rootId.length === 0) return new Map();
|
|
229
|
+
return applyFavoriteOps(readFavoritesFile(favoritesFileForRoot(rootId, dshHome)));
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* readState 用的收藏投影:当前会话的 seq + messageId(只下发数字/短串,守 32KB 帧)。
|
|
234
|
+
* @param {object} ctx
|
|
235
|
+
* @param {object|null|undefined} session
|
|
236
|
+
* @param {{max?:number}} [options]
|
|
237
|
+
*/
|
|
238
|
+
export function favoriteStateOf(ctx, session, { max = 200 } = {}) {
|
|
239
|
+
if (ctx === undefined || session === null || session === undefined) {
|
|
240
|
+
return { count: 0, seqs: [], messageIds: [], truncated: false };
|
|
241
|
+
}
|
|
242
|
+
const map = favoriteMapOf(ctx, session);
|
|
243
|
+
const seqs = favoriteSeqsOf(map, session.id);
|
|
244
|
+
const cap = Number.isInteger(max) && max > 0 ? max : 200;
|
|
245
|
+
const messageIds = [];
|
|
246
|
+
const wanted = new Set(seqs.slice(-100));
|
|
247
|
+
const log = Array.isArray(session.log) ? session.log : [];
|
|
248
|
+
for (const event of log) {
|
|
249
|
+
if (wanted.size === 0) break;
|
|
250
|
+
const seq = event !== null && typeof event === 'object' ? event.seq : undefined;
|
|
251
|
+
if (!wanted.has(seq)) continue;
|
|
252
|
+
const id = event.type === 'assistant/message' ? event.data?.message?.id : event.data?.id;
|
|
253
|
+
if (typeof id === 'string' && id.length > 0) {
|
|
254
|
+
messageIds.push(id);
|
|
255
|
+
wanted.delete(seq);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return {
|
|
259
|
+
count: seqs.length,
|
|
260
|
+
seqs: seqs.slice(-cap),
|
|
261
|
+
messageIds,
|
|
262
|
+
truncated: seqs.length > cap,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** 链总数(列表头部用)。 */
|
|
267
|
+
export function chainFavoriteTotal(ctx, session, dshHome = defaultDshHome()) {
|
|
268
|
+
return countFavorites(favoriteMapOf(ctx, session, dshHome));
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** 快照文件:`dcr-favorites/snapshots/<根会话短id>.md`。 */
|
|
272
|
+
export function snapshotFileOf(ctx, session, dshHome = defaultDshHome()) {
|
|
273
|
+
const rootId = rootSessionIdOf(session) ?? 'unknown';
|
|
274
|
+
return path.join(favoritesDirOf(dshHome), SNAPSHOT_DIRNAME, `${shortIdOf(rootId)}.md`);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const SNAPSHOT_MARKER = (seq) => `<!-- dcr-fav seq:${seq} -->`;
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* 把一批收藏原文追加进快照(按 seq 去重;已存在同 seq 标记则跳过)。永不抛。
|
|
281
|
+
* @param {string} file
|
|
282
|
+
* @param {{seq:number, role:string, title:string, text:string, at?:number}[]} entries
|
|
283
|
+
* @returns {{ok:boolean, written:number}}
|
|
284
|
+
*/
|
|
285
|
+
export function appendSnapshotEntries(file, entries) {
|
|
286
|
+
try {
|
|
287
|
+
if (!Array.isArray(entries) || entries.length === 0) return { ok: true, written: 0 };
|
|
288
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
289
|
+
let existing = '';
|
|
290
|
+
try {
|
|
291
|
+
existing = fs.readFileSync(file, 'utf8');
|
|
292
|
+
} catch {
|
|
293
|
+
existing = '';
|
|
294
|
+
}
|
|
295
|
+
const parts = [];
|
|
296
|
+
let written = 0;
|
|
297
|
+
for (const entry of entries) {
|
|
298
|
+
if (existing.includes(SNAPSHOT_MARKER(entry.seq))) continue;
|
|
299
|
+
const iso = new Date(Number.isFinite(entry.at) ? entry.at : Date.now()).toISOString();
|
|
300
|
+
parts.push(
|
|
301
|
+
`${SNAPSHOT_MARKER(entry.seq)}\n#### 收藏 · seq ${entry.seq} · ${entry.role} · ${iso}\n\n- 标题:${entry.title}\n\n${entry.text}\n\n---\n`,
|
|
302
|
+
);
|
|
303
|
+
written += 1;
|
|
304
|
+
}
|
|
305
|
+
if (written === 0) return { ok: true, written: 0 };
|
|
306
|
+
const header =
|
|
307
|
+
existing.length === 0
|
|
308
|
+
? '# 收藏快照\n\n> 每次压缩把「本段被压掉、且被收藏」的消息原文落到这里;原件日志被清理后仍可读回。\n\n'
|
|
309
|
+
: '';
|
|
310
|
+
fs.appendFileSync(file, header + parts.join('\n'), 'utf8');
|
|
311
|
+
return { ok: true, written };
|
|
312
|
+
} catch {
|
|
313
|
+
return { ok: false, written: 0 };
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* 从快照里取某条 seq 的原文(没有返回 null)。
|
|
319
|
+
* @param {string} file
|
|
320
|
+
* @param {number} seq
|
|
321
|
+
* @returns {string|null}
|
|
322
|
+
*/
|
|
323
|
+
export function snapshotTextFor(file, seq) {
|
|
324
|
+
try {
|
|
325
|
+
const text = fs.readFileSync(file, 'utf8');
|
|
326
|
+
const marker = SNAPSHOT_MARKER(seq);
|
|
327
|
+
const start = text.indexOf(marker);
|
|
328
|
+
if (start === -1) return null;
|
|
329
|
+
const rest = text.slice(start + marker.length);
|
|
330
|
+
// 去掉本段头(#### 行 + 标题行 + 空行),截到下一个标记或结尾
|
|
331
|
+
const nextMarker = rest.indexOf('<!-- dcr-fav seq:');
|
|
332
|
+
const body = nextMarker === -1 ? rest : rest.slice(0, nextMarker);
|
|
333
|
+
const lines = body.split('\n');
|
|
334
|
+
const content = [];
|
|
335
|
+
let skipping = true;
|
|
336
|
+
for (const line of lines) {
|
|
337
|
+
if (skipping) {
|
|
338
|
+
if (line.startsWith('####') || line.startsWith('- 标题:') || line.trim().length === 0) continue;
|
|
339
|
+
skipping = false;
|
|
340
|
+
}
|
|
341
|
+
if (line.trim() === '---') break;
|
|
342
|
+
content.push(line);
|
|
343
|
+
}
|
|
344
|
+
const joined = content.join('\n').trim();
|
|
345
|
+
return joined.length > 0 ? joined : null;
|
|
346
|
+
} catch {
|
|
347
|
+
return null;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
@@ -49,7 +49,7 @@ export async function generateHandoffDoc({ ctx, engine, agent, maxTokens, inputT
|
|
|
49
49
|
const prefix = surfaceMessages(session);
|
|
50
50
|
if (prefix.length === 0) throw actionError('handoff', '这个会话还没有可交接的内容(没有模型可见的消息)');
|
|
51
51
|
|
|
52
|
-
//
|
|
52
|
+
// reasoningEffort 由 prepareModelCall 决定:优先沿用主请求档位(cache 对齐);拿不到 header 才回退「支持 off 就 off」。
|
|
53
53
|
const controls = cacheAlignedControls(session, target);
|
|
54
54
|
let lastProblems = [];
|
|
55
55
|
|