@yeaft/webchat-agent 0.1.549 → 0.1.551

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.549",
3
+ "version": "0.1.551",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
package/unify/engine.js CHANGED
@@ -23,6 +23,7 @@ import { LLMContextError, LLMAbortError } from './llm/adapter.js';
23
23
  import { recallR6, formatForInjection } from './memory/recall-r6.js';
24
24
  import { shouldConsolidate, consolidate } from './memory/consolidate.js';
25
25
  import { buildMemoryInjection } from './memory/layout.js';
26
+ import { buildUserProfile } from './memory/user-memory-store.js';
26
27
  import { runStopHooks } from './stop-hooks.js';
27
28
  import { getThreadStore, MAIN_THREAD_ID } from './threads/store.js';
28
29
  import { pickEffort, parseEffortPrefix } from './effort.js';
@@ -293,9 +294,10 @@ export class Engine {
293
294
  * @param {string} [compactSummary]
294
295
  * @param {string} [prompt] — user prompt (for skill relevance matching)
295
296
  * @param {string} [memoryInjection] — task-287: prebuilt memory block (index + prefs + project)
297
+ * @param {string} [userProfile] — user profile from user-memory shard store
296
298
  * @returns {string}
297
299
  */
298
- #buildSystemPrompt(memory, compactSummary, prompt, memoryInjection) {
300
+ #buildSystemPrompt(memory, compactSummary, prompt, memoryInjection, userProfile) {
299
301
  // Get relevant skill content if SkillManager is wired
300
302
  let skillContent = '';
301
303
  if (this.#skillManager && prompt) {
@@ -314,6 +316,7 @@ export class Engine {
314
316
  memoryInjection,
315
317
  compactSummary,
316
318
  skillContent,
319
+ userProfile,
317
320
  // task-334f: memory_trace tool is now registered (49 → 51 tools), so
318
321
  // unlock the core_memory meta-line behind 334e's feature flag.
319
322
  memoryTraceAvailable: true,
@@ -359,9 +362,20 @@ export class Engine {
359
362
  async #recallMemory(prompt) {
360
363
  const memory = { profile: '', entries: [], formatted: '' };
361
364
 
362
- // Read user profile from legacy store if available
363
- if (this.#memoryStore) {
364
- memory.profile = this.#memoryStore.readProfile();
365
+ // Build user profile from user-memory shard store (R6 path),
366
+ // falling back to legacy readProfile if shard store unavailable.
367
+ try {
368
+ const profile = buildUserProfile(this.#memoryShardStore);
369
+ if (profile) {
370
+ memory.profile = profile;
371
+ } else if (this.#memoryStore) {
372
+ memory.profile = this.#memoryStore.readProfile();
373
+ }
374
+ } catch {
375
+ // Non-critical — fall through to legacy
376
+ if (this.#memoryStore) {
377
+ try { memory.profile = this.#memoryStore.readProfile(); } catch { /* */ }
378
+ }
365
379
  }
366
380
 
367
381
  // R6 shard-based recall (preferred path)
@@ -603,7 +617,8 @@ export class Engine {
603
617
  }
604
618
 
605
619
  const compactSummary = this.#getCompactSummary();
606
- const systemPrompt = this.#buildSystemPrompt(undefined, compactSummary, prompt, memoryInjection);
620
+ const userProfile = recallResult?.profile || '';
621
+ const systemPrompt = this.#buildSystemPrompt(undefined, compactSummary, prompt, memoryInjection, userProfile);
607
622
 
608
623
  // Build conversation: existing messages + new user message
609
624
  const conversationMessages = [
@@ -23,7 +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
+ import { runUserDreamJob } from './user-memory-store.js';
27
27
 
28
28
  /** Default idle timeout before dream triggers (ms). */
29
29
  export const DREAM_IDLE_MS = 30 * 60 * 1000; // 30 min
@@ -36,6 +36,8 @@ const MAX_CONCURRENT_DREAMS = 2;
36
36
  *
37
37
  * @param {{
38
38
  * memoryShardStore: object | null,
39
+ * userMemoryStore: object | null,
40
+ * conversationStore: object | null,
39
41
  * adapter: object | null,
40
42
  * config: object,
41
43
  * group?: import('../groups/group-store.js').GroupHandle | null,
@@ -50,6 +52,8 @@ const MAX_CONCURRENT_DREAMS = 2;
50
52
  export function createDreamScheduler(opts = {}) {
51
53
  const {
52
54
  memoryShardStore,
55
+ userMemoryStore,
56
+ conversationStore,
53
57
  adapter,
54
58
  config,
55
59
  group = null,
@@ -123,24 +127,7 @@ export function createDreamScheduler(opts = {}) {
123
127
  try {
124
128
  onDreamStart?.(vpId);
125
129
 
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
130
+ // Shard-based compact/merge/prune
144
131
  const result = await dreamShard({
145
132
  shardStore: memoryShardStore,
146
133
  adapter,
@@ -158,8 +145,18 @@ export function createDreamScheduler(opts = {}) {
158
145
  // Non-fatal
159
146
  }
160
147
 
161
- // Attach extract result
162
- result.extract = extractResult;
148
+ // Post-dream: run user-memory extract + compact (334-w7b)
149
+ try {
150
+ const userDreamResult = await runUserDreamJob({
151
+ store: userMemoryStore || undefined,
152
+ conversationStore: conversationStore || undefined,
153
+ adapter: adapter || undefined,
154
+ config: { model: config?.primaryModel || config?.model || 'default' },
155
+ });
156
+ result.userDream = userDreamResult;
157
+ } catch {
158
+ // Non-fatal
159
+ }
163
160
 
164
161
  messagesSinceLastDream = 0;
165
162
  lastDreamAt = Date.now();
@@ -15,10 +15,11 @@
15
15
  import { homedir } from 'os';
16
16
  import { join } from 'path';
17
17
  import { randomUUID } from 'crypto';
18
- import { existsSync } from 'fs';
18
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
19
19
  import { openMemoryShardStore } from './shard-store.js';
20
20
  import { USER_SHARDS } from './schema.js';
21
21
  import { scanShards, runCompactJob } from './dream-shard.js';
22
+ import { pickEffort } from '../effort.js';
22
23
 
23
24
  /** Default storage root for user memory. */
24
25
  export const USER_MEMORY_DIR = join(homedir(), '.yeaft', 'user', 'memory');
@@ -193,19 +194,45 @@ export function buildUserProfile(store, opts = {}) {
193
194
  // ─── Dream Job ───────────────────────────────────────────────
194
195
 
195
196
  /**
196
- * Run user-memory dream maintenance (compact low-utilization shards).
197
- * Reuses dream-shard.js compact framework no LLM calls needed for
198
- * user-memory (user-authored entries don't need merge/prune by an LLM;
199
- * we only compact to reclaim superseded/removed tombstones).
197
+ * Run user-memory dream maintenance: extract phase + compact.
198
+ * Extract reads conversation messages since the last watermark, uses LLM to
199
+ * identify user-relevant facts, then writes them to the appropriate shards.
200
+ * Compact phase reclaims superseded/removed tombstones (unchanged from 334g).
200
201
  *
201
- * @param {{ store?: object, onPhase?: (phase:string, data:any) => void }} [opts]
202
- * @returns {{ scan: object, compact: object } | null}
202
+ * @param {{
203
+ * store?: object,
204
+ * conversationStore?: object,
205
+ * adapter?: object,
206
+ * config?: object,
207
+ * onPhase?: (phase: string, data: any) => void,
208
+ * }} [opts]
209
+ * @returns {Promise<{ extract: object|null, scan: object, compact: object } | null>}
203
210
  */
204
- export function runUserDreamJob(opts = {}) {
211
+ export async function runUserDreamJob(opts = {}) {
205
212
  const store = 'store' in opts ? opts.store : getUserMemoryStore();
206
213
  if (!store) return null;
207
214
 
215
+ let extractResult = null;
216
+
208
217
  try {
218
+ // ── Phase 1: Extract (LLM) ─────────────────────────────
219
+ if (opts.conversationStore && opts.adapter && opts.config) {
220
+ opts.onPhase?.('extract', 'starting');
221
+ try {
222
+ extractResult = await dreamExtract({
223
+ store,
224
+ conversationStore: opts.conversationStore,
225
+ adapter: opts.adapter,
226
+ config: opts.config,
227
+ });
228
+ opts.onPhase?.('extract', extractResult);
229
+ } catch (err) {
230
+ console.warn('[user-memory-store] extract phase failed:', err.message);
231
+ extractResult = { error: err.message, extracted: 0 };
232
+ }
233
+ }
234
+
235
+ // ── Phase 2: Compact ───────────────────────────────────
209
236
  const scan = scanShards(store);
210
237
  const compact = runCompactJob({
211
238
  shardStore: store,
@@ -214,9 +241,212 @@ export function runUserDreamJob(opts = {}) {
214
241
  ? (shard, r) => opts.onPhase('compact', { shard, ...r })
215
242
  : undefined,
216
243
  });
217
- return { scan: { totalEntries: scan.totalEntries, totalBytes: scan.totalBytes }, compact };
244
+ return {
245
+ extract: extractResult,
246
+ scan: { totalEntries: scan.totalEntries, totalBytes: scan.totalBytes },
247
+ compact,
248
+ };
218
249
  } catch (err) {
219
250
  console.warn('[user-memory-store] dream job failed:', err.message);
220
251
  return null;
221
252
  }
222
253
  }
254
+
255
+ // ─── Watermark ──────────────────────────────────────────────
256
+
257
+ /**
258
+ * Watermark format (shared with 334-w7b):
259
+ * { lastMessageId: string, lastMessageTs: number, updatedAt: string }
260
+ *
261
+ * Stored at <storeDir>/.watermark.json (alongside shard files).
262
+ */
263
+
264
+ const WATERMARK_FILE = '.watermark.json';
265
+
266
+ /**
267
+ * Read the extract watermark for a user-memory store.
268
+ * Returns null if no watermark exists yet.
269
+ *
270
+ * @param {string} dir — store directory (e.g. ~/.yeaft/user/memory)
271
+ * @returns {{ lastMessageId: string, lastMessageTs: number, updatedAt: string } | null}
272
+ */
273
+ export function readWatermark(dir) {
274
+ try {
275
+ const p = join(dir, WATERMARK_FILE);
276
+ if (!existsSync(p)) return null;
277
+ return JSON.parse(readFileSync(p, 'utf8'));
278
+ } catch {
279
+ return null;
280
+ }
281
+ }
282
+
283
+ /**
284
+ * Write the extract watermark.
285
+ *
286
+ * @param {string} dir
287
+ * @param {{ lastMessageId: string, lastMessageTs: number }} wm
288
+ */
289
+ export function writeWatermark(dir, wm) {
290
+ try {
291
+ const p = join(dir, WATERMARK_FILE);
292
+ mkdirSync(dir, { recursive: true });
293
+ writeFileSync(p, JSON.stringify({
294
+ lastMessageId: wm.lastMessageId,
295
+ lastMessageTs: wm.lastMessageTs,
296
+ updatedAt: new Date().toISOString(),
297
+ }, null, 2));
298
+ } catch (err) {
299
+ console.warn('[user-memory-store] writeWatermark failed:', err.message);
300
+ }
301
+ }
302
+
303
+ // ─── Extract Phase ──────────────────────────────────────────
304
+
305
+ /** Max messages to process in a single extract pass. */
306
+ const EXTRACT_MAX_MESSAGES = 50;
307
+
308
+ /** Min messages required to trigger an extract. */
309
+ const EXTRACT_MIN_MESSAGES = 3;
310
+
311
+ /**
312
+ * Build the user-memory extraction prompt.
313
+ * Tailored for user-relevant facts (not VP/task memory).
314
+ *
315
+ * @param {object[]} messages
316
+ * @returns {string}
317
+ */
318
+ export function buildUserExtractPrompt(messages) {
319
+ const conversation = messages.map(m => {
320
+ const prefix = m.role === 'user' ? 'User' : m.role === 'assistant' ? 'Assistant' : 'System';
321
+ return `[${prefix}]: ${typeof m.content === 'string' ? m.content : JSON.stringify(m.content)}`;
322
+ }).join('\n\n');
323
+
324
+ return `Analyze the following conversation and extract facts about THE USER that are worth remembering long-term.
325
+
326
+ Focus on these categories:
327
+ - **profile**: Name, job title, company, location, background, expertise areas
328
+ - **preferences**: Coding style, tool preferences, language preferences, communication style
329
+ - **projects**: Projects they work on, tech stacks, repositories, products
330
+ - **goals**: Current goals, objectives, what they're trying to achieve
331
+ - **relations**: Team members, colleagues, managers, collaborators mentioned
332
+
333
+ For each fact, provide:
334
+ - **shard**: One of: profile, preferences, projects, goals, relations
335
+ - **body**: 1-2 sentences describing the fact clearly
336
+ - **tags**: 1-3 keyword tags as an array
337
+
338
+ Do NOT extract:
339
+ - Specific code snippets or technical instructions
340
+ - Temporary debugging context
341
+ - Facts about the assistant (only about the user)
342
+ - Information already implied by the conversation being about coding
343
+
344
+ Return a JSON array. If nothing about the user is worth remembering, return [].
345
+
346
+ Conversation:
347
+ ${conversation}`;
348
+ }
349
+
350
+ /**
351
+ * Extract user-relevant facts from conversation messages and write to user-memory shards.
352
+ *
353
+ * @param {{
354
+ * store: object,
355
+ * conversationStore: object,
356
+ * adapter: object,
357
+ * config: object,
358
+ * dir?: string,
359
+ * }} params
360
+ * @returns {Promise<{ extracted: number, skipped: number, watermark: object|null }>}
361
+ */
362
+ export async function dreamExtract({ store, conversationStore, adapter, config, dir }) {
363
+ const storeDir = dir || USER_MEMORY_DIR;
364
+ const wm = readWatermark(storeDir);
365
+
366
+ // Load all messages and filter to those after watermark
367
+ const allMessages = conversationStore.loadAll();
368
+ let newMessages;
369
+
370
+ if (wm && wm.lastMessageId) {
371
+ const idx = allMessages.findIndex(m => m.id === wm.lastMessageId);
372
+ newMessages = idx >= 0 ? allMessages.slice(idx + 1) : allMessages;
373
+ } else {
374
+ // No watermark — process all messages (first run)
375
+ newMessages = allMessages;
376
+ }
377
+
378
+ // Filter to user + assistant messages only (skip system)
379
+ newMessages = newMessages.filter(m => m.role === 'user' || m.role === 'assistant');
380
+
381
+ if (newMessages.length < EXTRACT_MIN_MESSAGES) {
382
+ return { extracted: 0, skipped: 0, watermark: wm };
383
+ }
384
+
385
+ // Cap to prevent huge LLM calls
386
+ const batch = newMessages.slice(-EXTRACT_MAX_MESSAGES);
387
+
388
+ // LLM extraction call
389
+ const system = 'You are a user profile extraction assistant. Analyze conversations and extract facts about the user. Return ONLY a valid JSON array, no other text.';
390
+ const prompt = buildUserExtractPrompt(batch);
391
+
392
+ let candidates = [];
393
+ try {
394
+ const result = await adapter.call({
395
+ model: config.model || config.primaryModel || 'default',
396
+ system,
397
+ messages: [{ role: 'user', content: prompt }],
398
+ maxTokens: 2048,
399
+ effort: pickEffort({ scenario: 'dream' }),
400
+ });
401
+
402
+ const text = result.text.trim();
403
+ const jsonMatch = text.match(/\[[\s\S]*\]/);
404
+ if (jsonMatch) {
405
+ candidates = JSON.parse(jsonMatch[0]);
406
+ }
407
+ } catch {
408
+ return { extracted: 0, skipped: 0, watermark: wm, error: 'llm_failed' };
409
+ }
410
+
411
+ if (!Array.isArray(candidates)) {
412
+ return { extracted: 0, skipped: 0, watermark: wm };
413
+ }
414
+
415
+ // Validate and write candidates
416
+ let extracted = 0;
417
+ let skipped = 0;
418
+
419
+ for (const c of candidates) {
420
+ if (!c || typeof c !== 'object' || !c.body) { skipped++; continue; }
421
+
422
+ // Use classifyUserMemoryShard if shard not provided or invalid
423
+ const shard = USER_SHARDS.includes(c.shard)
424
+ ? c.shard
425
+ : classifyUserMemoryShard(c.body, c.tags);
426
+
427
+ const id = writeUserMemory(store, {
428
+ text: c.body,
429
+ tags: Array.isArray(c.tags) ? c.tags.map(String) : [],
430
+ sourceRef: { origin: 'dream-extract' },
431
+ });
432
+
433
+ if (id) {
434
+ extracted++;
435
+ } else {
436
+ skipped++;
437
+ }
438
+ }
439
+
440
+ // Update watermark to last processed message
441
+ const lastMsg = batch[batch.length - 1];
442
+ if (lastMsg) {
443
+ const newWm = {
444
+ lastMessageId: lastMsg.id || '',
445
+ lastMessageTs: lastMsg.ts || Date.now(),
446
+ };
447
+ writeWatermark(storeDir, newWm);
448
+ return { extracted, skipped, watermark: newWm };
449
+ }
450
+
451
+ return { extracted, skipped, watermark: wm };
452
+ }
package/unify/session.js CHANGED
@@ -34,6 +34,7 @@ import { initInputQueueStore } from './input-queue/store.js';
34
34
  import { createDispatcher } from './pipeline/dispatcher.js';
35
35
  import { ensureDefaultGroupIfEmpty } from './groups/group-crud.js';
36
36
  import { createDreamScheduler } from './memory/dream-scheduler.js';
37
+ import { getUserMemoryStore } from './memory/user-memory-store.js';
37
38
  import { join } from 'path';
38
39
  import { existsSync as existsSyncSafe, readFileSync as readFileSyncSafe } from 'fs';
39
40
 
@@ -236,6 +237,8 @@ export async function loadSession(options = {}) {
236
237
  // ─── 9a. Create dream scheduler (wave-6b) ─────────────
237
238
  const dreamScheduler = createDreamScheduler({
238
239
  memoryShardStore,
240
+ userMemoryStore: getUserMemoryStore(),
241
+ conversationStore,
239
242
  adapter,
240
243
  config,
241
244
  onDreamStart: (vpId) => {