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/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,
@@ -62,8 +62,8 @@ export const CONFIG_FIELDS = Object.freeze([
62
62
  field(['thresholds', 'maxSummaries'], { label: '摘要份数上限', kind: 'positiveInt', default: 6, editable: true, view: true }),
63
63
  field(['contextWindowFallback'], { label: '兜底窗口', kind: 'positiveInt', default: 1_000_000 }),
64
64
  field(['retention', 'recentTailTokens'], { label: '保留近期原文', kind: 'nonNegativeInt', default: 40_000, editable: true, view: true }),
65
- field(['summary', 'maxTokens'], { label: '摘要输出上限', kind: 'positiveInt', default: 8_192 }),
66
- field(['handoff', 'maxTokens'], { label: '交接单输出上限', kind: 'positiveInt', default: 8_192 }),
65
+ field(['summary', 'maxTokens'], { label: '摘要输出上限', kind: 'positiveInt', default: 16_384 }),
66
+ field(['handoff', 'maxTokens'], { label: '交接单输出上限', kind: 'positiveInt', default: 16_384 }),
67
67
  field(['timeouts', 'llmMs'], { label: '模型调用超时', kind: 'positiveInt', default: 180_000 }),
68
68
  field(['dev', 'testRangeNodes'], { label: '测试取段条数', kind: 'positiveInt', default: 4 }),
69
69
  field(['handoffDir'], {
@@ -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) 链接,或留空';
@@ -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
- * @returns {Error & {code: string}}
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
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * 接力链 record 的**唯一构造点**(0.5.2 S2)。
3
+ *
4
+ * 为什么要有这个模块:0.5.1 之前父记录在 `actions/handoff.js` 手写 13–14 个键、
5
+ * 子记录在 `dsh/sessions.js` 手写 9–10 个键 —— 两个写点各拼一份,加字段要改两处,
6
+ * 漏一处就是静默降级(读侧 `normalizeRelayRecord` 对缺失/非法字段一律当没有)。
7
+ * 现在「一条 record 长什么样」只有这一处实现,两个写点只负责传语义参数。
8
+ *
9
+ * ⚠️ **这是 record 的字节契约,不是普通的对象字面量,不许「统一美化」**:
10
+ * 1. 键的**插入顺序**就是 `JSON.stringify` 的顺序(会话日志是 JSONL)。父/子两套
11
+ * 顺序**故意不同**,都必须逐字节保留 —— `test/contract-golden.mjs` 锁住了顺序;
12
+ * 2. 父记录多写 0.3 旧字段 `parentSessionId` / `index`(canonical 是 `parentId` /
13
+ * `ordinal`,新写入双写,旧记录仍按旧名可读);
14
+ * 3. 父记录写 `planId`(可能为 **null**);子记录**没有**这个键;
15
+ * 4. 父记录 `historyTitle` **条件写入**(改名没真的生效就不写这个键,不能记一个
16
+ * 不存在的标题);子记录没有这个键;
17
+ * 5. 父/子 `path` 都是**条件写入**(`typeof path === 'string' && path.length > 0`):
18
+ * 子记录在旧调用方/落盘失败时拿不到交接单,就不写键、不写空串;父记录的调用方
19
+ * 保证交接单已落盘(`path.join(handoffDir, handoffFileName(...))`,必然非空),
20
+ * 所以父记录实际总是带 path —— golden 里有断言;
21
+ * 6. 父/子都写 `titleSeq`,**即使为 null**(不是「省略 null」的统一策略:
22
+ * 0.4.x 记录没有这个键,0.5.0 起显式写 null 表示「拿不到宿主 eventSeq」)。
23
+ *
24
+ * 纯函数:不读文件/环境/时间,不 import 任何 `node:` 模块;`atIso` 由调用方按本地
25
+ * 时区口径算好传进来(core 不依赖 dsh 层),保证可在浏览器侧内联(build-client)。
26
+ */
27
+
28
+ /** record 的两个角色(写点只有这两个:交接的父会话、新建的子会话)。 */
29
+ export const RELAY_RECORD_ROLES = Object.freeze(['parent', 'child']);
30
+
31
+ /**
32
+ * 按角色产出一条 relay record(唯一构造)。
33
+ *
34
+ * 两个角色共用的字段:`parentId` / `childId` / `ordinal` / `title` / `baseTitle` /
35
+ * `scheme` / `titleSeq` / `at` / `atIso`(`title` 是本次新会话标题)。
36
+ *
37
+ * @param {object} input
38
+ * @param {'parent'|'child'} input.role 父记录(旧会话日志)还是子记录(新会话日志)
39
+ * @param {string} input.parentId 父会话 id(写 `parentId`,parent 另写 `parentSessionId`)
40
+ * @param {string} input.childId 子会话 id
41
+ * @param {number} input.ordinal 链号(parent 另写 `index`)
42
+ * @param {string} input.title 本次新会话标题
43
+ * @param {string} input.baseTitle 链上原始标题(不带序号前缀)
44
+ * @param {'handoff-n'|'history-n'} input.scheme 本次链的命名方案
45
+ * @param {number|null} [input.titleSeq] 本次 rename 的宿主 eventSeq(拿不到就是 null)
46
+ * @param {string} [input.path] 交接单路径(非空字符串才写键)
47
+ * @param {string|null} [input.planId] 压缩计划 id(**仅 parent**;null 表示这次没压缩)
48
+ * @param {string} [input.historyTitle] 旧会话改名后的「历史 N」标题(**仅 parent**,且只在改名生效时传)
49
+ * @param {number} input.at 记录时间(epoch ms)
50
+ * @param {string} input.atIso 记录时间(本地 ISO,含时区偏移)
51
+ * @returns {object} 可直接交给 `engine.appendPluginRecord` 的 record
52
+ */
53
+ export function relayRecordOfForm({
54
+ role,
55
+ parentId,
56
+ childId,
57
+ ordinal,
58
+ baseTitle,
59
+ title,
60
+ scheme,
61
+ titleSeq = null,
62
+ path,
63
+ planId,
64
+ historyTitle,
65
+ rootId,
66
+ at,
67
+ atIso,
68
+ } = {}) {
69
+ if (role === 'parent') {
70
+ return {
71
+ parentId,
72
+ childId,
73
+ ordinal,
74
+ at,
75
+ atIso,
76
+ // 0.3 旧字段双写(旧记录只读兼容);顺序与 0.5.1 手写写法逐字节一致。
77
+ parentSessionId: parentId,
78
+ index: ordinal,
79
+ ...(typeof path === 'string' && path.length > 0 ? { path } : {}),
80
+ planId: planId ?? null,
81
+ title,
82
+ baseTitle,
83
+ scheme,
84
+ titleSeq,
85
+ // 改名真的生效(renamed / already)时调用方才传 historyTitle;空串也当没有。
86
+ ...(typeof historyTitle === 'string' && historyTitle.length > 0 ? { historyTitle } : {}),
87
+ // 0.6:链根会话 id(收藏文件按链根定位)。
88
+ ...(typeof rootId === 'string' && rootId.length > 0 ? { rootId } : {}),
89
+ };
90
+ }
91
+ if (role === 'child') {
92
+ return {
93
+ parentId,
94
+ childId,
95
+ ordinal,
96
+ baseTitle,
97
+ title,
98
+ scheme,
99
+ titleSeq,
100
+ // 旧调用方/落盘失败拿不到交接单 → 不写这个键(不猜、不写空串)。
101
+ ...(typeof path === 'string' && path.length > 0 ? { path } : {}),
102
+ at,
103
+ atIso,
104
+ };
105
+ }
106
+ // 角色是代码常量,传错属于开发期错误(不是用户输入),直接抛。
107
+ throw new TypeError(`relayRecordOfForm: 未知 role「${String(role)}」(只认 ${RELAY_RECORD_ROLES.join(' / ')})`);
108
+ }
109
+
110
+ /**
111
+ * 父记录(写在**旧会话**日志里;子会话建好、旧会话改名之后)。
112
+ *
113
+ * 多写 0.3 旧字段 `parentSessionId` / `index` 和 `planId`,可选 `historyTitle`。
114
+ * @param {Omit<Parameters<typeof relayRecordOfForm>[0], 'role'>} input
115
+ * @returns {object}
116
+ */
117
+ export function parentRelayRecordOf(input = {}) {
118
+ return relayRecordOfForm({ ...input, role: 'parent' });
119
+ }
120
+
121
+ /**
122
+ * 子记录(写在**新会话**日志里;`followup` 之后、`flush` 之前)。
123
+ *
124
+ * 只写 canonical 字段 + `titleSeq` + 条件 `path`;不写 `parentSessionId` / `index` /
125
+ * `planId` / `historyTitle`(这些是父会话侧的旧字段/审计字段)。
126
+ * @param {Omit<Parameters<typeof relayRecordOfForm>[0], 'role'>} input
127
+ * @returns {object}
128
+ */
129
+ export function childRelayRecordOf(input = {}) {
130
+ return relayRecordOfForm({ ...input, role: 'child' });
131
+ }