/** * DSH 消息与会话日志的纯函数工具。 * * 单独放一个模块是为了能被单测覆盖 —— 其中 userMessage() 的形状曾让整个插件 * 卡了一下午(见该函数注释),这种「错了不报错、只在深处炸一个无关的类型错误」 * 的约定必须被测试钉住。 */ /** * 构造 DSH 的 UserMessage。 * * **这是 followup()/steer() 唯一接受的形状。** DSH 的 `agent.followup(message)` * 要的是完整的 UserMessage(`content` + `source`),不是 opencode 那种 parts 数组。 * 传数组进去不会当场报错:agent-loop 会一路走到 preStep 里读 `message.source.kind`, * 然后抛 `Cannot read properties of undefined (reading 'kind')` —— 错误信息落在 * agent-loop 内部,完全不指向调用点。 * * @param {string} text 正文 * @returns {{content: {type: 'text', text: string}[], source: {kind: 'user'}}} */ export function userMessage(text) { return { content: [{ type: 'text', text: String(text) }], source: { kind: 'user' }, }; } /** 去掉已有的 Re: 前缀,避免 Re: Re: Re: 无限叠加。 */ export function stripRe(subject) { return String(subject ?? '').replace(/^(\s*Re:\s*)+/i, ''); } /** * 回信主题:原主题前加一个 Re:,空主题给一个兜底。 * @param {string} subject 来信主题 * @param {string} fallback 主题为空时用的标题 */ export function replySubject(subject, fallback = 'DSH 回复') { const base = stripRe(subject).trim(); return base ? `Re: ${base}` : fallback; } /** * 从会话事件日志里取最后一条 assistant 消息的可见文本。 * * 只取 `type === 'text'` 的块:reasoning 块是模型的思考过程,不该出现在邮件里。 * * @param {readonly any[]} events session.events * @returns {string} 文本,找不到时为空串 */ export function lastAssistantText(events) { const list = Array.isArray(events) ? events : []; for (let i = list.length - 1; i >= 0; i--) { const ev = list[i]; if (ev?.type !== 'assistant/message') continue; const blocks = ev.data?.message?.content; if (!Array.isArray(blocks)) return ''; return blocks .filter((b) => b?.type === 'text' && typeof b.text === 'string') .map((b) => b.text) .join('\n') .trim(); } return ''; } /** * 从会话事件日志里取最后一次 session/title 的标题。 * * DSH 首轮结束后由模型生成摘要标题,之前是 `source.kind === 'fallback'` 的占位 * (内容就是用户第一句话的截断)。占位标题不值得回写给 AgentMail,会把 * 「你收到一封新邮件(AgentMail)」这种插件自己的提示词当成会话标题。 * * @param {readonly any[]} events session.events * @returns {string} 模型生成的标题,没有则空串 */ export function modelTitle(events) { const list = Array.isArray(events) ? events : []; for (let i = list.length - 1; i >= 0; i--) { const ev = list[i]; if (ev?.type !== 'session/title') continue; const kind = ev.data?.source?.kind; if (kind === 'fallback') return ''; const title = ev.data?.title; return typeof title === 'string' ? title.trim() : ''; } return ''; }