DeepSeek Harness 本地聊天记忆:按来源保存聊天原文,关键词 + 向量检索,可选大模型重排与检索摘要。源码公开,仅限非商用
浏览文件
main
分支
projection.js
129 行 · 7.4 KB历史下载| 1 | /** Pure, bounded fold over committed DSH events; never scans host files. */ |
| 2 | export const PROJECTION_KEY = 'dsh-companion-memory.turn-tail'; |
| 3 | export function textOf(message) { |
| 4 | return Array.isArray(message?.content) ? message.content |
| 5 | .filter(block => block?.type === 'text' && typeof block.text === 'string') |
| 6 | .map(block => block.text).join('\n').trim() : ''; |
| 7 | } |
| 8 | const LIMIT = 90000; |
| 9 | const bound = value => value.length > LIMIT ? value.slice(0, LIMIT) + '\n[原文过长,已截断]' : value; |
| 10 | const append = (old, text) => bound(old ? old + '\n' + text : text); |
| 11 | export const isUser = message => message?.role === 'user' && message?.source?.kind === 'user'; |
| 12 | |
| 13 | /** Glob (`*` only) → anchored, case-insensitive matcher on a tool name. */ |
| 14 | export function toolMatcher(globs) { |
| 15 | const res = globs.map(glob => new RegExp('^' + glob.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replaceAll('*', '.*') + '$', 'i')); |
| 16 | return name => res.some(re => re.test(name)); |
| 17 | } |
| 18 | let speaks = toolMatcher(['*send_message*', '*send_group_message*', '*send_private_message*', '*reply*']); |
| 19 | let hears = toolMatcher(['*unread*', '*recent_messages*', '*wait_for_messages*', '*message_history*', '*chat_history*', '*get_messages*']); |
| 20 | /** Apply to events folded from now on; already-folded state is not rewritten. */ |
| 21 | export function setSpeakTools(globs) { speaks = toolMatcher(globs); } |
| 22 | export function setHearTools(globs) { hears = toolMatcher(globs); } |
| 23 | |
| 24 | const TEXT_FIELDS = ['text', 'plain', 'content', 'message', 'raw_message']; |
| 25 | const NAME_FIELDS = ['sender', 'senderName', 'nickname', 'card', 'name', 'user_name']; |
| 26 | const ID_FIELDS = ['userId', 'user_id', 'senderId', 'sender_id']; |
| 27 | const nameOf = item => { |
| 28 | for (const key of NAME_FIELDS) { |
| 29 | const value = item[key]; |
| 30 | if (typeof value === 'string' && value.trim()) return value.trim(); |
| 31 | if (value && typeof value === 'object') { const nested = value.card || value.nickname || value.name; if (typeof nested === 'string' && nested.trim()) return nested.trim(); } |
| 32 | } |
| 33 | for (const key of ID_FIELDS) if (item[key] != null && String(item[key]).trim()) return String(item[key]).trim(); |
| 34 | return ''; |
| 35 | }; |
| 36 | /** |
| 37 | * Messages a companion read through a tool, as "name:text" lines. Understands the common |
| 38 | * shape of chat APIs (objects with a sender and a text field, nested anywhere in the JSON) |
| 39 | * without knowing any particular bridge; its own messages are skipped (recorded as spoken). |
| 40 | * Non-JSON results are kept as plain text. |
| 41 | */ |
| 42 | export function heardText(raw) { |
| 43 | let data; |
| 44 | try { data = JSON.parse(raw); } catch { return String(raw ?? '').trim(); } |
| 45 | const lines = []; |
| 46 | const visit = (node, depth) => { |
| 47 | if (depth > 6 || lines.length >= 200 || node === null || typeof node !== 'object') return; |
| 48 | if (Array.isArray(node)) { for (const item of node) visit(item, depth + 1); return; } |
| 49 | const field = TEXT_FIELDS.find(key => typeof node[key] === 'string' && node[key].trim()); |
| 50 | const name = field && nameOf(node); |
| 51 | if (field && name) { |
| 52 | if (node.isSelf !== true && node.self !== true) lines.push(`${name.slice(0, 40)}:${node[field].trim().replace(/\s*\n\s*/g, ' ')}`); |
| 53 | return; |
| 54 | } |
| 55 | for (const value of Object.values(node)) visit(value, depth + 1); |
| 56 | }; |
| 57 | visit(data, 0); |
| 58 | return [...new Set(lines)].join('\n'); |
| 59 | } |
| 60 | |
| 61 | /** Text a companion sends through a tool: top-level string fields, or text segments. */ |
| 62 | export function spokenText(rawArguments) { |
| 63 | let args; |
| 64 | try { args = JSON.parse(rawArguments); } catch { return ''; } |
| 65 | const out = []; |
| 66 | for (const key of ['message', 'text', 'content']) { |
| 67 | const value = args?.[key]; |
| 68 | if (typeof value === 'string') out.push(value); |
| 69 | else if (Array.isArray(value)) for (const part of value) { |
| 70 | if (typeof part === 'string') out.push(part); |
| 71 | else if (typeof part?.text === 'string') out.push(part.text); |
| 72 | else if (typeof part?.data?.text === 'string') out.push(part.data.text); |
| 73 | } |
| 74 | } |
| 75 | return out.join('').trim(); |
| 76 | } |
| 77 | |
| 78 | function parseState(value) { |
| 79 | if (!value || !Array.isArray(value.completed) || value.completed.length > 32 || |
| 80 | !Number.isSafeInteger(value.inherited) || !(value.turn === null || Number.isSafeInteger(value.turn))) throw new Error('无效记忆投影'); |
| 81 | const text = value => typeof value === 'string' && value.length <= LIMIT + 100; |
| 82 | const valid = row => row && Number.isSafeInteger(row.turn) && Number.isSafeInteger(row.seq) && |
| 83 | Number.isSafeInteger(row.time) && text(row.user) && text(row.assistant) && text(row.spoken) && text(row.heard) && |
| 84 | Array.isArray(row.listening) && row.listening.length <= 32; |
| 85 | if ((value.pending !== null && !valid(value.pending)) || !value.completed.every(valid)) throw new Error('无效记忆投影条目'); |
| 86 | return value; |
| 87 | } |
| 88 | export const turnProjection = { |
| 89 | key: PROJECTION_KEY, stateVersion: 3, stateSchema: { parse: parseState }, |
| 90 | init: (_header, inheritedEventCount = 0) => ({ inherited: inheritedEventCount, turn: null, pending: null, completed: [] }), |
| 91 | apply(state, event) { |
| 92 | if (event.seq < state.inherited) return state; |
| 93 | if (event.type === 'turn/start') return { ...state, turn: event.data.turn, pending: null }; |
| 94 | if (event.type === 'user/message' && isUser(event.data) && state.turn !== null) { |
| 95 | const text = textOf(event.data); if (!text) return state; |
| 96 | const old = state.pending; |
| 97 | return { ...state, pending: { turn: state.turn, seq: old?.seq ?? event.seq, time: old?.time ?? event.time, |
| 98 | user: append(old?.user, text), assistant: old?.assistant ?? '', spoken: old?.spoken ?? '', heard: old?.heard ?? '', listening: old?.listening ?? [] } }; |
| 99 | } |
| 100 | if (event.type === 'assistant/message' && state.pending && event.data.turn === state.turn) { |
| 101 | const message = event.data.message; |
| 102 | // Text beside tool calls is the agent thinking aloud, not something said to the person. |
| 103 | if (message?.source?.interrupted || message?.content?.some(block => block.type === 'tool-call')) return state; |
| 104 | const text = textOf(message); if (!text) return state; |
| 105 | return { ...state, pending: { ...state.pending, assistant: append(state.pending.assistant, text) } }; |
| 106 | } |
| 107 | if (event.type === 'tool/call' && state.pending && event.data.turn === state.turn) { |
| 108 | const name = String(event.data.name); |
| 109 | if (hears(name) && state.pending.listening.length < 32) |
| 110 | return { ...state, pending: { ...state.pending, listening: [...state.pending.listening, String(event.data.callId)] } }; |
| 111 | if (!speaks(name)) return state; |
| 112 | const text = spokenText(event.data.arguments); if (!text) return state; |
| 113 | return { ...state, pending: { ...state.pending, spoken: append(state.pending.spoken, text) } }; |
| 114 | } |
| 115 | if (event.type === 'tool/result' && state.pending && event.data.turn === state.turn && |
| 116 | state.pending.listening.includes(String(event.data.message?.toolCallId)) && !event.data.message?.isError) { |
| 117 | const text = heardText(textOf(event.data.message)); if (!text) return state; |
| 118 | return { ...state, pending: { ...state.pending, heard: append(state.pending.heard, text) } }; |
| 119 | } |
| 120 | if (event.type === 'turn/end' && event.data.turn === state.turn) { |
| 121 | const row = state.pending; |
| 122 | // A turn is worth keeping when the companion said something or heard others talk. |
| 123 | const accepted = event.data.reason?.kind === 'completed' && row?.user && (row.assistant || row.spoken || row.heard); |
| 124 | const { listening, ...kept } = row ?? {}; |
| 125 | return { ...state, turn: null, pending: null, completed: accepted ? [...state.completed, { ...kept, listening: [] }].slice(-32) : state.completed }; |
| 126 | } |
| 127 | return state; |
| 128 | }, |
| 129 | }; |

