@yeaft/webchat-agent 0.1.779 → 0.1.781

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.779",
3
+ "version": "0.1.781",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -163,7 +163,7 @@ export async function applyMergedTarget(merged, opts) {
163
163
  await snapFn(opts.root, ts, scopeDirRel);
164
164
 
165
165
  let memoryMd = await readMemory(scope, { root: opts.root });
166
- let summaryMd = await readSummary(scope, { root: opts.root });
166
+ let summaryMd = await readSummary(scope, { root: opts.root, language: opts.language });
167
167
 
168
168
  if (merged.kind === 'create' && (memoryMd || summaryMd)) {
169
169
  // Race / partial state: the scope already exists. Treat as update —
@@ -226,7 +226,7 @@ export async function applyMergedTarget(merged, opts) {
226
226
  // Stamp the per-scope dream marker, then atomically write both files.
227
227
  const stamped = withDreamMarker(memoryMd, { lastDreamAt: nowIso });
228
228
  await writeMemory(scope, stamped, { root: opts.root });
229
- await writeSummary(scope, summaryMd || '', { root: opts.root });
229
+ await writeSummary(scope, summaryMd || '', { root: opts.root, language: opts.language });
230
230
 
231
231
  if (opts.onProgress) opts.onProgress({ phase: 'apply', target: merged.target, status: 'done', batches: batchesUsed });
232
232
  return { target: merged.target, kind: merged.kind, batches: batchesUsed };
@@ -88,6 +88,7 @@ export async function runDream(opts) {
88
88
  // 1. enumerate groups
89
89
  const groupIds = await safeCall(opts.listGroups, []);
90
90
  const filter = Array.isArray(opts.scopeFilter) ? new Set(opts.scopeFilter) : null;
91
+ const groupFilter = deriveGroupFilter(filter);
91
92
  const groupsReport = [];
92
93
  const groupTriages = [];
93
94
  const processedGroups = [];
@@ -95,12 +96,18 @@ export async function runDream(opts) {
95
96
  // 2. per-group: skip / segment / triage
96
97
  const topicSummaries = opts.listTopicSummaries
97
98
  ? await safeCall(opts.listTopicSummaries, [])
98
- : await defaultListTopicSummaries(opts.root).catch(() => []);
99
+ : await defaultListTopicSummaries(opts.root, opts.language).catch(() => []);
99
100
 
100
101
  for (const groupId of groupIds) {
101
- // NOTE: scopeFilter is applied at the merge/apply stage, not here.
102
- // A filter like ['user'] still requires triaging every group so their
103
- // hard-rule actions can contribute to the user target.
102
+ // Current-group manual dream passes are the one case where scopeFilter
103
+ // must constrain enumeration too: clicking the conversation header means
104
+ // "dream this group now", not "triage every group and then only apply
105
+ // group/<id>". Pure target filters such as ['user'] still triage every
106
+ // group so their hard-rule actions can contribute to the requested scope.
107
+ if (groupFilter && !groupFilter.has(groupId)) {
108
+ groupsReport.push({ groupId, new: 0, status: 'skipped', reason: 'scope-filtered' });
109
+ continue;
110
+ }
104
111
  const state = await readGroupState(opts.root, groupId);
105
112
  const beforeCount = await safeCall(() => opts.countMessages(groupId), 0);
106
113
  const newCount = Math.max(0, beforeCount - (state.messageCount || 0));
@@ -170,7 +177,7 @@ export async function runDream(opts) {
170
177
  // 3. merge
171
178
  const mergedTargets = mergeByTarget(groupTriages);
172
179
  const targetsToApply = filter && filter.size > 0 && !filter.has('*')
173
- ? mergedTargets.filter(t => filter.has(t.target))
180
+ ? mergedTargets.filter(t => filter.has(t.target) || filter.has(`group/${sourceGroupId(t)}`))
174
181
  : mergedTargets;
175
182
 
176
183
  onProgress({ phase: 'merge', targets: targetsToApply.length });
@@ -263,6 +270,22 @@ function lastMessageId(messages) {
263
270
  return null;
264
271
  }
265
272
 
273
+ function deriveGroupFilter(filter) {
274
+ if (!filter || filter.size === 0 || filter.has('*')) return null;
275
+ const groups = [];
276
+ for (const scope of filter) {
277
+ if (typeof scope !== 'string') continue;
278
+ const m = /^group\/([^/]+)$/.exec(scope);
279
+ if (m && m[1]) groups.push(m[1]);
280
+ }
281
+ return groups.length > 0 ? new Set(groups) : null;
282
+ }
283
+
284
+ function sourceGroupId(mergedTarget) {
285
+ const src = Array.isArray(mergedTarget?.sources) ? mergedTarget.sources[0] : null;
286
+ return src && typeof src.groupId === 'string' ? src.groupId : '';
287
+ }
288
+
266
289
  async function safeCall(fn, fallback) {
267
290
  try {
268
291
  if (typeof fn !== 'function') return fallback;
@@ -273,12 +296,12 @@ async function safeCall(fn, fallback) {
273
296
  }
274
297
  }
275
298
 
276
- async function defaultListTopicSummaries(root) {
299
+ async function defaultListTopicSummaries(root, language) {
277
300
  const all = await listScopes({ root });
278
301
  const out = [];
279
302
  for (const sc of all) {
280
303
  if (sc.kind !== 'topic') continue;
281
- const summary = await readSummary(sc, { root });
304
+ const summary = await readSummary(sc, { root, language });
282
305
  out.push({ path: sc.path.join('/'), summary });
283
306
  }
284
307
  return out;
package/unify/engine.js CHANGED
@@ -216,6 +216,10 @@ export function buildResidentEntries(args) {
216
216
  return out;
217
217
  }
218
218
 
219
+ function isZhRuntimeLanguage(language) {
220
+ return String(language || '').toLowerCase().startsWith('zh');
221
+ }
222
+
219
223
  export class Engine {
220
224
  /** @type {import('./llm/adapter.js').LLMAdapter} */
221
225
  #adapter;
@@ -478,19 +482,19 @@ export class Engine {
478
482
  * dream tick (Phase 6) is what populates these; on a fresh install they
479
483
  * all return ''.
480
484
  *
481
- * @param {{groupId?: string, vpId?: string}} ctx
485
+ * @param {{groupId?: string, vpId?: string, language?: string}} ctx
482
486
  * @returns {Promise<{user:string, group:string, vp:string}>}
483
487
  */
484
- async #loadLayerASummaries({ groupId, vpId } = {}) {
488
+ async #loadLayerASummaries({ groupId, vpId, language } = {}) {
485
489
  if (!this.#yeaftDir) return { user: '', group: '', vp: '' };
486
490
  const memoryRoot = `${this.#yeaftDir}/memory`;
487
491
  const tasks = [
488
- readScopeSummary({ kind: 'user' }, { root: memoryRoot }).catch(() => ''),
492
+ readScopeSummary({ kind: 'user' }, { root: memoryRoot, language }).catch(() => ''),
489
493
  groupId
490
- ? readScopeSummary({ kind: 'group', id: groupId }, { root: memoryRoot }).catch(() => '')
494
+ ? readScopeSummary({ kind: 'group', id: groupId }, { root: memoryRoot, language }).catch(() => '')
491
495
  : Promise.resolve(''),
492
496
  vpId
493
- ? readScopeSummary({ kind: 'vp', id: vpId }, { root: memoryRoot }).catch(() => '')
497
+ ? readScopeSummary({ kind: 'vp', id: vpId }, { root: memoryRoot, language }).catch(() => '')
494
498
  : Promise.resolve(''),
495
499
  ];
496
500
  const [user, group, vp] = await Promise.all(tasks);
@@ -544,7 +548,7 @@ export class Engine {
544
548
  ams.setOnDemand(segs);
545
549
 
546
550
  // (c) Snapshot — render the AMS layers as a single prompt block.
547
- const snapshotBlock = this.#renderAmsSnapshot(ams);
551
+ const snapshotBlock = this.#renderAmsSnapshot(ams, this.#config.language || 'en');
548
552
 
549
553
  const scopes = buildRelevantScopes({
550
554
  groupId: args.groupId,
@@ -560,30 +564,35 @@ export class Engine {
560
564
  * so the LLM sees a consistent layout.
561
565
  *
562
566
  * @param {import('./memory/ams.js').ActiveMemorySet} ams
567
+ * @param {string} [language]
563
568
  * @returns {string}
564
569
  */
565
- #renderAmsSnapshot(ams) {
570
+ #renderAmsSnapshot(ams, language = 'en') {
566
571
  const snap = ams.snapshot();
567
572
  if (!snap) return '';
568
573
  const parts = [];
569
574
  if (snap.resident.length === 0 && snap.recent.length === 0 && snap.onDemand.length === 0) {
570
575
  return '';
571
576
  }
572
- parts.push('## Active Memory Set');
577
+ const zh = isZhRuntimeLanguage(language);
578
+ parts.push(zh ? '## 活跃记忆集' : '## Active Memory Set');
579
+ parts.push(zh
580
+ ? '以下记忆按当前用户语言呈现;如果个别历史摘要仍是其他语言,请只把它当作事实来源,回答和新增记忆应使用中文。'
581
+ : 'Memory is presented for the current user language; if an older summary is in another language, treat it as factual context and continue in English.');
573
582
  if (snap.resident.length > 0) {
574
- parts.push('### Resident');
583
+ parts.push(zh ? '### 常驻记忆' : '### Resident');
575
584
  for (const r of snap.resident) {
576
585
  parts.push(`- **${r.scope}**: ${r.summary}`);
577
586
  }
578
587
  }
579
588
  if (snap.recent.length > 0) {
580
- parts.push('### Recent');
589
+ parts.push(zh ? '### 最近记忆' : '### Recent');
581
590
  for (const s of snap.recent) {
582
591
  parts.push(`- (${s.scope}) ${(s.body || '').trim()}`);
583
592
  }
584
593
  }
585
594
  if (snap.onDemand.length > 0) {
586
- parts.push('### OnDemand');
595
+ parts.push(zh ? '### 按需记忆' : '### OnDemand');
587
596
  for (const s of snap.onDemand) {
588
597
  parts.push(`- (${s.scope}) ${(s.body || '').trim()}`);
589
598
  }
@@ -1140,6 +1149,7 @@ export class Engine {
1140
1149
  vpId: vpPersona && typeof vpPersona === 'object' && typeof vpPersona.vpId === 'string'
1141
1150
  ? vpPersona.vpId
1142
1151
  : (typeof senderVpId === 'string' ? senderVpId : undefined),
1152
+ language: this.#config.language || 'en',
1143
1153
  });
1144
1154
 
1145
1155
  // ─── AMS: populate + snapshot ───────────────────────────────
@@ -197,7 +197,7 @@ async function atomicWrite(absPath, content) {
197
197
  * Read a scope's memory.md. Missing → empty string.
198
198
  *
199
199
  * @param {Scope} scope
200
- * @param {{ root?: string, currentVpId?: string }} [opts]
200
+ * @param {{ root?: string, currentVpId?: string, language?: string }} [opts]
201
201
  * @returns {Promise<string>}
202
202
  */
203
203
  export async function readMemory(scope, opts = {}) {
@@ -254,23 +254,38 @@ export async function appendMemory(scope, chunk, opts = {}) {
254
254
 
255
255
  // ─── summary.md ────────────────────────────────────────────────
256
256
 
257
+
258
+ function summaryFileName(language) {
259
+ const normalized = String(language || '').toLowerCase().startsWith('zh') ? 'zh' : 'en';
260
+ return normalized === 'zh' ? 'summary.zh.md' : 'summary.md';
261
+ }
262
+
263
+ function summaryCandidateRels(scope, language) {
264
+ const dir = scopeDir(scope);
265
+ const primary = `${dir}/${summaryFileName(language)}`;
266
+ const fallback = `${dir}/summary.md`;
267
+ return primary === fallback ? [fallback] : [primary, fallback];
268
+ }
269
+
257
270
  /**
258
271
  * Read a scope's summary.md (trimmed). Missing → empty string.
259
272
  *
260
273
  * @param {Scope} scope
261
- * @param {{ root?: string, currentVpId?: string }} [opts]
274
+ * @param {{ root?: string, currentVpId?: string, language?: string }} [opts]
262
275
  * @returns {Promise<string>}
263
276
  */
264
277
  export async function readSummary(scope, opts = {}) {
265
- const { root = DEFAULT_MEMORY_ROOT, currentVpId } = opts;
266
- const rel = `${scopeDir(scope)}/summary.md`;
267
- enforceVpAcl(rel, currentVpId);
268
- const abs = join(root, rel);
269
- try { return (await fsp.readFile(abs, 'utf8')).trim(); }
270
- catch (err) {
271
- if (err && err.code === 'ENOENT') return '';
272
- throw err;
278
+ const { root = DEFAULT_MEMORY_ROOT, currentVpId, language } = opts;
279
+ for (const rel of summaryCandidateRels(scope, language)) {
280
+ enforceVpAcl(rel, currentVpId);
281
+ const abs = join(root, rel);
282
+ try { return (await fsp.readFile(abs, 'utf8')).trim(); }
283
+ catch (err) {
284
+ if (err && err.code === 'ENOENT') continue;
285
+ throw err;
286
+ }
273
287
  }
288
+ return '';
274
289
  }
275
290
 
276
291
  /**
@@ -278,11 +293,11 @@ export async function readSummary(scope, opts = {}) {
278
293
  *
279
294
  * @param {Scope} scope
280
295
  * @param {string} body
281
- * @param {{ root?: string, currentVpId?: string }} [opts]
296
+ * @param {{ root?: string, currentVpId?: string, language?: string }} [opts]
282
297
  */
283
298
  export async function writeSummary(scope, body, opts = {}) {
284
- const { root = DEFAULT_MEMORY_ROOT, currentVpId } = opts;
285
- const rel = `${scopeDir(scope)}/summary.md`;
299
+ const { root = DEFAULT_MEMORY_ROOT, currentVpId, language } = opts;
300
+ const rel = `${scopeDir(scope)}/${summaryFileName(language)}`;
286
301
  enforceVpAcl(rel, currentVpId);
287
302
  const abs = join(root, rel);
288
303
  await atomicWrite(abs, `${(body || '').trim()}\n`);
package/unify/session.js CHANGED
@@ -50,7 +50,7 @@ import { openSegmentIndex } from './memory/index-db.js';
50
50
  import { syncAll as syncSegmentIndex } from './memory/segment-sync.js';
51
51
  import { openAmsRegistry } from './memory/ams-registry.js';
52
52
  import { join } from 'path';
53
- import { existsSync as existsSyncSafe, readFileSync as readFileSyncSafe } from 'fs';
53
+ import { existsSync as existsSyncSafe, readFileSync as readFileSyncSafe, mkdirSync as mkdirSyncSafe } from 'fs';
54
54
 
55
55
  /**
56
56
  * @typedef {Object} SessionOptions
@@ -80,6 +80,33 @@ import { existsSync as existsSyncSafe, readFileSync as readFileSyncSafe } from '
80
80
  * @property {() => Promise<void>} shutdown — Graceful shutdown
81
81
  */
82
82
 
83
+ /**
84
+ * Eagerly create `<yeaftDir>/stats/` and surface failures as a warn.
85
+ *
86
+ * Returns the resolved path either way — the caller can still pass it
87
+ * to `ToolUsageStats`, which keeps an in-memory counter path even when
88
+ * the disk is read-only.
89
+ *
90
+ * NOTE: this knowledge ("stats lives under `stats/`") belongs inside
91
+ * `ToolUsageStats.init()`. Once that exists, delete this helper and
92
+ * the call site collapses to `await toolStats.init(yeaftDir)`.
93
+ *
94
+ * @param {string} yeaftDir
95
+ * @returns {string} statsDir
96
+ */
97
+ function prepareToolStatsDir(yeaftDir) {
98
+ const statsDir = join(yeaftDir, 'stats');
99
+ try {
100
+ mkdirSyncSafe(statsDir, { recursive: true });
101
+ } catch (err) {
102
+ console.warn(
103
+ `[Unify] Could not create stats dir ${statsDir}: ${err?.message || err}. ` +
104
+ `Tool-usage counters will live in memory only.`
105
+ );
106
+ }
107
+ return statsDir;
108
+ }
109
+
83
110
  /**
84
111
  * Load (or initialize) a Yeaft session.
85
112
  *
@@ -320,8 +347,24 @@ export async function loadSession(options = {}) {
320
347
  // Tool-call usage statistics: persisted to <yeaftDir>/stats/tool-usage.json.
321
348
  // Loaded synchronously at boot so the first turn already sees prior counts.
322
349
  // Threaded into the engine so it can `record` each tool_exec event.
350
+ //
351
+ // 2026-05-16: eagerly create the `stats/` directory at boot. The
352
+ // ToolUsageStats writer does `fsp.mkdir(..., {recursive:true})` lazily
353
+ // inside `#doFlush()` and swallows any mkdir error, which meant an
354
+ // unwritable parent (perm denied, ENOSPC) was silently invisible
355
+ // until the user filed a support ticket. Doing it here surfaces the
356
+ // failure as a console.warn while still leaving the in-memory
357
+ // counter path functional — the engine keeps recording even if the
358
+ // disk is read-only.
359
+ //
360
+ // FOLLOW-UP: this leaks `ToolUsageStats`'s storage layout (its
361
+ // directory name) into the session orchestrator. The right home is
362
+ // a `ToolUsageStats.init()` that owns the mkdir + the warn + a
363
+ // `writesDisabled` flag. Tracking as future work; for now the helper
364
+ // below visually quarantines the leak so the migration is one delete.
365
+ const statsDir = prepareToolStatsDir(yeaftDir);
323
366
  const toolStats = new ToolUsageStats({
324
- path: join(yeaftDir, 'stats', 'tool-usage.json'),
367
+ path: join(statsDir, 'tool-usage.json'),
325
368
  });
326
369
  toolStats.loadSync();
327
370
  const engine = new Engine({
@@ -2692,6 +2692,37 @@ export const __testRaceWithEscalation = raceWithEscalation;
2692
2692
  * Backwards-compat: when neither field is set, defaults to `vpId='default'`
2693
2693
  * which matches the pre-v0.1.754 behavior.
2694
2694
  */
2695
+ export function normalizeDreamResult(result) {
2696
+ const groups = Array.isArray(result?.groups) ? result.groups : [];
2697
+ const targets = Array.isArray(result?.targets) ? result.targets : [];
2698
+ const groupsProcessed = groups.filter(g => g && g.status === 'triaged').length;
2699
+ const skippedGroups = groups.filter(g => g && g.status === 'skipped');
2700
+ const groupsSkipped = skippedGroups.length;
2701
+ const targetsApplied = targets.filter(t => t && t.status === 'done').length;
2702
+ const targetErrors = targets
2703
+ .filter(t => t && t.status === 'error')
2704
+ .map(t => ({ target: t.target || null, error: t.error || 'unknown' }));
2705
+ const hardError = result?.error || null;
2706
+ const skipped = !hardError && groupsProcessed === 0 && targetsApplied === 0;
2707
+ const skippedReason = skipped
2708
+ ? (skippedGroups[0]?.reason || 'no-targets-applied')
2709
+ : null;
2710
+ const success = !hardError && targetErrors.length === 0 && !skipped && targetsApplied > 0;
2711
+
2712
+ return {
2713
+ success,
2714
+ skipped,
2715
+ skippedReason,
2716
+ groupsProcessed,
2717
+ groupsSkipped,
2718
+ targetsApplied,
2719
+ targetErrors,
2720
+ entriesCreated: targetsApplied,
2721
+ lastDreamAt: result?.startedAt || new Date().toISOString(),
2722
+ error: hardError || (targetErrors[0]?.error || null),
2723
+ };
2724
+ }
2725
+
2695
2726
  export async function handleUnifyDreamTrigger(msg = {}) {
2696
2727
  // Resolve tag up-front so EVERY outbound envelope (including the
2697
2728
  // scheduler-uninitialised early-return below) carries `groupId` /
@@ -2704,11 +2735,11 @@ export async function handleUnifyDreamTrigger(msg = {}) {
2704
2735
  const tag = groupId ? { groupId } : { vpId };
2705
2736
 
2706
2737
  if (!session?.dreamScheduler) {
2738
+ const error = 'Dream scheduler not initialized — session not loaded.';
2707
2739
  sendToServer({
2708
2740
  type: 'unify_dream_result',
2709
2741
  ...tag,
2710
- success: false,
2711
- error: 'Dream scheduler not initialized — session not loaded.',
2742
+ ...normalizeDreamResult({ error }),
2712
2743
  });
2713
2744
  return;
2714
2745
  }
@@ -2724,11 +2755,11 @@ export async function handleUnifyDreamTrigger(msg = {}) {
2724
2755
  // dream-v2/schedule.js inflight reuse), so the user-facing semantics
2725
2756
  // are unchanged ("you already asked").
2726
2757
  if (groupId && inflightScopedDreamGroups.size > 0) {
2758
+ const error = 'A dream pass is already running.';
2727
2759
  sendToServer({
2728
2760
  type: 'unify_dream_result',
2729
2761
  ...tag,
2730
- success: false,
2731
- error: 'A dream pass is already running.',
2762
+ ...normalizeDreamResult({ error }),
2732
2763
  });
2733
2764
  return;
2734
2765
  }
@@ -2764,17 +2795,12 @@ export async function handleUnifyDreamTrigger(msg = {}) {
2764
2795
  ? await session.dreamScheduler.triggerDreamForScopes([`group/${groupId}`])
2765
2796
  : await session.dreamScheduler.triggerDreamNow();
2766
2797
 
2767
- // fix/dream-cadence-and-ui-trigger: derive a single "entries
2768
- // created" count for the UI bubble. The runner returns a richer
2769
- // shape (groups[], targets[]); the front-end button only needs a
2770
- // scalar plus the start timestamp.
2771
- const targets = Array.isArray(result?.targets) ? result.targets : [];
2772
- const entriesCreated = targets.filter(t => t && t.status === 'done').length;
2773
- const lastDreamAt = result?.startedAt || new Date().toISOString();
2774
- const success = !result.error && !result.skipped;
2775
-
2776
- // Spread `result` FIRST so derived fields (success, entriesCreated,
2777
- // lastDreamAt) authoritatively shadow anything the runner might grow
2798
+ const normalized = normalizeDreamResult(result);
2799
+
2800
+ // Spread `result` FIRST so normalized fields (success, skipped,
2801
+ // skippedReason, groupsProcessed, groupsSkipped, targetsApplied,
2802
+ // targetErrors, entriesCreated, lastDreamAt) authoritatively shadow
2803
+ // anything the runner might grow
2778
2804
  // with the same name. Today there is no collision (runner.js returns
2779
2805
  // { groups, targets, startedAt, error?, skipped? }) but the failure
2780
2806
  // mode of the alternative ordering is silent — review feedback from
@@ -2791,16 +2817,14 @@ export async function handleUnifyDreamTrigger(msg = {}) {
2791
2817
  type: 'unify_dream_result',
2792
2818
  ...tag,
2793
2819
  ...result,
2794
- success,
2795
- entriesCreated,
2796
- lastDreamAt,
2820
+ ...normalized,
2797
2821
  });
2798
2822
  } catch (err) {
2823
+ const error = err?.message || String(err);
2799
2824
  sendToServer({
2800
2825
  type: 'unify_dream_result',
2801
2826
  ...tag,
2802
- success: false,
2803
- error: err?.message || String(err),
2827
+ ...normalizeDreamResult({ error }),
2804
2828
  });
2805
2829
  } finally {
2806
2830
  // Restore the original sink and release the per-group inflight lock.