dsh-compact-relay 0.5.2 → 0.5.4
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 +55 -0
- package/README.en.md +3 -0
- package/README.md +28 -3
- package/lib/actions/better-compact.js +66 -6
- 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 +244 -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
package/lib/config.js
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
validateConfig,
|
|
15
15
|
} from './core/config-schema.js';
|
|
16
16
|
import { isPlainObject } from './core/object.js';
|
|
17
|
+
import { normalizeFavoriteWords } from './core/favorite-score.js';
|
|
17
18
|
|
|
18
19
|
// 兼容旧导入:其它模块/文档仍可从 config.js 拿默认配置与路径工具。
|
|
19
20
|
export { CONFIG_FIELDS, DEFAULT_CONFIG, defaultDshHome, expandHome } from './core/config-schema.js';
|
|
@@ -136,6 +137,13 @@ export function resolveConfig(raw = {}) {
|
|
|
136
137
|
? expandHome(input.handoffDir)
|
|
137
138
|
: DEFAULT_CONFIG.handoffDir,
|
|
138
139
|
),
|
|
140
|
+
// 0.5.3 P1-6:推荐收藏配置(词表规范化:去空白/去重/截长/限量)。
|
|
141
|
+
favorite: {
|
|
142
|
+
enabled: input.favorite?.enabled ?? DEFAULT_CONFIG.favorite.enabled,
|
|
143
|
+
level: input.favorite?.level ?? DEFAULT_CONFIG.favorite.level,
|
|
144
|
+
mustWords: normalizeFavoriteWords(input.favorite?.mustWords ?? DEFAULT_CONFIG.favorite.mustWords),
|
|
145
|
+
muteWords: normalizeFavoriteWords(input.favorite?.muteWords ?? DEFAULT_CONFIG.favorite.muteWords),
|
|
146
|
+
},
|
|
139
147
|
colors: normalizeColors(colors),
|
|
140
148
|
links: {
|
|
141
149
|
tutorial: input.links?.tutorial ?? DEFAULT_CONFIG.links.tutorial,
|
|
@@ -80,6 +80,20 @@ export const CONFIG_FIELDS = Object.freeze([
|
|
|
80
80
|
field(['colors', 'base', 'pin'], { label: '锁定颜色', kind: 'color', default: '#C026D3', editable: true, view: true }),
|
|
81
81
|
field(['colors', 'base', 'compact'], { label: '调整颜色', kind: 'color', default: '#C26A00', editable: true, view: true }),
|
|
82
82
|
field(['colors', 'base', 'handoff'], { label: '上限颜色', kind: 'color', default: '#D92D20', editable: true, view: true }),
|
|
83
|
+
// 0.6:收藏星标点亮色(设计稿到位后改配置值即可,不改代码)。
|
|
84
|
+
field(['colors', 'base', 'favorite'], { label: '收藏颜色', kind: 'color', default: '#F5C542', editable: true, view: true }),
|
|
85
|
+
// 0.5.3 P1-6:推荐收藏(模型打分 → 星标「点亮」建议,绝不自动收藏)。
|
|
86
|
+
field(['favorite', 'enabled'], { label: '推荐收藏', kind: 'boolean', default: true, editable: true, view: true }),
|
|
87
|
+
field(['favorite', 'level'], {
|
|
88
|
+
label: '推荐档位',
|
|
89
|
+
kind: 'enum',
|
|
90
|
+
values: ['loose', 'standard', 'strict'],
|
|
91
|
+
default: 'standard',
|
|
92
|
+
editable: true,
|
|
93
|
+
view: true,
|
|
94
|
+
}),
|
|
95
|
+
field(['favorite', 'mustWords'], { label: '必亮词', kind: 'words', default: [], editable: true, view: true }),
|
|
96
|
+
field(['favorite', 'muteWords'], { label: '屏蔽词', kind: 'words', default: [], editable: true, view: true }),
|
|
83
97
|
// 0.3.1:仓库已建(cfanzx/dsh-compact-relay),默认指向仓库内的教程与 Issues;
|
|
84
98
|
// 用户仍可在设置文件里覆盖 links.tutorial / links.feedback。
|
|
85
99
|
field(['links', 'tutorial'], { label: '使用教程链接', kind: 'urlOrEmpty', default: 'https://github.com/cfanzx/dsh-compact-relay/blob/main/TUTORIAL.md', view: true }),
|
|
@@ -229,6 +243,15 @@ export function validateFieldValue(item, value) {
|
|
|
229
243
|
return typeof value === 'string' && value.trim().length > 0 ? null : '不能为空,必须是颜色值(如 #6F7680)';
|
|
230
244
|
case 'path':
|
|
231
245
|
return typeof value === 'string' && value.length > 0 ? null : '不能为空';
|
|
246
|
+
case 'words':
|
|
247
|
+
if (!Array.isArray(value)) return '必须是字符串数组(例如 ["结论","定案"])';
|
|
248
|
+
if (value.length > 20) return '最多 20 个词';
|
|
249
|
+
for (const word of value) {
|
|
250
|
+
if (typeof word !== 'string') return '每个词都必须是字符串';
|
|
251
|
+
if ([...word.trim()].length === 0) return '不能有空词';
|
|
252
|
+
if ([...word.trim()].length > 12) return `单词最长 12 个字(${word.slice(0, 12)}…)`;
|
|
253
|
+
}
|
|
254
|
+
return null;
|
|
232
255
|
case 'urlOrEmpty':
|
|
233
256
|
if (typeof value !== 'string') return '必须是字符串';
|
|
234
257
|
if (value.length > 0 && !/^https?:\/\//i.test(value)) return '必须是 http(s) 链接,或留空';
|
package/lib/core/errors.js
CHANGED
|
@@ -21,15 +21,21 @@ const MESSAGES = Object.freeze({
|
|
|
21
21
|
});
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
|
-
* 给一次动作失败打上稳定的 code
|
|
24
|
+
* 给一次动作失败打上稳定的 code(可选 HTTP 状态码)。
|
|
25
|
+
*
|
|
26
|
+
* 0.5.3 P2-2:错误码不再一律 400 —— 未知会话 404、宿主缺接缝 503、内部错 500
|
|
27
|
+
* 由动作层显式标注(`statusCode`),adapters/http 只做映射,不重猜。
|
|
28
|
+
*
|
|
25
29
|
* @param {string} code
|
|
26
30
|
* @param {string} message
|
|
27
31
|
* @param {unknown} [cause]
|
|
28
|
-
* @
|
|
32
|
+
* @param {number} [statusCode] 建议的 HTTP 状态码(缺省由入口按 400/500 兜底)
|
|
33
|
+
* @returns {Error & {code: string, statusCode?: number}}
|
|
29
34
|
*/
|
|
30
|
-
export function actionError(code, message, cause) {
|
|
35
|
+
export function actionError(code, message, cause, statusCode) {
|
|
31
36
|
const error = new Error(message, cause === undefined ? undefined : { cause });
|
|
32
37
|
error.code = code;
|
|
38
|
+
if (Number.isInteger(statusCode)) error.statusCode = statusCode;
|
|
33
39
|
return error;
|
|
34
40
|
}
|
|
35
41
|
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 推荐收藏 · 打分与判定(纯函数层)—— 2026-10-28 定稿。
|
|
3
|
+
*
|
|
4
|
+
* 位置:`AI 回复落地 → 异步问模型「这条值不值得收藏原文」→ 判定 → 客户端星标高亮`。
|
|
5
|
+
* 这里只做**不碰 IO 的那一半**:提示词、解析、档位阈值、筛选词过滤、最终判定。
|
|
6
|
+
* 模型调用在 `dsh/favorite-scorer.js`,事件钩子在 `dsh/favorite-watch.js`。
|
|
7
|
+
*
|
|
8
|
+
* 红线(定稿 §6):推荐只决定「亮不亮」,绝不自动写收藏;真正的收藏仍由用户点星触发。
|
|
9
|
+
*
|
|
10
|
+
* 档位(实测数据见 `dcr-收藏-推荐收藏-验证-2026-10-28.md`):
|
|
11
|
+
* 宽松 ≥60:每 3 条回复亮 1 次,准确率 71%
|
|
12
|
+
* 标准 ≥75:每 4 条亮 1 次,准确率 76%(默认)
|
|
13
|
+
* 严格 ≥85:每 7 条亮 1 次,准确率 89%
|
|
14
|
+
*
|
|
15
|
+
* 筛选词(定稿 §3):
|
|
16
|
+
* 必亮词:出现就亮(跳过分数);屏蔽词:出现就绝不亮;优先级 屏蔽 > 必亮 > 分数。
|
|
17
|
+
* 只做大小写无关的**子串包含**,不引正则——用户写不坏、可试算、可预期。
|
|
18
|
+
*
|
|
19
|
+
* 判定顺序:屏蔽词 → 必亮词 → 档位阈值。模型失败(score===null)→ 不亮(fail closed)。
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** 三档阈值(分数 0-100)。 */
|
|
23
|
+
export const FAVORITE_SCORE_LEVELS = Object.freeze({ loose: 60, standard: 75, strict: 85 });
|
|
24
|
+
|
|
25
|
+
/** 默认档位(2026-10-28 小潇拍板)。 */
|
|
26
|
+
export const FAVORITE_SCORE_DEFAULT_LEVEL = 'standard';
|
|
27
|
+
|
|
28
|
+
/** 合法档位名。 */
|
|
29
|
+
const LEVELS = Object.freeze(['loose', 'standard', 'strict']);
|
|
30
|
+
|
|
31
|
+
/** 必亮词 / 屏蔽词各自上限。 */
|
|
32
|
+
export const FAVORITE_WORDS_MAX = 20;
|
|
33
|
+
|
|
34
|
+
/** 单个词最长字符数。 */
|
|
35
|
+
export const FAVORITE_WORD_MAX_CHARS = 12;
|
|
36
|
+
|
|
37
|
+
/** 单条消息送给模型的最大字符数(超长截断,末尾加省略号)。 */
|
|
38
|
+
export const FAVORITE_SCORE_INPUT_MAX_CHARS = 3500;
|
|
39
|
+
|
|
40
|
+
/** 短于这个长度的消息不参与推荐(没有可收藏的原文价值)。 */
|
|
41
|
+
export const FAVORITE_SCORE_MIN_CHARS = 12;
|
|
42
|
+
|
|
43
|
+
/** 解析失败/模型不可用时的占位分数(null 表示「不知道」,判定为不亮)。 */
|
|
44
|
+
export const FAVORITE_SCORE_UNKNOWN = null;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 档位 → 阈值;非法档位回落默认档。
|
|
48
|
+
* @param {string} [level]
|
|
49
|
+
* @returns {number}
|
|
50
|
+
*/
|
|
51
|
+
export function favoriteScoreThresholdOf(level) {
|
|
52
|
+
if (typeof level === 'string' && Object.hasOwn(FAVORITE_SCORE_LEVELS, level)) {
|
|
53
|
+
return FAVORITE_SCORE_LEVELS[level];
|
|
54
|
+
}
|
|
55
|
+
return FAVORITE_SCORE_LEVELS[FAVORITE_SCORE_DEFAULT_LEVEL];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 规范化档位名;非法值回落默认档。
|
|
60
|
+
* @param {unknown} level
|
|
61
|
+
* @returns {'loose'|'standard'|'strict'}
|
|
62
|
+
*/
|
|
63
|
+
export function normalizeFavoriteLevel(level) {
|
|
64
|
+
return typeof level === 'string' && LEVELS.includes(level) ? level : FAVORITE_SCORE_DEFAULT_LEVEL;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 规范化一组筛选词:去空白、去重(按小写比较)、截长、限量。
|
|
69
|
+
* 空串/非字符串一律丢弃;词内空白保留原样(用户可能写「测试 全绿」)。
|
|
70
|
+
* @param {unknown} words
|
|
71
|
+
* @returns {string[]}
|
|
72
|
+
*/
|
|
73
|
+
export function normalizeFavoriteWords(words) {
|
|
74
|
+
if (!Array.isArray(words)) return [];
|
|
75
|
+
const seen = new Set();
|
|
76
|
+
const out = [];
|
|
77
|
+
for (const raw of words) {
|
|
78
|
+
if (typeof raw !== 'string') continue;
|
|
79
|
+
const word = raw.trim();
|
|
80
|
+
if (word.length === 0) continue;
|
|
81
|
+
const clipped = [...word].slice(0, FAVORITE_WORD_MAX_CHARS).join('');
|
|
82
|
+
const key = clipped.toLowerCase();
|
|
83
|
+
if (seen.has(key)) continue;
|
|
84
|
+
seen.add(key);
|
|
85
|
+
out.push(clipped);
|
|
86
|
+
if (out.length >= FAVORITE_WORDS_MAX) break;
|
|
87
|
+
}
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* 在文本里找第一个命中的词(大小写无关的子串匹配)。
|
|
93
|
+
* @param {string} text
|
|
94
|
+
* @param {string[]} words 规范化后的词表
|
|
95
|
+
* @returns {string|null} 命中的词;没命中返回 null
|
|
96
|
+
*/
|
|
97
|
+
export function matchFavoriteWord(text, words) {
|
|
98
|
+
if (typeof text !== 'string' || text.length === 0) return null;
|
|
99
|
+
if (!Array.isArray(words) || words.length === 0) return null;
|
|
100
|
+
const haystack = text.toLowerCase();
|
|
101
|
+
for (const word of words) {
|
|
102
|
+
if (typeof word !== 'string' || word.length === 0) continue;
|
|
103
|
+
if (haystack.includes(word.toLowerCase())) return word;
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* 送模型的系统提示词(与验证脚本 v3 逐字一致,唯一差别是要求 0-100 分)。
|
|
110
|
+
* 改这里等于改口径——`test/favorite-suggest.mjs` 锁了几个关键句,别顺手删。
|
|
111
|
+
* @returns {string}
|
|
112
|
+
*/
|
|
113
|
+
export function buildFavoriteScoreSystemPrompt() {
|
|
114
|
+
return (
|
|
115
|
+
'你帮用户判断:AI 的一条回复,值不值得『收藏原文』(以后可能想回看这条的原话)。\n\n' +
|
|
116
|
+
'判断标准(三类算):\n' +
|
|
117
|
+
'- 结论:能/不能、可行/不可行、该做/不该做、问题在哪、定案;\n' +
|
|
118
|
+
'- 判断:方案取舍与理由、原因分析、优先级、对错判断、建议;\n' +
|
|
119
|
+
'- 纠偏:承认之前判断错了、指出根因、给出改进方向或下次注意。\n\n' +
|
|
120
|
+
'不算:主体是报进度(做完了/改完了/测试全绿/真机验证通过/发版了)、列清单(改了哪些文件/有哪些发现)、操作步骤、提问、寒暄、确认收到。\n\n' +
|
|
121
|
+
'给这条消息打一个 0-100 的分:\n' +
|
|
122
|
+
'90-100:就是一条关键结论/判断/纠偏,用户以后大概率想回看原话;\n' +
|
|
123
|
+
'70-89:有明确判断,但也掺了流水账,可收可不收;\n' +
|
|
124
|
+
'40-69:有点结论的味道,但主体不是判断;\n' +
|
|
125
|
+
'0-39:进度汇报/清单/步骤/寒暄,不该收。\n' +
|
|
126
|
+
'只回 JSON:{"score":0到100的整数,"reason":"不超过15字"}'
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* 裁到送模型的最大长度(按字符,不切坏 emoji)。
|
|
132
|
+
* @param {string} text
|
|
133
|
+
* @returns {string}
|
|
134
|
+
*/
|
|
135
|
+
export function clipForScore(text) {
|
|
136
|
+
if (typeof text !== 'string') return '';
|
|
137
|
+
const chars = [...text];
|
|
138
|
+
if (chars.length <= FAVORITE_SCORE_INPUT_MAX_CHARS) return text;
|
|
139
|
+
return `${chars.slice(0, FAVORITE_SCORE_INPUT_MAX_CHARS).join('')}…`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* 送模型的用户消息文本。
|
|
144
|
+
* @param {string} text
|
|
145
|
+
* @returns {string}
|
|
146
|
+
*/
|
|
147
|
+
export function buildFavoriteScoreUserText(text) {
|
|
148
|
+
return `消息原文:\n${clipForScore(text)}`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* 解析模型输出:剥 ```json 围栏、取第一个 JSON 对象、clamp 到 0-100。
|
|
153
|
+
* 解析不出来返回 null(调用方按「不亮」处理,不抛错)。
|
|
154
|
+
* @param {unknown} content
|
|
155
|
+
* @returns {{score:number, reason:string}|null}
|
|
156
|
+
*/
|
|
157
|
+
export function parseFavoriteScore(content) {
|
|
158
|
+
if (typeof content !== 'string') return null;
|
|
159
|
+
let text = content.trim();
|
|
160
|
+
if (text.startsWith('```')) {
|
|
161
|
+
text = text.replace(/^```[a-zA-Z]*\s*/u, '').replace(/```\s*$/u, '');
|
|
162
|
+
}
|
|
163
|
+
const start = text.indexOf('{');
|
|
164
|
+
const end = text.lastIndexOf('}');
|
|
165
|
+
if (start === -1 || end === -1 || end <= start) return null;
|
|
166
|
+
let parsed;
|
|
167
|
+
try {
|
|
168
|
+
parsed = JSON.parse(text.slice(start, end + 1));
|
|
169
|
+
} catch {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
if (parsed === null || typeof parsed !== 'object') return null;
|
|
173
|
+
const rawScore = parsed.score;
|
|
174
|
+
const score = typeof rawScore === 'number' ? rawScore : Number.NaN;
|
|
175
|
+
if (!Number.isFinite(score)) return null;
|
|
176
|
+
const clamped = Math.max(0, Math.min(100, Math.round(score)));
|
|
177
|
+
const reason = typeof parsed.reason === 'string' ? parsed.reason.trim().slice(0, 40) : '';
|
|
178
|
+
return { score: clamped, reason };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* 最终判定:这条 AI 回复要不要点亮收藏星标。
|
|
183
|
+
*
|
|
184
|
+
* @param {{ text?:string, score?:number|null, level?:string, mustWords?:unknown, muteWords?:unknown }} input
|
|
185
|
+
* @returns {{ suggest:boolean, score:number|null, reason:'mute-word'|'must-word'|'threshold'|'too-short'|'unknown-score', matched:string|null }}
|
|
186
|
+
*/
|
|
187
|
+
export function decideFavoriteSuggest({ text, score, level, mustWords, muteWords } = {}) {
|
|
188
|
+
const body = typeof text === 'string' ? text : '';
|
|
189
|
+
const words = {
|
|
190
|
+
must: normalizeFavoriteWords(mustWords),
|
|
191
|
+
mute: normalizeFavoriteWords(muteWords),
|
|
192
|
+
};
|
|
193
|
+
const unknown = { suggest: false, score: FAVORITE_SCORE_UNKNOWN, matched: null };
|
|
194
|
+
|
|
195
|
+
if ([...body.trim()].length < FAVORITE_SCORE_MIN_CHARS) {
|
|
196
|
+
return { ...unknown, reason: 'too-short' };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// 屏蔽词优先:命中就绝不亮(即使模型给高分)。
|
|
200
|
+
const muted = matchFavoriteWord(body, words.mute);
|
|
201
|
+
if (muted !== null) return { ...unknown, matched: muted, reason: 'mute-word' };
|
|
202
|
+
|
|
203
|
+
// 必亮词:出现就亮,跳过模型分数(模型失败也亮)。
|
|
204
|
+
const must = matchFavoriteWord(body, words.must);
|
|
205
|
+
if (must !== null) {
|
|
206
|
+
const knownScore = typeof score === 'number' && Number.isFinite(score) ? Math.round(score) : null;
|
|
207
|
+
return { suggest: true, score: knownScore, matched: must, reason: 'must-word' };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (typeof score !== 'number' || !Number.isFinite(score)) {
|
|
211
|
+
return { ...unknown, reason: 'unknown-score' };
|
|
212
|
+
}
|
|
213
|
+
const threshold = favoriteScoreThresholdOf(level);
|
|
214
|
+
return {
|
|
215
|
+
suggest: score >= threshold,
|
|
216
|
+
score: Math.round(score),
|
|
217
|
+
matched: null,
|
|
218
|
+
reason: 'threshold',
|
|
219
|
+
};
|
|
220
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 收藏(favorite)—— 纯数据层:记录校验、集合计算、标题截取、摘要目录生成。
|
|
3
|
+
*
|
|
4
|
+
* 零 IO、零依赖(只复用 pin-anchors 的消息摘要),可裸跑单测。
|
|
5
|
+
* 记录存储走 dsh/favorites-file.js 的「一项工作一个文件」JSONL:追加式 add/remove,不写快照。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { messageSnippetOf } from './pin-anchors.js';
|
|
9
|
+
|
|
10
|
+
/** 单条记录 schema 版本。 */
|
|
11
|
+
export const FAVORITE_SCHEMA = 'dcr-favorite/v1';
|
|
12
|
+
|
|
13
|
+
/** 单项目收藏上限(定稿:够用)。 */
|
|
14
|
+
export const FAVORITE_MAX = 500;
|
|
15
|
+
|
|
16
|
+
/** 摘要目录最多列几条(超了截断,不让摘要膨胀)。 */
|
|
17
|
+
export const DIRECTORY_MAX = 20;
|
|
18
|
+
|
|
19
|
+
const isSeq = (value) => typeof value === 'number' && Number.isInteger(value) && value >= 0;
|
|
20
|
+
|
|
21
|
+
function nonEmpty(value) {
|
|
22
|
+
return typeof value === 'string' && value.length > 0 ? value : null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 读侧容错:形状不对 → null(调用方跳过该行,永不抛)。
|
|
27
|
+
* @param {unknown} raw
|
|
28
|
+
* @returns {{schema:string,op:'add'|'remove',sessionId:string,seq:number,at:number|null}|null}
|
|
29
|
+
*/
|
|
30
|
+
export function normalizeFavoriteRecord(raw) {
|
|
31
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
|
32
|
+
if (raw.schema !== FAVORITE_SCHEMA) return null;
|
|
33
|
+
if (raw.op !== 'add' && raw.op !== 'remove') return null;
|
|
34
|
+
if (!isSeq(raw.seq)) return null;
|
|
35
|
+
const sessionId = nonEmpty(raw.sessionId);
|
|
36
|
+
if (sessionId === null) return null;
|
|
37
|
+
return {
|
|
38
|
+
schema: FAVORITE_SCHEMA,
|
|
39
|
+
op: raw.op,
|
|
40
|
+
sessionId,
|
|
41
|
+
seq: raw.seq,
|
|
42
|
+
at: typeof raw.at === 'number' && Number.isFinite(raw.at) ? raw.at : null,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* JSONL 文本 → 记录数组(坏行跳过)。
|
|
48
|
+
* @param {string} text
|
|
49
|
+
* @returns {object[]}
|
|
50
|
+
*/
|
|
51
|
+
export function parseFavoriteLines(text) {
|
|
52
|
+
if (typeof text !== 'string' || text.length === 0) return [];
|
|
53
|
+
const records = [];
|
|
54
|
+
for (const line of text.split('\n')) {
|
|
55
|
+
const trimmed = line.trim();
|
|
56
|
+
if (trimmed.length === 0) continue;
|
|
57
|
+
let parsed;
|
|
58
|
+
try {
|
|
59
|
+
parsed = JSON.parse(trimmed);
|
|
60
|
+
} catch {
|
|
61
|
+
continue; // 半截写/手改 → 跳过
|
|
62
|
+
}
|
|
63
|
+
const record = normalizeFavoriteRecord(parsed);
|
|
64
|
+
if (record !== null) records.push(record);
|
|
65
|
+
}
|
|
66
|
+
return records;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 记录数组 → Map<sessionId, Set<seq>>(按时间顺序应用 add/remove)。
|
|
71
|
+
* @param {object[]} records
|
|
72
|
+
* @returns {Map<string, Set<number>>}
|
|
73
|
+
*/
|
|
74
|
+
export function applyFavoriteOps(records) {
|
|
75
|
+
const map = new Map();
|
|
76
|
+
if (!Array.isArray(records)) return map;
|
|
77
|
+
for (const record of records) {
|
|
78
|
+
const op = normalizeFavoriteRecord(record);
|
|
79
|
+
if (op === null) continue;
|
|
80
|
+
let set = map.get(op.sessionId);
|
|
81
|
+
if (set === undefined) {
|
|
82
|
+
set = new Set();
|
|
83
|
+
map.set(op.sessionId, set);
|
|
84
|
+
}
|
|
85
|
+
if (op.op === 'add') set.add(op.seq);
|
|
86
|
+
else set.delete(op.seq);
|
|
87
|
+
}
|
|
88
|
+
for (const [sessionId, set] of [...map]) {
|
|
89
|
+
if (set.size === 0) map.delete(sessionId);
|
|
90
|
+
}
|
|
91
|
+
return map;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** 全部会话的收藏总数。 */
|
|
95
|
+
export function countFavorites(map) {
|
|
96
|
+
let total = 0;
|
|
97
|
+
for (const set of map.values()) total += set.size;
|
|
98
|
+
return total;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** 某会话的收藏 seq(升序数组)。 */
|
|
102
|
+
export function favoriteSeqsOf(map, sessionId) {
|
|
103
|
+
const set = map instanceof Map && typeof sessionId === 'string' ? map.get(sessionId) : undefined;
|
|
104
|
+
return set === undefined ? [] : [...set].sort((a, b) => a - b);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* 标题截取:40–60 字,不调模型。
|
|
109
|
+
* @param {object|undefined} event 消息事件
|
|
110
|
+
* @param {number} [max]
|
|
111
|
+
* @returns {string}
|
|
112
|
+
*/
|
|
113
|
+
export function buildFavoriteTitle(event, max = 60) {
|
|
114
|
+
const snippet = messageSnippetOf(event) || '';
|
|
115
|
+
const text = snippet.replace(/\s+/gu, ' ').trim();
|
|
116
|
+
if (text.length === 0) return '(无文本)';
|
|
117
|
+
return text.length > max ? `${text.slice(0, Math.max(1, max - 1))}…` : text;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* 目录行(摘要与 /favorite list 共用)。
|
|
122
|
+
* @param {{index:number, role:string, title:string}[]} entries
|
|
123
|
+
* @param {number} [max]
|
|
124
|
+
* @returns {string[]}
|
|
125
|
+
*/
|
|
126
|
+
export function buildFavoriteDirectoryLines(entries, max = DIRECTORY_MAX) {
|
|
127
|
+
const list = Array.isArray(entries) ? entries.filter((entry) => entry !== null && typeof entry === 'object') : [];
|
|
128
|
+
const shown = list.slice(0, Math.max(1, max));
|
|
129
|
+
const lines = shown.map((entry) => `- 收藏 ${entry.index} · [${entry.role}] ${entry.title}`);
|
|
130
|
+
if (list.length > shown.length) lines.push(`- …等 ${list.length} 条,用 /favorite list 查看`);
|
|
131
|
+
return lines;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* 把「收藏目录」作为独立文本块追加在摘要之后;没有目录原样返回。
|
|
136
|
+
* @param {{type:string,text:string}[]} summary
|
|
137
|
+
* @param {string[]} lines buildFavoriteDirectoryLines 的产物
|
|
138
|
+
* @returns {{type:string,text:string}[]}
|
|
139
|
+
*/
|
|
140
|
+
export function appendFavoriteDirectory(summary, lines) {
|
|
141
|
+
const blocks = Array.isArray(summary)
|
|
142
|
+
? summary.filter((block) => block !== null && typeof block === 'object' && block.type === 'text')
|
|
143
|
+
: [];
|
|
144
|
+
if (!Array.isArray(lines) || lines.length === 0) return blocks;
|
|
145
|
+
const text = ['## 收藏(本段)', ...lines, '', '(以上原文未进摘要,按序号可查:/favorite <序号>)'].join('\n');
|
|
146
|
+
return [...blocks, { type: 'text', text }];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* 推荐收藏(半自动)—— **接口占位,实现待接**。
|
|
151
|
+
*
|
|
152
|
+
* 定位:系统识别「值得留」的消息并**建议**,必须由用户点星/命令确认,绝不自动收藏。
|
|
153
|
+
* 预期实现:复用 lib/core/pin-suggest.js 的识别规则(关键决策 / 报错堆栈 / 明确要求别改),
|
|
154
|
+
* 把输出从「建议锁定」改成「建议收藏」。
|
|
155
|
+
*
|
|
156
|
+
* 约定(调用方已按此消费,改实现不用改调用方):
|
|
157
|
+
* @param {{seq:number,type:string}[]} events 候选事件(通常是当前会话 surface 内消息)
|
|
158
|
+
* @param {{limit?:number}} [options]
|
|
159
|
+
* @returns {{seq:number,role:string,title:string,reason:string,confidence:'high'|'medium'|'low'}[]}
|
|
160
|
+
*/
|
|
161
|
+
export function suggestFavorites(events, { limit = 3 } = {}) {
|
|
162
|
+
if (!Array.isArray(events) || events.length === 0) return [];
|
|
163
|
+
const cap = Number.isInteger(limit) && limit > 0 ? limit : 3;
|
|
164
|
+
// 实现留白:接 pin-suggest 规则后在这里返回建议;
|
|
165
|
+
// 现在返回空数组 = 「没有推荐」,命令会明确提示「推荐收藏尚未接入」。
|
|
166
|
+
return events.slice(0, 0).slice(0, cap);
|
|
167
|
+
}
|
package/lib/core/relay-record.js
CHANGED
|
@@ -62,6 +62,7 @@ export function relayRecordOfForm({
|
|
|
62
62
|
path,
|
|
63
63
|
planId,
|
|
64
64
|
historyTitle,
|
|
65
|
+
rootId,
|
|
65
66
|
at,
|
|
66
67
|
atIso,
|
|
67
68
|
} = {}) {
|
|
@@ -83,6 +84,8 @@ export function relayRecordOfForm({
|
|
|
83
84
|
titleSeq,
|
|
84
85
|
// 改名真的生效(renamed / already)时调用方才传 historyTitle;空串也当没有。
|
|
85
86
|
...(typeof historyTitle === 'string' && historyTitle.length > 0 ? { historyTitle } : {}),
|
|
87
|
+
// 0.6:链根会话 id(收藏文件按链根定位)。
|
|
88
|
+
...(typeof rootId === 'string' && rootId.length > 0 ? { rootId } : {}),
|
|
86
89
|
};
|
|
87
90
|
}
|
|
88
91
|
if (role === 'child') {
|
|
@@ -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;
|