跳到主要内容

DeepSeek Harness 本地聊天记忆:按来源保存聊天原文,关键词 + 向量检索,可选大模型重排与检索摘要。源码公开,仅限非商用

浏览文件
main

分支

projection.js

129 行 · 7.4 KB历史下载
1/** Pure, bounded fold over committed DSH events; never scans host files. */
2export const PROJECTION_KEY = 'dsh-companion-memory.turn-tail';
3export 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}
8const LIMIT = 90000;
9const bound = value => value.length > LIMIT ? value.slice(0, LIMIT) + '\n[原文过长,已截断]' : value;
10const append = (old, text) => bound(old ? old + '\n' + text : text);
11export const isUser = message => message?.role === 'user' && message?.source?.kind === 'user';
12
13/** Glob (`*` only) → anchored, case-insensitive matcher on a tool name. */
14export 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}
18let speaks = toolMatcher(['*send_message*', '*send_group_message*', '*send_private_message*', '*reply*']);
19let 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. */
21export function setSpeakTools(globs) { speaks = toolMatcher(globs); }
22export function setHearTools(globs) { hears = toolMatcher(globs); }
23
24const TEXT_FIELDS = ['text', 'plain', 'content', 'message', 'raw_message'];
25const NAME_FIELDS = ['sender', 'senderName', 'nickname', 'card', 'name', 'user_name'];
26const ID_FIELDS = ['userId', 'user_id', 'senderId', 'sender_id'];
27const 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 */
42export 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. */
62export 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
78function 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}
88export 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};