@yeaft/webchat-agent 0.1.547 → 0.1.549

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.547",
3
+ "version": "0.1.549",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -0,0 +1,381 @@
1
+ /**
2
+ * dream-extract.js — R6 §Δ26 Phase A: Extract memories from conversation.
3
+ *
4
+ * Reads un-ingested messages from the group coordinator JSONL log,
5
+ * calls the LLM to extract memory-worthy candidates, then deduplicates
6
+ * each candidate against the existing shard store before writing.
7
+ *
8
+ * Watermark: tracks the last-ingested message id in a JSON file
9
+ * at `<memoryDir>/extract-watermark.json`.
10
+ *
11
+ * Dedup classification per candidate:
12
+ * - is_duplicate → skip (already exists)
13
+ * - is_update → supersede old entry with new
14
+ * - is_new → write fresh entry
15
+ */
16
+
17
+ import { existsSync, readFileSync, mkdirSync } from 'fs';
18
+ import { join } from 'path';
19
+ import { randomUUID } from 'crypto';
20
+ import { writeAtomic } from '../storage/index.js';
21
+ import { pickEffort } from '../effort.js';
22
+ import { classifyUserMemoryShard } from './user-memory-store.js';
23
+
24
+ // ─── Watermark ────────────────────────────────────────────────
25
+
26
+ const WATERMARK_FILE = 'extract-watermark.json';
27
+
28
+ /**
29
+ * Read the extract watermark (last processed message id).
30
+ * @param {string} memoryDir
31
+ * @returns {{ lastMsgId: string|null, lastTs: string|null }}
32
+ */
33
+ export function readWatermark(memoryDir) {
34
+ const p = join(memoryDir, WATERMARK_FILE);
35
+ if (!existsSync(p)) return { lastMsgId: null, lastTs: null };
36
+ try {
37
+ return JSON.parse(readFileSync(p, 'utf8'));
38
+ } catch {
39
+ return { lastMsgId: null, lastTs: null };
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Write the extract watermark.
45
+ * @param {string} memoryDir
46
+ * @param {{ lastMsgId: string, lastTs: string }} wm
47
+ */
48
+ export function writeWatermark(memoryDir, wm) {
49
+ if (!existsSync(memoryDir)) mkdirSync(memoryDir, { recursive: true });
50
+ writeAtomic(join(memoryDir, WATERMARK_FILE), JSON.stringify(wm, null, 2));
51
+ }
52
+
53
+ // ─── Message Collection ───────────────────────────────────────
54
+
55
+ /**
56
+ * Collect messages from the group log that are newer than the watermark.
57
+ *
58
+ * @param {import('../groups/group-store.js').GroupHandle} group
59
+ * @param {{ lastMsgId: string|null }} watermark
60
+ * @param {{ maxMessages?: number }} [opts]
61
+ * @returns {{ messages: object[], lastMsg: object|null }}
62
+ */
63
+ export function collectNewMessages(group, watermark, opts = {}) {
64
+ const max = opts.maxMessages || 200;
65
+ const messages = [];
66
+ let pastWatermark = !watermark.lastMsgId; // if no watermark, take all
67
+ let lastMsg = null;
68
+
69
+ for (const msg of group.streamMessages()) {
70
+ if (!pastWatermark) {
71
+ if (msg.id === watermark.lastMsgId) {
72
+ pastWatermark = true;
73
+ }
74
+ continue;
75
+ }
76
+ // Only include user and assistant messages (skip system/meta)
77
+ if (msg.role === 'user' || msg.role === 'assistant') {
78
+ messages.push(msg);
79
+ lastMsg = msg;
80
+ }
81
+ if (messages.length >= max) break;
82
+ }
83
+
84
+ return { messages, lastMsg };
85
+ }
86
+
87
+ // ─── LLM Extraction ──────────────────────────────────────────
88
+
89
+ /**
90
+ * Build the extraction prompt for dream-extract.
91
+ * Reuses the pattern from extract.js but outputs R6-compatible entries.
92
+ */
93
+ function buildDreamExtractionPrompt(messages) {
94
+ const conversation = messages.map(m => {
95
+ const prefix = m.role === 'user' ? 'User' : 'Assistant';
96
+ const text = typeof m.text === 'string' ? m.text : String(m.text || '');
97
+ return `[${prefix}]: ${text}`;
98
+ }).join('\n\n');
99
+
100
+ return `Analyze the following conversation and extract any information worth saving to long-term memory.
101
+
102
+ For each memory, provide:
103
+ - **body**: 1-3 sentences describing the memory (concise, factual)
104
+ - **kind**: One of: fact, preference, skill, lesson, context, relation
105
+ - **tags**: Relevant keywords as a string array
106
+ - **importance**: "high", "normal", or "low"
107
+
108
+ Memory kinds:
109
+ - fact: Objective facts about the user or their work
110
+ - preference: User preferences (coding style, tools, habits)
111
+ - skill: Techniques or patterns the user uses or is learning
112
+ - lesson: Lessons learned, pitfalls, debugging insights
113
+ - context: Current project context, OKRs, deadlines
114
+ - relation: People, teams, roles the user mentions
115
+
116
+ Do NOT extract:
117
+ - Specific code snippets (too large, become stale)
118
+ - Temporary debugging info
119
+ - Trivial greetings or small talk
120
+ - Information already obviously known (like "user asked me a question")
121
+
122
+ Return a JSON array. If nothing is worth remembering, return [].
123
+
124
+ Conversation:
125
+ ${conversation}`;
126
+ }
127
+
128
+ /**
129
+ * Call LLM to extract memory candidates from messages.
130
+ *
131
+ * @param {{ messages: object[], adapter: object, config: object }} params
132
+ * @returns {Promise<object[]>} — extracted candidates
133
+ */
134
+ export async function extractCandidates({ messages, adapter, config }) {
135
+ if (!messages || messages.length === 0) return [];
136
+
137
+ const system = 'You are a memory extraction assistant. Analyze conversations and extract important facts, preferences, and lessons. Return ONLY a valid JSON array, no other text.';
138
+ const prompt = buildDreamExtractionPrompt(messages);
139
+
140
+ try {
141
+ const result = await adapter.call({
142
+ model: config.model || config.primaryModel || 'default',
143
+ system,
144
+ messages: [{ role: 'user', content: prompt }],
145
+ maxTokens: 2048,
146
+ effort: pickEffort({ scenario: 'consolidate' }),
147
+ });
148
+
149
+ const text = (result.text || '').trim();
150
+ const jsonMatch = text.match(/\[[\s\S]*\]/);
151
+ if (!jsonMatch) return [];
152
+
153
+ const entries = JSON.parse(jsonMatch[0]);
154
+ return entries
155
+ .filter(e => e && typeof e === 'object' && e.body)
156
+ .map(e => ({
157
+ body: String(e.body).slice(0, 500),
158
+ kind: ['fact', 'preference', 'skill', 'lesson', 'context', 'relation'].includes(e.kind) ? e.kind : 'fact',
159
+ tags: Array.isArray(e.tags) ? e.tags.map(String) : [],
160
+ importance: ['high', 'normal', 'low'].includes(e.importance) ? e.importance : 'normal',
161
+ }));
162
+ } catch {
163
+ return [];
164
+ }
165
+ }
166
+
167
+ // ─── Dedup / Similarity ──────────────────────────────────────
168
+
169
+ /**
170
+ * Classify a candidate against existing shard entries.
171
+ *
172
+ * Simple heuristic (no LLM): normalized text overlap.
173
+ * - Exact body match → is_duplicate
174
+ * - >60% word overlap with existing → is_update (supersede)
175
+ * - Otherwise → is_new
176
+ *
177
+ * @param {object} candidate — { body, kind, tags }
178
+ * @param {object[]} existingEntries — thin entries with body loaded
179
+ * @returns {{ action: 'is_duplicate'|'is_update'|'is_new', matchId?: string }}
180
+ */
181
+ export function classifyCandidate(candidate, existingEntries) {
182
+ if (!candidate || !candidate.body) return { action: 'is_new' };
183
+ if (!existingEntries || existingEntries.length === 0) return { action: 'is_new' };
184
+
185
+ const candWords = normalizeWords(candidate.body);
186
+ if (candWords.size === 0) return { action: 'is_new' };
187
+
188
+ let bestOverlap = 0;
189
+ let bestId = null;
190
+
191
+ for (const entry of existingEntries) {
192
+ const entryBody = entry.body || '';
193
+ if (!entryBody) continue;
194
+
195
+ // Exact match
196
+ if (entryBody.trim().toLowerCase() === candidate.body.trim().toLowerCase()) {
197
+ return { action: 'is_duplicate', matchId: entry.id };
198
+ }
199
+
200
+ // Word overlap
201
+ const entryWords = normalizeWords(entryBody);
202
+ if (entryWords.size === 0) continue;
203
+ let overlap = 0;
204
+ for (const w of candWords) {
205
+ if (entryWords.has(w)) overlap++;
206
+ }
207
+ const ratio = overlap / Math.max(candWords.size, entryWords.size);
208
+ if (ratio > bestOverlap) {
209
+ bestOverlap = ratio;
210
+ bestId = entry.id;
211
+ }
212
+ }
213
+
214
+ if (bestOverlap > 0.6 && bestId) {
215
+ return { action: 'is_update', matchId: bestId };
216
+ }
217
+ return { action: 'is_new' };
218
+ }
219
+
220
+ function normalizeWords(text) {
221
+ return new Set(
222
+ text.toLowerCase().replace(/[^\w\s]/g, '').split(/\s+/).filter(w => w.length > 2)
223
+ );
224
+ }
225
+
226
+ // ─── Main Entry Point ────────────────────────────────────────
227
+
228
+ /**
229
+ * Run dream extract: read new messages, LLM extract, dedup, write.
230
+ *
231
+ * @param {{
232
+ * group: import('../groups/group-store.js').GroupHandle,
233
+ * shardStore: object,
234
+ * adapter: object,
235
+ * config: object,
236
+ * memoryDir: string,
237
+ * onPhase?: (phase: string, data: any) => void,
238
+ * maxMessages?: number,
239
+ * }} opts
240
+ * @returns {Promise<DreamExtractResult>}
241
+ */
242
+ export async function dreamExtract(opts) {
243
+ const { group, shardStore, adapter, config, memoryDir, onPhase, maxMessages } = opts;
244
+
245
+ const result = {
246
+ messagesRead: 0,
247
+ candidatesExtracted: 0,
248
+ written: 0,
249
+ updated: 0,
250
+ duplicatesSkipped: 0,
251
+ errors: [],
252
+ };
253
+
254
+ if (!group || !shardStore || !adapter) {
255
+ return result;
256
+ }
257
+
258
+ try {
259
+ // 1. Read watermark + collect new messages
260
+ onPhase?.('collect', 'starting');
261
+ const wm = readWatermark(memoryDir);
262
+ const { messages, lastMsg } = collectNewMessages(group, wm, { maxMessages });
263
+ result.messagesRead = messages.length;
264
+ onPhase?.('collect', { count: messages.length });
265
+
266
+ if (messages.length === 0) return result;
267
+
268
+ // 2. LLM extraction
269
+ onPhase?.('extract', 'starting');
270
+ const candidates = await extractCandidates({ messages, adapter, config });
271
+ result.candidatesExtracted = candidates.length;
272
+ onPhase?.('extract', { count: candidates.length });
273
+
274
+ if (candidates.length === 0) {
275
+ // Still advance watermark — we read the messages, just nothing to extract
276
+ if (lastMsg) {
277
+ writeWatermark(memoryDir, { lastMsgId: lastMsg.id, lastTs: lastMsg.ts || new Date().toISOString() });
278
+ }
279
+ return result;
280
+ }
281
+
282
+ // 3. Load existing entries for dedup comparison
283
+ const existingByBody = loadExistingBodies(shardStore);
284
+
285
+ // 4. Dedup + write each candidate
286
+ onPhase?.('dedup', 'starting');
287
+ for (const candidate of candidates) {
288
+ try {
289
+ const classification = classifyCandidate(candidate, existingByBody);
290
+
291
+ switch (classification.action) {
292
+ case 'is_duplicate':
293
+ result.duplicatesSkipped++;
294
+ break;
295
+
296
+ case 'is_update': {
297
+ // Supersede old entry
298
+ const shard = classifyUserMemoryShard(candidate.body, candidate.tags);
299
+ const newId = `de-${randomUUID().slice(0, 12)}`;
300
+ shardStore.supersede({
301
+ newEntry: {
302
+ id: newId,
303
+ shard,
304
+ kind: candidate.kind || 'fact',
305
+ body: candidate.body,
306
+ tags: candidate.tags || [],
307
+ sourceRef: { hint: 'dream-extract' },
308
+ authoredBy: 'dream:extract',
309
+ },
310
+ oldIds: [classification.matchId],
311
+ });
312
+ result.updated++;
313
+ break;
314
+ }
315
+
316
+ case 'is_new':
317
+ default: {
318
+ const shard = classifyUserMemoryShard(candidate.body, candidate.tags);
319
+ const newId = `de-${randomUUID().slice(0, 12)}`;
320
+ shardStore.put({
321
+ id: newId,
322
+ shard,
323
+ kind: candidate.kind || 'fact',
324
+ body: candidate.body,
325
+ tags: candidate.tags || [],
326
+ sourceRef: { hint: 'dream-extract' },
327
+ authoredBy: 'dream:extract',
328
+ });
329
+ result.written++;
330
+ break;
331
+ }
332
+ }
333
+ } catch (err) {
334
+ result.errors.push(`write candidate: ${err.message}`);
335
+ }
336
+ }
337
+ onPhase?.('dedup', { written: result.written, updated: result.updated, skipped: result.duplicatesSkipped });
338
+
339
+ // 5. Advance watermark
340
+ if (lastMsg) {
341
+ writeWatermark(memoryDir, { lastMsgId: lastMsg.id, lastTs: lastMsg.ts || new Date().toISOString() });
342
+ }
343
+
344
+ } catch (err) {
345
+ result.errors.push(err.message);
346
+ }
347
+
348
+ return result;
349
+ }
350
+
351
+ /**
352
+ * Load all existing entry bodies (for dedup comparison).
353
+ * Returns array of { id, body } objects.
354
+ */
355
+ function loadExistingBodies(shardStore) {
356
+ const entries = [];
357
+ try {
358
+ const st = shardStore.stats();
359
+ for (const shardName of Object.keys(st.shards)) {
360
+ const { results } = shardStore.query({ shard: shardName });
361
+ for (const rec of results) {
362
+ if (rec.supersededBy) continue;
363
+ const full = shardStore.get(rec.id);
364
+ if (full && full.body) {
365
+ entries.push({ id: rec.id, body: full.body });
366
+ }
367
+ }
368
+ }
369
+ } catch { /* best effort */ }
370
+ return entries;
371
+ }
372
+
373
+ /**
374
+ * @typedef {Object} DreamExtractResult
375
+ * @property {number} messagesRead
376
+ * @property {number} candidatesExtracted
377
+ * @property {number} written
378
+ * @property {number} updated
379
+ * @property {number} duplicatesSkipped
380
+ * @property {string[]} errors
381
+ */
@@ -23,6 +23,7 @@
23
23
 
24
24
  import { dreamShard } from './dream-shard.js';
25
25
  import { checkRecompression } from './recompression.js';
26
+ import { dreamExtract } from './dream-extract.js';
26
27
 
27
28
  /** Default idle timeout before dream triggers (ms). */
28
29
  export const DREAM_IDLE_MS = 30 * 60 * 1000; // 30 min
@@ -37,6 +38,8 @@ const MAX_CONCURRENT_DREAMS = 2;
37
38
  * memoryShardStore: object | null,
38
39
  * adapter: object | null,
39
40
  * config: object,
41
+ * group?: import('../groups/group-store.js').GroupHandle | null,
42
+ * memoryDir?: string | null,
40
43
  * idleMs?: number,
41
44
  * onDreamStart?: (vpId: string) => void,
42
45
  * onDreamEnd?: (vpId: string, result: object) => void,
@@ -49,6 +52,8 @@ export function createDreamScheduler(opts = {}) {
49
52
  memoryShardStore,
50
53
  adapter,
51
54
  config,
55
+ group = null,
56
+ memoryDir = null,
52
57
  idleMs = DREAM_IDLE_MS,
53
58
  onDreamStart,
54
59
  onDreamEnd,
@@ -118,6 +123,24 @@ export function createDreamScheduler(opts = {}) {
118
123
  try {
119
124
  onDreamStart?.(vpId);
120
125
 
126
+ // Phase A: Extract new memories from conversation (§Δ26)
127
+ let extractResult = null;
128
+ if (group && memoryDir) {
129
+ try {
130
+ extractResult = await dreamExtract({
131
+ group,
132
+ shardStore: memoryShardStore,
133
+ adapter,
134
+ config: { model: config?.primaryModel || config?.model || 'default' },
135
+ memoryDir,
136
+ });
137
+ } catch (err) {
138
+ // Non-fatal — proceed to compact even if extract fails
139
+ extractResult = { error: err.message };
140
+ }
141
+ }
142
+
143
+ // Phase B: Shard-based compact/merge/prune
121
144
  const result = await dreamShard({
122
145
  shardStore: memoryShardStore,
123
146
  adapter,
@@ -135,6 +158,9 @@ export function createDreamScheduler(opts = {}) {
135
158
  // Non-fatal
136
159
  }
137
160
 
161
+ // Attach extract result
162
+ result.extract = extractResult;
163
+
138
164
  messagesSinceLastDream = 0;
139
165
  lastDreamAt = Date.now();
140
166
  onDreamEnd?.(vpId, { ...result, trigger });