@yeaft/webchat-agent 1.0.216 → 1.0.217
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/local-runtime/version.json +1 -1
- package/package.json +1 -1
- package/yeaft/conversation/search.js +100 -37
- package/yeaft/tools/history-search.js +152 -16
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":"1.0.
|
|
1
|
+
{"version":"1.0.217"}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* search.js — Conversation history search
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Bounded content search across hot and cold messages.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { existsSync, readdirSync, readFileSync, statSync } from 'fs';
|
|
@@ -18,36 +18,77 @@ function parseJsonLine(line) {
|
|
|
18
18
|
}
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
function normalizeTerms(keyword) {
|
|
22
|
+
return String(keyword || '')
|
|
23
|
+
.trim()
|
|
24
|
+
.toLocaleLowerCase()
|
|
25
|
+
.split(/\s+/u)
|
|
26
|
+
.filter(Boolean);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function searchableContent(msg) {
|
|
30
|
+
if (typeof msg?.content === 'string') return msg.content;
|
|
31
|
+
if (Array.isArray(msg?.content)) {
|
|
32
|
+
return msg.content
|
|
33
|
+
.map(block => typeof block === 'string' ? block : (block?.text || ''))
|
|
34
|
+
.filter(Boolean)
|
|
35
|
+
.join('\n');
|
|
36
|
+
}
|
|
37
|
+
return '';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function matchesMessage(msg, terms) {
|
|
41
|
+
if (!msg || msg.role === 'tool') return false;
|
|
42
|
+
const content = searchableContent(msg).toLocaleLowerCase();
|
|
43
|
+
return content.length > 0 && terms.every(term => content.includes(term));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function recordFileScan(telemetry, raw) {
|
|
47
|
+
if (!telemetry) return;
|
|
48
|
+
telemetry.scannedFiles += 1;
|
|
49
|
+
telemetry.scannedBytes += Buffer.byteLength(raw, 'utf8');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function recordMessageScan(telemetry) {
|
|
53
|
+
if (telemetry) telemetry.scannedMessages += 1;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function withSource(msg, source) {
|
|
57
|
+
return {
|
|
58
|
+
...msg,
|
|
59
|
+
content: searchableContent(msg),
|
|
60
|
+
sessionId: msg.sessionId || source.sessionId || null,
|
|
61
|
+
historySource: source.kind,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
21
65
|
/**
|
|
22
|
-
* Search Markdown messages in
|
|
23
|
-
*
|
|
24
|
-
* @param {string} dir — messages directory
|
|
25
|
-
* @param {string} keyword — search term (case-insensitive)
|
|
26
|
-
* @returns {object[]} — matching messages
|
|
66
|
+
* Search Markdown messages in one directory, newest first.
|
|
27
67
|
*/
|
|
28
|
-
function searchMarkdownDir(dir,
|
|
68
|
+
function searchMarkdownDir(dir, terms, limit, source, telemetry) {
|
|
29
69
|
if (!existsSync(dir)) return [];
|
|
30
70
|
|
|
31
|
-
const lowerKeyword = keyword.toLowerCase();
|
|
32
71
|
const files = readdirSync(dir)
|
|
33
72
|
.filter(f => f.endsWith('.md'))
|
|
34
73
|
.sort()
|
|
35
|
-
.reverse();
|
|
74
|
+
.reverse();
|
|
36
75
|
|
|
37
76
|
const results = [];
|
|
38
77
|
for (const file of files) {
|
|
78
|
+
if (results.length >= limit) break;
|
|
39
79
|
const raw = readFileSync(join(dir, file), 'utf8');
|
|
40
|
-
|
|
41
|
-
|
|
80
|
+
recordFileScan(telemetry, raw);
|
|
81
|
+
recordMessageScan(telemetry);
|
|
82
|
+
const lowerRaw = raw.toLocaleLowerCase();
|
|
83
|
+
if (!terms.every(term => lowerRaw.includes(term))) continue;
|
|
42
84
|
const msg = parseMessage(raw);
|
|
43
|
-
if (msg) results.push(msg);
|
|
85
|
+
if (matchesMessage(msg, terms)) results.push(withSource(msg, source));
|
|
44
86
|
}
|
|
45
87
|
return results;
|
|
46
88
|
}
|
|
47
89
|
|
|
48
|
-
function searchSegmentDir(dir,
|
|
90
|
+
function searchSegmentDir(dir, terms, limit, source, telemetry) {
|
|
49
91
|
if (!existsSync(dir)) return [];
|
|
50
|
-
const lowerKeyword = keyword.toLowerCase();
|
|
51
92
|
const files = readdirSync(dir)
|
|
52
93
|
.filter(f => f.endsWith('.jsonl'))
|
|
53
94
|
.sort()
|
|
@@ -55,24 +96,30 @@ function searchSegmentDir(dir, keyword) {
|
|
|
55
96
|
|
|
56
97
|
const results = [];
|
|
57
98
|
for (const file of files) {
|
|
99
|
+
if (results.length >= limit) break;
|
|
58
100
|
const raw = readFileSync(join(dir, file), 'utf8');
|
|
59
|
-
|
|
101
|
+
recordFileScan(telemetry, raw);
|
|
102
|
+
const lowerRaw = raw.toLocaleLowerCase();
|
|
103
|
+
if (!terms.every(term => lowerRaw.includes(term))) continue;
|
|
60
104
|
const lines = raw.split('\n');
|
|
61
105
|
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
62
|
-
|
|
63
|
-
if (!
|
|
64
|
-
|
|
65
|
-
|
|
106
|
+
if (results.length >= limit) break;
|
|
107
|
+
if (!lines[i]?.trim()) continue;
|
|
108
|
+
recordMessageScan(telemetry);
|
|
109
|
+
const msg = parseJsonLine(lines[i]);
|
|
110
|
+
if (matchesMessage(msg, terms)) results.push(withSource(msg, source));
|
|
66
111
|
}
|
|
67
112
|
}
|
|
68
113
|
return results;
|
|
69
114
|
}
|
|
70
115
|
|
|
71
116
|
function compareNewest(a, b) {
|
|
117
|
+
const timeComparison = String(b?.time || b?.timestamp || '').localeCompare(String(a?.time || a?.timestamp || ''));
|
|
118
|
+
if (timeComparison !== 0) return timeComparison;
|
|
119
|
+
if (a?.sessionId !== b?.sessionId || a?.historySource !== b?.historySource) return 0;
|
|
72
120
|
const sa = parseSeqFromId(a?.id);
|
|
73
121
|
const sb = parseSeqFromId(b?.id);
|
|
74
|
-
|
|
75
|
-
return String(b?.time || '').localeCompare(String(a?.time || ''));
|
|
122
|
+
return Number.isFinite(sa) && Number.isFinite(sb) ? sb - sa : 0;
|
|
76
123
|
}
|
|
77
124
|
|
|
78
125
|
function sessionConversationDirs(dir) {
|
|
@@ -92,40 +139,56 @@ function sessionConversationDirs(dir) {
|
|
|
92
139
|
const conversationDir = join(sessionDir, 'conversation');
|
|
93
140
|
if (seen.has(conversationDir)) continue;
|
|
94
141
|
seen.add(conversationDir);
|
|
95
|
-
dirs.push(conversationDir);
|
|
142
|
+
dirs.push({ dir: conversationDir, sessionId: name, kind: rootName === 'sessions' ? 'session' : 'legacy-session' });
|
|
96
143
|
}
|
|
97
144
|
}
|
|
98
145
|
return dirs;
|
|
99
146
|
}
|
|
100
147
|
|
|
101
148
|
/**
|
|
102
|
-
* Search Yeaft history (chat + per-session + legacy conversation)
|
|
149
|
+
* Search Yeaft history (chat + per-session + legacy conversation) by content.
|
|
150
|
+
* Whitespace-separated terms use AND semantics. Tool messages are excluded.
|
|
103
151
|
*
|
|
104
152
|
* @param {string} dir — Yeaft root directory (e.g. ~/.yeaft)
|
|
105
|
-
* @param {string} keyword — search
|
|
106
|
-
* @param {number} [limit=
|
|
153
|
+
* @param {string} keyword — search terms
|
|
154
|
+
* @param {number} [limit=10] — max results
|
|
155
|
+
* @param {{telemetry?: {scannedFiles?: number, scannedBytes?: number, scannedMessages?: number}}} [options]
|
|
107
156
|
* @returns {object[]} — matching messages, newest first
|
|
108
157
|
*/
|
|
109
|
-
export function searchMessages(dir, keyword, limit =
|
|
110
|
-
|
|
158
|
+
export function searchMessages(dir, keyword, limit = 10, options = {}) {
|
|
159
|
+
const terms = normalizeTerms(keyword);
|
|
160
|
+
if (terms.length === 0) return [];
|
|
161
|
+
|
|
162
|
+
const resultLimit = Math.max(1, Math.min(100, Math.floor(Number(limit) || 10)));
|
|
163
|
+
const telemetry = options.telemetry || null;
|
|
164
|
+
if (telemetry) {
|
|
165
|
+
telemetry.scannedFiles = 0;
|
|
166
|
+
telemetry.scannedBytes = 0;
|
|
167
|
+
telemetry.scannedMessages = 0;
|
|
168
|
+
}
|
|
111
169
|
|
|
112
170
|
const conversationDirs = [
|
|
113
|
-
join(dir, 'chat'),
|
|
171
|
+
{ dir: join(dir, 'chat'), sessionId: null, kind: 'chat' },
|
|
114
172
|
...sessionConversationDirs(dir),
|
|
115
173
|
];
|
|
116
174
|
|
|
117
175
|
const markdownDirs = [
|
|
118
|
-
...conversationDirs.flatMap(
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
176
|
+
...conversationDirs.flatMap(source => [
|
|
177
|
+
{ ...source, dir: join(source.dir, 'messages') },
|
|
178
|
+
{ ...source, dir: join(source.dir, 'cold') },
|
|
179
|
+
]),
|
|
180
|
+
{ dir: join(dir, 'conversation', 'messages'), sessionId: null, kind: 'legacy-conversation' },
|
|
181
|
+
{ dir: join(dir, 'conversation', 'cold'), sessionId: null, kind: 'legacy-conversation' },
|
|
122
182
|
];
|
|
123
|
-
const segmentDirs = conversationDirs.map(
|
|
183
|
+
const segmentDirs = conversationDirs.map(source => ({ ...source, dir: join(source.dir, 'segments') }));
|
|
124
184
|
|
|
125
|
-
|
|
126
|
-
...segmentDirs.flatMap(
|
|
127
|
-
...markdownDirs.flatMap(
|
|
185
|
+
const results = [
|
|
186
|
+
...segmentDirs.flatMap(source => searchSegmentDir(source.dir, terms, resultLimit, source, telemetry)),
|
|
187
|
+
...markdownDirs.flatMap(source => searchMarkdownDir(source.dir, terms, resultLimit, source, telemetry)),
|
|
128
188
|
]
|
|
129
189
|
.sort(compareNewest)
|
|
130
|
-
.slice(0,
|
|
190
|
+
.slice(0, resultLimit);
|
|
191
|
+
|
|
192
|
+
if (telemetry) telemetry.resultCount = results.length;
|
|
193
|
+
return results;
|
|
131
194
|
}
|
|
@@ -8,20 +8,144 @@
|
|
|
8
8
|
import { defineTool } from './types.js';
|
|
9
9
|
import { searchMessages } from '../conversation/search.js';
|
|
10
10
|
|
|
11
|
+
const DEFAULT_RESULT_LIMIT = 10;
|
|
12
|
+
const MAX_SNIPPET_CHARS = 1000;
|
|
13
|
+
export const HISTORY_SEARCH_MAX_OUTPUT_BYTES = 32 * 1024;
|
|
14
|
+
|
|
15
|
+
function truncateUtf8(text, maxBytes) {
|
|
16
|
+
if (maxBytes <= 0) return '';
|
|
17
|
+
const buffer = Buffer.from(String(text), 'utf8');
|
|
18
|
+
if (buffer.length <= maxBytes) return String(text);
|
|
19
|
+
let end = maxBytes;
|
|
20
|
+
while (end > 0 && (buffer[end] & 0xc0) === 0x80) end -= 1;
|
|
21
|
+
return buffer.subarray(0, end).toString('utf8');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function isHighSurrogate(codeUnit) {
|
|
25
|
+
return codeUnit >= 0xd800 && codeUnit <= 0xdbff;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function isLowSurrogate(codeUnit) {
|
|
29
|
+
return codeUnit >= 0xdc00 && codeUnit <= 0xdfff;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function lowercaseWithOriginalOffsets(text) {
|
|
33
|
+
const fullLower = text.toLocaleLowerCase();
|
|
34
|
+
const lowerParts = [];
|
|
35
|
+
const originalOffsets = [];
|
|
36
|
+
for (let offset = 0; offset < text.length;) {
|
|
37
|
+
const codePoint = String.fromCodePoint(text.codePointAt(offset));
|
|
38
|
+
const loweredCodePoint = codePoint.toLocaleLowerCase();
|
|
39
|
+
lowerParts.push(loweredCodePoint);
|
|
40
|
+
for (let i = 0; i < loweredCodePoint.length; i += 1) originalOffsets.push(offset);
|
|
41
|
+
offset += codePoint.length;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const mappedLower = lowerParts.join('');
|
|
45
|
+
return {
|
|
46
|
+
lower: mappedLower.length === fullLower.length ? fullLower : mappedLower,
|
|
47
|
+
originalOffsets,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function buildSnippet(content, keyword, maxChars = MAX_SNIPPET_CHARS) {
|
|
52
|
+
const text = String(content || '');
|
|
53
|
+
if (text.length <= maxChars) return text;
|
|
54
|
+
|
|
55
|
+
const terms = String(keyword || '').trim().toLocaleLowerCase().split(/\s+/u).filter(Boolean);
|
|
56
|
+
const { lower, originalOffsets } = lowercaseWithOriginalOffsets(text);
|
|
57
|
+
const positions = terms.map(term => lower.indexOf(term)).filter(pos => pos >= 0);
|
|
58
|
+
const transformedMatchAt = positions.length > 0 ? Math.min(...positions) : 0;
|
|
59
|
+
const matchAt = originalOffsets[transformedMatchAt] ?? 0;
|
|
60
|
+
let start = Math.max(0, Math.min(matchAt - Math.floor(maxChars / 3), text.length - maxChars));
|
|
61
|
+
let end = Math.min(text.length, start + maxChars);
|
|
62
|
+
|
|
63
|
+
if (start > 0 && isLowSurrogate(text.charCodeAt(start)) && isHighSurrogate(text.charCodeAt(start - 1))) {
|
|
64
|
+
start -= 1;
|
|
65
|
+
}
|
|
66
|
+
if (end < text.length && isLowSurrogate(text.charCodeAt(end)) && isHighSurrogate(text.charCodeAt(end - 1))) {
|
|
67
|
+
end -= 1;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return `${start > 0 ? '...' : ''}${text.slice(start, end)}${end < text.length ? '...' : ''}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function serializeHistorySearchOutput(payload, maxBytes = HISTORY_SEARCH_MAX_OUTPUT_BYTES) {
|
|
74
|
+
const serialize = value => JSON.stringify(value, null, 2);
|
|
75
|
+
let output = serialize(payload);
|
|
76
|
+
if (Buffer.byteLength(output, 'utf8') <= maxBytes) return output;
|
|
77
|
+
|
|
78
|
+
const originalResultCount = payload.results.length;
|
|
79
|
+
const bounded = {
|
|
80
|
+
...payload,
|
|
81
|
+
results: [],
|
|
82
|
+
truncated: true,
|
|
83
|
+
omittedResults: originalResultCount,
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
for (const result of payload.results) {
|
|
87
|
+
const candidate = {
|
|
88
|
+
...bounded,
|
|
89
|
+
results: [...bounded.results, result],
|
|
90
|
+
omittedResults: originalResultCount - bounded.results.length - 1,
|
|
91
|
+
};
|
|
92
|
+
const candidateOutput = serialize(candidate);
|
|
93
|
+
if (Buffer.byteLength(candidateOutput, 'utf8') <= maxBytes) {
|
|
94
|
+
bounded.results.push(result);
|
|
95
|
+
bounded.omittedResults -= 1;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const emptyContentCandidate = {
|
|
100
|
+
...candidate,
|
|
101
|
+
results: [...bounded.results, { ...result, content: '' }],
|
|
102
|
+
};
|
|
103
|
+
const emptyOutput = serialize(emptyContentCandidate);
|
|
104
|
+
if (Buffer.byteLength(emptyOutput, 'utf8') > maxBytes) break;
|
|
105
|
+
|
|
106
|
+
let low = 0;
|
|
107
|
+
let high = Buffer.byteLength(result.content || '', 'utf8');
|
|
108
|
+
let best = '';
|
|
109
|
+
while (low <= high) {
|
|
110
|
+
const mid = Math.floor((low + high) / 2);
|
|
111
|
+
const content = truncateUtf8(result.content || '', mid);
|
|
112
|
+
const partialOutput = serialize({
|
|
113
|
+
...candidate,
|
|
114
|
+
results: [...bounded.results, { ...result, content }],
|
|
115
|
+
});
|
|
116
|
+
if (Buffer.byteLength(partialOutput, 'utf8') <= maxBytes) {
|
|
117
|
+
best = content;
|
|
118
|
+
low = mid + 1;
|
|
119
|
+
} else {
|
|
120
|
+
high = mid - 1;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
bounded.results.push({ ...result, content: best });
|
|
124
|
+
bounded.omittedResults -= 1;
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
output = serialize(bounded);
|
|
129
|
+
if (Buffer.byteLength(output, 'utf8') > maxBytes) {
|
|
130
|
+
throw new Error('History search output metadata exceeds the 32 KiB budget');
|
|
131
|
+
}
|
|
132
|
+
return output;
|
|
133
|
+
}
|
|
134
|
+
|
|
11
135
|
export default defineTool({
|
|
12
136
|
name: 'HistorySearch',
|
|
13
137
|
description: {
|
|
14
138
|
en: `Search through past conversation history.
|
|
15
139
|
|
|
16
|
-
Searches
|
|
17
|
-
Useful for finding previous discussions, decisions, or code snippets.
|
|
140
|
+
Searches message content for all whitespace-separated terms (case-insensitive).
|
|
141
|
+
Tool-result messages are excluded. Useful for finding previous discussions, decisions, or code snippets.
|
|
18
142
|
|
|
19
|
-
Results are returned newest-first with
|
|
143
|
+
Results are returned newest-first with a bounded matching snippet and source metadata.`,
|
|
20
144
|
zh: `搜索历史对话记录。
|
|
21
145
|
|
|
22
|
-
|
|
146
|
+
在已持久化消息的正文中搜索全部空格分隔的关键词(不区分大小写),并排除工具结果消息。用于查找之前的讨论、决策或代码片段。
|
|
23
147
|
|
|
24
|
-
|
|
148
|
+
结果按最新优先返回,包含有界的命中片段和来源信息。`
|
|
25
149
|
},
|
|
26
150
|
parameters: {
|
|
27
151
|
type: 'object',
|
|
@@ -29,15 +153,15 @@ Results are returned newest-first with message role and content.`,
|
|
|
29
153
|
keyword: {
|
|
30
154
|
type: 'string',
|
|
31
155
|
description: {
|
|
32
|
-
en: 'Search
|
|
33
|
-
zh: '
|
|
156
|
+
en: 'Search terms (case-insensitive, whitespace-separated terms use AND semantics)',
|
|
157
|
+
zh: '搜索关键词(不区分大小写,空格分隔的多个词采用 AND 语义)',
|
|
34
158
|
},
|
|
35
159
|
},
|
|
36
160
|
limit: {
|
|
37
161
|
type: 'number',
|
|
38
162
|
description: {
|
|
39
|
-
en: 'Maximum number of results (default:
|
|
40
|
-
zh: '最多返回结果数(默认
|
|
163
|
+
en: 'Maximum number of results (default: 10, maximum: 100)',
|
|
164
|
+
zh: '最多返回结果数(默认 10,最大 100)',
|
|
41
165
|
},
|
|
42
166
|
},
|
|
43
167
|
},
|
|
@@ -46,7 +170,7 @@ Results are returned newest-first with message role and content.`,
|
|
|
46
170
|
isConcurrencySafe: () => true,
|
|
47
171
|
isReadOnly: () => true,
|
|
48
172
|
async execute(input, ctx) {
|
|
49
|
-
const { keyword, limit =
|
|
173
|
+
const { keyword, limit = DEFAULT_RESULT_LIMIT } = input;
|
|
50
174
|
if (!keyword) return JSON.stringify({ error: 'keyword is required' });
|
|
51
175
|
|
|
52
176
|
const yeaftDir = ctx?.yeaftDir;
|
|
@@ -55,25 +179,37 @@ Results are returned newest-first with message role and content.`,
|
|
|
55
179
|
}
|
|
56
180
|
|
|
57
181
|
try {
|
|
58
|
-
const
|
|
182
|
+
const telemetry = {};
|
|
183
|
+
const results = searchMessages(yeaftDir, keyword, limit, { telemetry });
|
|
184
|
+
const searchTelemetry = {
|
|
185
|
+
resultCount: results.length,
|
|
186
|
+
scannedFiles: telemetry.scannedFiles || 0,
|
|
187
|
+
scannedMessages: telemetry.scannedMessages || 0,
|
|
188
|
+
scannedBytes: telemetry.scannedBytes || 0,
|
|
189
|
+
};
|
|
59
190
|
|
|
60
191
|
if (results.length === 0) {
|
|
61
|
-
return
|
|
192
|
+
return serializeHistorySearchOutput({
|
|
62
193
|
results: [],
|
|
63
194
|
message: `No matches found for "${keyword}"`,
|
|
195
|
+
telemetry: searchTelemetry,
|
|
64
196
|
});
|
|
65
197
|
}
|
|
66
198
|
|
|
67
|
-
return
|
|
199
|
+
return serializeHistorySearchOutput({
|
|
68
200
|
results: results.map(msg => ({
|
|
201
|
+
messageId: msg.id || null,
|
|
202
|
+
sessionId: msg.sessionId || null,
|
|
69
203
|
role: msg.role,
|
|
70
|
-
content: msg.content
|
|
204
|
+
content: buildSnippet(msg.content, keyword),
|
|
71
205
|
mode: msg.mode,
|
|
72
|
-
|
|
206
|
+
time: msg.time || msg.timestamp || null,
|
|
207
|
+
source: msg.historySource || null,
|
|
73
208
|
})),
|
|
74
209
|
totalResults: results.length,
|
|
75
210
|
keyword,
|
|
76
|
-
|
|
211
|
+
telemetry: searchTelemetry,
|
|
212
|
+
});
|
|
77
213
|
} catch (err) {
|
|
78
214
|
return JSON.stringify({ error: `History search failed: ${err.message}` });
|
|
79
215
|
}
|