dsh-compact-relay 0.5.2 → 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 +41 -0
- package/README.en.md +3 -0
- package/README.md +28 -3
- 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 +60 -12
- package/lib/actions/handoff.js +3 -1
- package/lib/actions/recover.js +17 -4
- package/lib/actions/state.js +6 -0
- package/lib/adapters/commands.js +36 -0
- package/lib/adapters/http.js +90 -12
- package/lib/client.js +654 -101
- package/lib/config.js +8 -0
- package/lib/core/config-schema.js +23 -0
- 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 +3 -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/relay.js +3 -0
- package/lib/dsh/select-range.js +2 -2
- package/lib/index.js +13 -2
- package/package.json +2 -2
|
@@ -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
|
+
}
|
package/lib/dsh/relay.js
CHANGED
|
@@ -56,6 +56,9 @@ export function normalizeRelayRecord(data) {
|
|
|
56
56
|
scheme: RECORD_SCHEMES.has(data.scheme) ? data.scheme : null,
|
|
57
57
|
titleSeq: Number.isInteger(data.titleSeq) && data.titleSeq > 0 ? data.titleSeq : null,
|
|
58
58
|
historyTitle: typeof data.historyTitle === 'string' && data.historyTitle.length > 0 ? data.historyTitle : null,
|
|
59
|
+
// 0.6:链根会话 id(收藏文件按链根定位)。老记录**不写这个键**,读侧回退 parentId——
|
|
60
|
+
// 条件展开保持旧记录读取形状不变(0.3 记录字节契约)。
|
|
61
|
+
...(typeof data.rootId === 'string' && data.rootId.length > 0 ? { rootId: data.rootId } : {}),
|
|
59
62
|
};
|
|
60
63
|
}
|
|
61
64
|
|
package/lib/dsh/select-range.js
CHANGED
|
@@ -6,7 +6,6 @@
|
|
|
6
6
|
* 工具调用必须配对(provider 的 toolPairingBalanced*)。
|
|
7
7
|
*/
|
|
8
8
|
import { compactionPolicy } from '../core/policy.js';
|
|
9
|
-
import { clipRangeByPins, latestPinnedSeqs } from '../core/pins.js';
|
|
10
9
|
import { selectFullRange } from '../core/ranges.js';
|
|
11
10
|
import { countSummaries, firstCompactableIdx, lastCheckpointSurfaceSeq, sessionLog } from './session-view.js';
|
|
12
11
|
|
|
@@ -33,7 +32,8 @@ export function selectCompactableRange({ session, nodes, engine, cfg, tokenOf })
|
|
|
33
32
|
|
|
34
33
|
// M6:锁定的消息必须保留——范围里碰到就截到最早那条锁定消息之前;
|
|
35
34
|
// 截空(锁定挡在最前面)就本次不压,由调用方提示 /unpin。
|
|
36
|
-
|
|
35
|
+
// 0.6:pin 已退役(改「收藏」),不再按锁定消息截断选段;收藏不改变压缩范围。
|
|
36
|
+
let range = candidate;
|
|
37
37
|
if (range === null) return null;
|
|
38
38
|
|
|
39
39
|
// clip 缩短后终点可能落在 tool-call 中间(原来平衡的 candidate 被截断):
|
package/lib/index.js
CHANGED
|
@@ -16,6 +16,7 @@ import { createDiagCounters } from './dsh/diag-counters.js';
|
|
|
16
16
|
import engine from './dsh/engine.js';
|
|
17
17
|
import { createLedgerStore } from './dsh/ledger.js';
|
|
18
18
|
import { createStateHub } from './dsh/state-hub.js';
|
|
19
|
+
import { createFavoriteWatcher } from './dsh/favorite-watch.js';
|
|
19
20
|
|
|
20
21
|
export const name = 'compact-relay';
|
|
21
22
|
|
|
@@ -74,8 +75,18 @@ export function apply(ctx, config) {
|
|
|
74
75
|
// A9:readState 由这里注入,dsh 层不反向依赖 actions 层。
|
|
75
76
|
const hub = createStateHub({ readState: (sessionId) => readState(deps, { sessionId }) });
|
|
76
77
|
deps.hub = hub;
|
|
77
|
-
|
|
78
|
-
|
|
78
|
+
// 0.5.3 P1-6:推荐收藏——助手消息落地后异步打分,结果只进 readState 投影(绝不自动收藏)。
|
|
79
|
+
const favoriteWatch = createFavoriteWatcher(deps);
|
|
80
|
+
favoriteWatch.setHub(hub);
|
|
81
|
+
deps.favoriteWatch = favoriteWatch;
|
|
82
|
+
ctx.on('session/event', (session, event) => {
|
|
83
|
+
hub.onSessionEvent(session, event);
|
|
84
|
+
favoriteWatch.onEvent(session, event);
|
|
85
|
+
});
|
|
86
|
+
ctx.effect(() => () => {
|
|
87
|
+
favoriteWatch.close();
|
|
88
|
+
hub.close();
|
|
89
|
+
}, 'compact-relay: state hub + favorite watcher');
|
|
79
90
|
|
|
80
91
|
// 0.3.4(BUG-010):给模型一条「当前在可压缩区间」的动态 system-prompt 提示,
|
|
81
92
|
// 让「这里适合压缩」从不可判断变成可判断。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-compact-relay",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.3",
|
|
4
4
|
"description": "DeepSeek Harness 上下文指示器插件:提供容量监控、压缩预览与确认、九段式交接单生成及消息锁定功能。 · Context indicator for DSH: preview-then-confirm compaction, 9-section session handoff, and pinned messages.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
"node": ">=22"
|
|
54
54
|
},
|
|
55
55
|
"scripts": {
|
|
56
|
-
"test": "node scripts/build-client.mjs --check && node --check lib/client.js && node test/smoke.mjs && node test/handoff.mjs && node test/plugin-load.mjs && node test/contract.mjs && node test/compact-log.mjs && node test/compress-cue.mjs && node test/state-passive.mjs && node test/native-compaction.mjs && node test/markdown-lite.mjs && node test/clip.mjs && node test/diagnostics.mjs && node test/pin-contract.mjs && node test/ledger.mjs && node test/handoff-p2.mjs && node test/relay-title-scheme.mjs && node test/relay-chain.mjs && node test/relay-chain-endpoint.mjs && node test/handoff-naming.mjs && node test/native-line.mjs && node test/handoff-guard.mjs && node test/settings-ux.mjs && node test/pin-health.mjs && node test/archive.mjs && node test/force-archive.mjs && node test/relay-bar.mjs && node test/relay-record.mjs && node test/contract-golden.mjs",
|
|
56
|
+
"test": "node scripts/build-client.mjs --check && node --check lib/client.js && node test/smoke.mjs && node test/handoff.mjs && node test/plugin-load.mjs && node test/contract.mjs && node test/compact-log.mjs && node test/compress-cue.mjs && node test/state-passive.mjs && node test/native-compaction.mjs && node test/markdown-lite.mjs && node test/clip.mjs && node test/diagnostics.mjs && node test/pin-contract.mjs && node test/favorite.mjs && node test/favorites-unit.mjs && node test/favorite-suggest.mjs && node test/favorite-preview.mjs && node test/ledger.mjs && node test/handoff-p2.mjs && node test/relay-title-scheme.mjs && node test/relay-chain.mjs && node test/relay-chain-endpoint.mjs && node test/handoff-naming.mjs && node test/native-line.mjs && node test/handoff-guard.mjs && node test/settings-ux.mjs && node test/pin-health.mjs && node test/archive.mjs && node test/force-archive.mjs && node test/relay-bar.mjs && node test/relay-record.mjs && node test/contract-golden.mjs",
|
|
57
57
|
"test:e2e": "node test/e2e/smoke.mjs",
|
|
58
58
|
"test:e2e:settings": "node test/e2e/settings-ui.mjs",
|
|
59
59
|
"test:e2e:settings-ux": "node test/e2e/settings-ux.mjs",
|