@yeaft/webchat-agent 0.1.629 → 0.1.631

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,258 @@
1
+ /**
2
+ * memory/recall-v2.js — DESIGN-v2 Part II: scope-based memory recall.
3
+ *
4
+ * Recall under v2 is structurally different from R6: instead of selecting
5
+ * individual entry shards by tag/keyword, we assemble per-scope `memory.md`
6
+ * + `summary.md` for the scopes that are *known* to be relevant from the
7
+ * current turn's context (always-include rules) plus the topic scopes whose
8
+ * summary best matches the user's prompt keywords.
9
+ *
10
+ * Always-include scopes (no LLM):
11
+ * - user (every turn)
12
+ * - group/<groupId> (when groupId is provided)
13
+ * - vp/<vpId> (when vpId is provided AND not a foreign vp)
14
+ * - feature/<featureId> (when featureId is provided)
15
+ *
16
+ * Topic scopes:
17
+ * - Score each topic by simple keyword overlap between the prompt's
18
+ * extracted keywords (via recall.js → extractKeywords) and the topic's
19
+ * `summary.md` body. Top-N by score join the bundle.
20
+ * - This is a heuristic — no LLM call. Topics that the dream pipeline
21
+ * created already correlate with the conversation's natural language,
22
+ * so a cheap keyword overlap is a good first cut.
23
+ *
24
+ * What this module deliberately does NOT do:
25
+ * - No LLM side-query. R6's recall.js does a 3rd-step LLM-select; v2
26
+ * skips it because the unit of selection is now whole scopes (5 + N
27
+ * topics) instead of dozens of individual entries.
28
+ * - No frontmatter parsing. memory.md is markdown; the dream-state tail
29
+ * marker is stripped before injection (so the LLM doesn't see internal
30
+ * bookkeeping bytes).
31
+ * - No write side effects. Pure read.
32
+ *
33
+ * Reference: agent/unify/memory/DESIGN-v2.md §6 (recall surface).
34
+ */
35
+
36
+ import { join } from 'path';
37
+ import { promises as fsp, existsSync } from 'fs';
38
+
39
+ import {
40
+ DEFAULT_MEMORY_ROOT, scopeDir, readMemory, readSummary,
41
+ } from './store-v2.js';
42
+ import { extractKeywords } from './recall.js';
43
+
44
+ /** Default cap for how many topic scopes recall pulls in. */
45
+ export const DEFAULT_TOPIC_LIMIT = 3;
46
+
47
+ /** Marker block written by dream-v2/state.js — stripped from injection. */
48
+ const DREAM_MARKER_RE = /\n*<!-- dream-state -->[\s\S]*?<!-- \/dream-state -->\s*$/;
49
+
50
+ /**
51
+ * Strip the trailing dream-state marker block (if any) from a memory.md body.
52
+ *
53
+ * @param {string} body
54
+ * @returns {string}
55
+ */
56
+ export function stripDreamMarker(body) {
57
+ if (!body || typeof body !== 'string') return '';
58
+ return body.replace(DREAM_MARKER_RE, '').trimEnd();
59
+ }
60
+
61
+ /**
62
+ * List all topic scopes present under <root>/topic/. Returns paths like
63
+ * ['science', 'physics'] (level 1) or ['life', 'parenting'] (level 2).
64
+ *
65
+ * @param {string} root
66
+ * @returns {Promise<string[][]>}
67
+ */
68
+ async function listTopicPaths(root) {
69
+ const out = [];
70
+ const topicRoot = join(root, 'topic');
71
+ if (!existsSync(topicRoot)) return out;
72
+ let l1Names;
73
+ try { l1Names = await fsp.readdir(topicRoot, { withFileTypes: true }); }
74
+ catch { return out; }
75
+ for (const e1 of l1Names) {
76
+ if (!e1.isDirectory()) continue;
77
+ if (e1.name.startsWith('.')) continue;
78
+ // Level-1 topic is itself a scope (memory.md may sit at this level).
79
+ out.push([e1.name]);
80
+ // Walk one more level.
81
+ let l2Names;
82
+ try { l2Names = await fsp.readdir(join(topicRoot, e1.name), { withFileTypes: true }); }
83
+ catch { continue; }
84
+ for (const e2 of l2Names) {
85
+ if (!e2.isDirectory()) continue;
86
+ if (e2.name.startsWith('.')) continue;
87
+ out.push([e1.name, e2.name]);
88
+ }
89
+ }
90
+ return out;
91
+ }
92
+
93
+ /**
94
+ * Score a topic by how many of its summary's tokens overlap the prompt's
95
+ * keyword set. Topics with no summary score 0.
96
+ *
97
+ * @param {string} summary
98
+ * @param {Set<string>} keywordSet
99
+ * @returns {number}
100
+ */
101
+ function scoreTopic(summary, keywordSet) {
102
+ if (!summary || keywordSet.size === 0) return 0;
103
+ const tokens = (summary.toLowerCase()
104
+ .match(/[\p{L}\p{N}_-]+/gu) || [])
105
+ .filter(t => t.length > 1);
106
+ if (tokens.length === 0) return 0;
107
+ let hits = 0;
108
+ for (const t of tokens) {
109
+ if (keywordSet.has(t)) hits += 1;
110
+ }
111
+ return hits;
112
+ }
113
+
114
+ /**
115
+ * @typedef {Object} RecallV2Section
116
+ * @property {string} scope — human label, e.g. "user", "group/g-eng"
117
+ * @property {string} kind — 'user' | 'vp' | 'group' | 'feature' | 'topic'
118
+ * @property {string} memory — memory.md body (dream marker stripped)
119
+ * @property {string} summary — summary.md body
120
+ */
121
+
122
+ /**
123
+ * @typedef {Object} RecallV2Result
124
+ * @property {RecallV2Section[]} sections
125
+ * @property {string[]} keywords
126
+ * @property {string} formatted — ready to splice into the system prompt
127
+ */
128
+
129
+ /**
130
+ * Build a scope label suitable for the formatted block heading.
131
+ *
132
+ * @param {import('./store-v2.js').Scope} scope
133
+ * @returns {string}
134
+ */
135
+ export function scopeLabel(scope) {
136
+ if (scope.kind === 'user') return 'user';
137
+ if (scope.kind === 'topic') return `topic/${(scope.path || []).join('/')}`;
138
+ return `${scope.kind}/${scope.id || ''}`;
139
+ }
140
+
141
+ /**
142
+ * Format the bundle for direct injection into the system prompt.
143
+ *
144
+ * @param {RecallV2Section[]} sections
145
+ * @returns {string}
146
+ */
147
+ export function formatRecallV2(sections) {
148
+ if (!sections || sections.length === 0) return '';
149
+ const blocks = [];
150
+ for (const s of sections) {
151
+ const memBlock = s.memory ? s.memory.trim() : '';
152
+ const sumBlock = s.summary ? s.summary.trim() : '';
153
+ if (!memBlock && !sumBlock) continue;
154
+ const parts = [`### ${s.scope}`];
155
+ if (sumBlock) parts.push(`**Summary**\n${sumBlock}`);
156
+ if (memBlock) parts.push(`**Memory**\n${memBlock}`);
157
+ blocks.push(parts.join('\n\n'));
158
+ }
159
+ if (blocks.length === 0) return '';
160
+ return ['## Recalled Memory (v2)', ...blocks].join('\n\n');
161
+ }
162
+
163
+ /**
164
+ * Read one scope's pair (memory.md + summary.md) and translate to a section.
165
+ * Returns null when both files are empty/missing or VP ACL refuses.
166
+ *
167
+ * @param {import('./store-v2.js').Scope} scope
168
+ * @param {{ root: string, currentVpId?: string }} opts
169
+ * @returns {Promise<RecallV2Section|null>}
170
+ */
171
+ async function readScopeSection(scope, opts) {
172
+ let memory = '';
173
+ let summary = '';
174
+ try { memory = stripDreamMarker(await readMemory(scope, opts)); }
175
+ catch { return null; } // VP ACL or other → skip silently
176
+ try { summary = await readSummary(scope, opts); } catch { /* */ }
177
+ if (!memory && !summary) return null;
178
+ return {
179
+ scope: scopeLabel(scope),
180
+ kind: scope.kind,
181
+ memory,
182
+ summary,
183
+ };
184
+ }
185
+
186
+ /**
187
+ * Recall v2: assemble per-scope memory.md + summary.md for the current turn.
188
+ *
189
+ * @param {Object} params
190
+ * @param {string} params.prompt — the user's turn prompt
191
+ * @param {string} [params.root] — memory root (defaults to DEFAULT_MEMORY_ROOT)
192
+ * @param {string} [params.groupId] — active group, if any
193
+ * @param {string} [params.vpId] — active VP for this turn (NOT used as ACL)
194
+ * @param {string} [params.currentVpId] — current session's VP, gates vp/<other> reads
195
+ * @param {string} [params.featureId] — active feature, if any
196
+ * @param {number} [params.topicLimit] — cap on topic scopes (default DEFAULT_TOPIC_LIMIT)
197
+ * @returns {Promise<RecallV2Result>}
198
+ */
199
+ export async function recallV2({
200
+ prompt,
201
+ root = DEFAULT_MEMORY_ROOT,
202
+ groupId,
203
+ vpId,
204
+ currentVpId,
205
+ featureId,
206
+ topicLimit = DEFAULT_TOPIC_LIMIT,
207
+ } = {}) {
208
+ const sections = [];
209
+ const opts = { root, currentVpId };
210
+ const keywords = extractKeywords(prompt || '');
211
+
212
+ // Always: user.
213
+ const userSec = await readScopeSection({ kind: 'user' }, opts);
214
+ if (userSec) sections.push(userSec);
215
+
216
+ // Conditional: group/<groupId>
217
+ if (groupId && typeof groupId === 'string' && groupId !== '_no-group') {
218
+ const sec = await readScopeSection({ kind: 'group', id: groupId }, opts);
219
+ if (sec) sections.push(sec);
220
+ }
221
+
222
+ // Conditional: vp/<vpId>
223
+ if (vpId && typeof vpId === 'string') {
224
+ const sec = await readScopeSection({ kind: 'vp', id: vpId }, opts);
225
+ if (sec) sections.push(sec);
226
+ }
227
+
228
+ // Conditional: feature/<featureId>
229
+ if (featureId && typeof featureId === 'string') {
230
+ const sec = await readScopeSection({ kind: 'feature', id: featureId }, opts);
231
+ if (sec) sections.push(sec);
232
+ }
233
+
234
+ // Topics: rank by keyword overlap on summary.
235
+ if (topicLimit > 0 && keywords.length > 0) {
236
+ const keywordSet = new Set(keywords.map(k => k.toLowerCase()));
237
+ const candidates = [];
238
+ const paths = await listTopicPaths(root);
239
+ for (const path of paths) {
240
+ const scope = { kind: 'topic', path };
241
+ let summary = '';
242
+ try { summary = await readSummary(scope, opts); } catch { /* */ }
243
+ const score = scoreTopic(summary, keywordSet);
244
+ if (score > 0) candidates.push({ scope, score });
245
+ }
246
+ candidates.sort((a, b) => b.score - a.score);
247
+ for (const c of candidates.slice(0, topicLimit)) {
248
+ const sec = await readScopeSection(c.scope, opts);
249
+ if (sec) sections.push(sec);
250
+ }
251
+ }
252
+
253
+ return {
254
+ sections,
255
+ keywords,
256
+ formatted: formatRecallV2(sections),
257
+ };
258
+ }