@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.
@@ -1 +1 @@
1
- {"version":"1.0.216"}
1
+ {"version":"1.0.217"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "1.0.216",
3
+ "version": "1.0.217",
4
4
  "description": "Remote worker agent for Yeaft Web Code Agent — connects the native Yeaft engine, CLI providers, and workbench tools",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * search.js — Conversation history search
3
3
  *
4
- * Simple keyword search across hot and cold messages.
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 a directory for a keyword.
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, keyword) {
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(); // newest first within one directory
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
- if (!raw.toLowerCase().includes(lowerKeyword)) continue;
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, keyword) {
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
- if (!raw.toLowerCase().includes(lowerKeyword)) continue;
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
- const line = lines[i];
63
- if (!line || !line.toLowerCase().includes(lowerKeyword)) continue;
64
- const msg = parseJsonLine(line);
65
- if (msg) results.push(msg);
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
- if (Number.isFinite(sa) && Number.isFinite(sb) && sa !== sb) return sb - sa;
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) for a keyword.
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 term
106
- * @param {number} [limit=20] — max results
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 = 20) {
110
- if (!keyword || !keyword.trim()) return [];
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(d => [join(d, 'messages'), join(d, 'cold')]),
119
- // Compatibility for profiles created before chat/session split.
120
- join(dir, 'conversation', 'messages'),
121
- join(dir, 'conversation', 'cold'),
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(d => join(d, 'segments'));
183
+ const segmentDirs = conversationDirs.map(source => ({ ...source, dir: join(source.dir, 'segments') }));
124
184
 
125
- return [
126
- ...segmentDirs.flatMap(d => searchSegmentDir(d, keyword)),
127
- ...markdownDirs.flatMap(d => searchMarkdownDir(d, keyword)),
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, limit);
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 for keywords in previously persisted conversation messages.
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 message role and content.`,
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 keyword (case-insensitive)',
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: 20)',
40
- zh: '最多返回结果数(默认 20)',
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 = 20 } = input;
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 results = searchMessages(yeaftDir, keyword, limit);
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 JSON.stringify({
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 JSON.stringify({
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?.slice(0, 2000) + (msg.content?.length > 2000 ? '...' : ''),
204
+ content: buildSnippet(msg.content, keyword),
71
205
  mode: msg.mode,
72
- timestamp: msg.timestamp,
206
+ time: msg.time || msg.timestamp || null,
207
+ source: msg.historySource || null,
73
208
  })),
74
209
  totalResults: results.length,
75
210
  keyword,
76
- }, null, 2);
211
+ telemetry: searchTelemetry,
212
+ });
77
213
  } catch (err) {
78
214
  return JSON.stringify({ error: `History search failed: ${err.message}` });
79
215
  }