@yeaft/webchat-agent 0.1.408 → 0.1.409

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.
@@ -0,0 +1,243 @@
1
+ /**
2
+ * recall.js — 3-step memory recall with fingerprint cache
3
+ *
4
+ * Recall flow (per design doc):
5
+ * Step 1: Keyword extraction (pure rules, <1ms)
6
+ * Step 2: Scope + Tags filter (read scopes.md, <5ms) → top 15 candidates
7
+ * Step 3: LLM select (side-query via adapter.call) → ≤7 most relevant
8
+ *
9
+ * Fingerprint cache:
10
+ * fingerprint = hash(scope, top 5 keywords, task_id)
11
+ * Same fingerprint → skip recall, reuse last result
12
+ *
13
+ * Reference: yeaft-unify-core-systems.md §3.2, yeaft-unify-design.md §5.1
14
+ */
15
+
16
+ import { createHash } from 'crypto';
17
+
18
+ // ─── Constants ──────────────────────────────────────────────────
19
+
20
+ /** Max entries returned by recall. */
21
+ const MAX_RECALL_RESULTS = 7;
22
+
23
+ /** Max candidates passed to LLM select (Step 2 → Step 3). */
24
+ const MAX_CANDIDATES = 15;
25
+
26
+ // ─── Step 1: Keyword Extraction (pure rules, <1ms) ──────────────
27
+
28
+ /** Common stop words to filter out. */
29
+ const STOP_WORDS = new Set([
30
+ 'the', 'a', 'an', 'is', 'are', 'was', 'were', 'be', 'been', 'being',
31
+ 'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could',
32
+ 'should', 'may', 'might', 'can', 'shall', 'to', 'of', 'in', 'for',
33
+ 'on', 'with', 'at', 'by', 'from', 'as', 'into', 'through', 'during',
34
+ 'before', 'after', 'above', 'below', 'between', 'out', 'off', 'over',
35
+ 'under', 'again', 'further', 'then', 'once', 'here', 'there', 'when',
36
+ 'where', 'why', 'how', 'all', 'both', 'each', 'few', 'more', 'most',
37
+ 'other', 'some', 'such', 'no', 'nor', 'not', 'only', 'own', 'same',
38
+ 'so', 'than', 'too', 'very', 'just', 'because', 'but', 'and', 'or',
39
+ 'if', 'while', 'about', 'up', 'it', 'its', 'my', 'me', 'i', 'you',
40
+ 'your', 'we', 'our', 'they', 'them', 'their', 'this', 'that', 'what',
41
+ 'which', 'who', 'whom', 'these', 'those',
42
+ // Chinese stop words
43
+ '的', '了', '在', '是', '我', '有', '和', '就', '不', '人', '都',
44
+ '一', '一个', '上', '也', '很', '到', '说', '要', '去', '你', '会',
45
+ '着', '没有', '看', '好', '自己', '这', '他', '她', '吗', '呢', '吧',
46
+ '把', '被', '那', '它', '让', '给', '可以', '什么', '怎么', '帮',
47
+ '帮我', '请', '能', '想',
48
+ ]);
49
+
50
+ /**
51
+ * Extract keywords from a prompt (pure rules, no LLM).
52
+ *
53
+ * @param {string} prompt
54
+ * @returns {string[]} — keywords sorted by relevance (simple freq)
55
+ */
56
+ export function extractKeywords(prompt) {
57
+ if (!prompt || !prompt.trim()) return [];
58
+
59
+ // Tokenize: split on whitespace and punctuation (keep CJK chars)
60
+ const tokens = prompt
61
+ .toLowerCase()
62
+ .replace(/[^\w\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff]+/g, ' ')
63
+ .split(/\s+/)
64
+ .filter(t => t.length > 1 && !STOP_WORDS.has(t));
65
+
66
+ // Count frequencies
67
+ const freq = new Map();
68
+ for (const t of tokens) {
69
+ freq.set(t, (freq.get(t) || 0) + 1);
70
+ }
71
+
72
+ // Sort by frequency descending, then alphabetically
73
+ return [...freq.entries()]
74
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
75
+ .map(([word]) => word);
76
+ }
77
+
78
+ // ─── Fingerprint Cache ──────────────────────────────────────────
79
+
80
+ /**
81
+ * Compute a recall fingerprint for cache checking.
82
+ *
83
+ * @param {{ scope?: string, keywords: string[], taskId?: string }} params
84
+ * @returns {string} — hex hash
85
+ */
86
+ export function computeFingerprint({ scope = '', keywords, taskId = '' }) {
87
+ const top5 = keywords.slice(0, 5).join(',');
88
+ const input = `${scope}|${top5}|${taskId}`;
89
+ return createHash('sha256').update(input).digest('hex').slice(0, 16);
90
+ }
91
+
92
+ // ─── Step 2: Scope + Tags Filter ────────────────────────────────
93
+
94
+ /**
95
+ * Filter entries by scope and tags (in-memory, no LLM).
96
+ * Uses MemoryStore.findByFilter internally.
97
+ *
98
+ * @param {import('./store.js').MemoryStore} memoryStore
99
+ * @param {{ scope?: string, keywords: string[] }} params
100
+ * @returns {object[]} — top MAX_CANDIDATES entries
101
+ */
102
+ function filterCandidates(memoryStore, { scope, keywords }) {
103
+ return memoryStore.findByFilter({
104
+ scope,
105
+ tags: keywords,
106
+ limit: MAX_CANDIDATES,
107
+ });
108
+ }
109
+
110
+ // ─── Step 3: LLM Select ────────────────────────────────────────
111
+
112
+ /**
113
+ * Use LLM side-query to select the most relevant entries.
114
+ *
115
+ * @param {object} adapter — LLM adapter with .call() method
116
+ * @param {object} config — { model }
117
+ * @param {string} prompt — user's prompt
118
+ * @param {object[]} candidates — entries with frontmatter
119
+ * @returns {Promise<string[]>} — selected entry names
120
+ */
121
+ async function llmSelect(adapter, config, prompt, candidates) {
122
+ if (candidates.length <= MAX_RECALL_RESULTS) {
123
+ // No need to filter if already under limit
124
+ return candidates.map(c => c.name);
125
+ }
126
+
127
+ const candidateList = candidates.map((c, i) =>
128
+ `${i + 1}. [${c.name}] kind=${c.kind}, scope=${c.scope}, tags=[${(c.tags || []).join(', ')}]`
129
+ ).join('\n');
130
+
131
+ const system = `You are a memory retrieval assistant. Given a user's prompt and a list of memory entries, select the most relevant ones (up to ${MAX_RECALL_RESULTS}).
132
+ Return ONLY a JSON array of entry names, like: ["entry-name-1", "entry-name-2"]
133
+ No explanation, just the JSON array.`;
134
+
135
+ const messages = [{
136
+ role: 'user',
137
+ content: `User prompt: "${prompt}"
138
+
139
+ Memory entries:
140
+ ${candidateList}
141
+
142
+ Select the ${MAX_RECALL_RESULTS} most relevant entries. Return a JSON array of entry names.`,
143
+ }];
144
+
145
+ try {
146
+ const result = await adapter.call({
147
+ model: config.model,
148
+ system,
149
+ messages,
150
+ maxTokens: 512,
151
+ });
152
+
153
+ // Parse the JSON array from the response
154
+ const text = result.text.trim();
155
+ const jsonMatch = text.match(/\[[\s\S]*\]/);
156
+ if (jsonMatch) {
157
+ const names = JSON.parse(jsonMatch[0]);
158
+ return names.filter(n => typeof n === 'string');
159
+ }
160
+ } catch {
161
+ // Fallback: return all candidates if LLM fails
162
+ }
163
+
164
+ return candidates.slice(0, MAX_RECALL_RESULTS).map(c => c.name);
165
+ }
166
+
167
+ // ─── Main Recall Function ───────────────────────────────────────
168
+
169
+ /** @type {Map<string, { entries: object[], timestamp: number }>} */
170
+ const _cache = new Map();
171
+
172
+ /** Cache TTL — 5 minutes. */
173
+ const CACHE_TTL = 5 * 60 * 1000;
174
+
175
+ /**
176
+ * Recall relevant memory entries for a given prompt.
177
+ *
178
+ * 3-step process:
179
+ * 1. Extract keywords (rules, <1ms)
180
+ * 2. Scope + Tags filter → top 15 candidates
181
+ * 3. LLM select → ≤7 entries (skipped if ≤7 candidates)
182
+ *
183
+ * Uses fingerprint cache to skip repeat recalls.
184
+ *
185
+ * @param {{ prompt: string, adapter: object, config: object, memoryStore: import('./store.js').MemoryStore, scope?: string, taskId?: string }} params
186
+ * @returns {Promise<{ entries: object[], keywords: string[], fingerprint: string, cached: boolean }>}
187
+ */
188
+ export async function recall({ prompt, adapter, config, memoryStore, scope, taskId }) {
189
+ // Step 1: Extract keywords
190
+ const keywords = extractKeywords(prompt);
191
+
192
+ if (keywords.length === 0) {
193
+ return { entries: [], keywords: [], fingerprint: '', cached: false };
194
+ }
195
+
196
+ // Check fingerprint cache
197
+ const fingerprint = computeFingerprint({ scope, keywords, taskId });
198
+
199
+ const cached = _cache.get(fingerprint);
200
+ if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
201
+ return { entries: cached.entries, keywords, fingerprint, cached: true };
202
+ }
203
+
204
+ // Step 2: Scope + Tags filter
205
+ const candidates = filterCandidates(memoryStore, { scope, keywords });
206
+
207
+ if (candidates.length === 0) {
208
+ _cache.set(fingerprint, { entries: [], timestamp: Date.now() });
209
+ return { entries: [], keywords, fingerprint, cached: false };
210
+ }
211
+
212
+ // Step 3: LLM select (only if > MAX_RECALL_RESULTS candidates)
213
+ let selectedNames;
214
+ if (candidates.length <= MAX_RECALL_RESULTS) {
215
+ selectedNames = candidates.map(c => c.name);
216
+ } else {
217
+ selectedNames = await llmSelect(adapter, config, prompt, candidates);
218
+ }
219
+
220
+ // Load full entries for selected names
221
+ const entries = [];
222
+ for (const name of selectedNames) {
223
+ const slug = name.toLowerCase().replace(/[^a-z0-9\u4e00-\u9fff-]+/g, '-').replace(/^-+|-+$/g, '');
224
+ const entry = memoryStore.readEntry(slug) || memoryStore.readEntry(name);
225
+ if (entry) {
226
+ entries.push(entry);
227
+ // Bump frequency
228
+ memoryStore.bumpFrequency(slug || name);
229
+ }
230
+ }
231
+
232
+ // Update cache
233
+ _cache.set(fingerprint, { entries, timestamp: Date.now() });
234
+
235
+ return { entries, keywords, fingerprint, cached: false };
236
+ }
237
+
238
+ /**
239
+ * Clear the recall cache. Useful for testing.
240
+ */
241
+ export function clearRecallCache() {
242
+ _cache.clear();
243
+ }