@yeaft/webchat-agent 1.0.456 → 1.0.458

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": "1.0.456",
3
+ "version": "1.0.458",
4
4
  "description": "Remote worker agent for Yeaft Web Code Agent — connects the native Yeaft engine, CLI providers, and workbench tools",
5
5
  "main": "index.js",
6
6
  "type": "module",
package/yeaft/config.js CHANGED
@@ -377,6 +377,7 @@ function loadLegacyConfig(dir, overrides) {
377
377
  // task-318: legacy path never had the `yeaft` section — defaults.
378
378
  yeaft: normaliseYeaftSection(null),
379
379
  telemetry: normaliseTelemetrySection(null),
380
+ dream: { enabled: true },
380
381
  browserRuntime: normaliseBrowserRuntimeSection(null),
381
382
  plugins: {},
382
383
  providers: null,
@@ -534,6 +535,7 @@ export function loadConfig(overrides = {}) {
534
535
  // don't pollute the flat config namespace used by chat code.
535
536
  yeaft: normaliseYeaftSection(jsonConfig.yeaft),
536
537
  telemetry: normaliseTelemetrySection(jsonConfig.telemetry),
538
+ dream: { enabled: jsonConfig.dream?.enabled !== false },
537
539
  browserRuntime: normaliseBrowserRuntimeSection(jsonConfig.browserRuntime),
538
540
 
539
541
  // Agent-level tools / skills / MCP server allowlists. Missing fields mean
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * dream/segment-extract.js.
3
3
  *
4
- * Extract atomic evidence segments for scopes touched by a Dream pass and
5
- * persist them through memory/segment-store. Dream Apply separately maintains
6
- * canonical content.md and the short catalog summary.md.
4
+ * Extract atomic evidence segments only for scopes successfully touched by a
5
+ * Dream pass and persist them through memory/segment-store. Dream Apply
6
+ * separately maintains canonical content.md and the short catalog summary.md.
7
7
  */
8
8
 
9
9
  import { readScope, writeScope } from '../memory/segment-store.js';
@@ -144,7 +144,7 @@ function templateVarsForScope(scope, sessionId) {
144
144
  }
145
145
 
146
146
  function normalizeTargetScopes(sessionId, targets) {
147
- const out = new Set(['user', `sessions/${sessionId}`, `sessions/${sessionId}/user`]);
147
+ const out = new Set();
148
148
  for (const target of targets) {
149
149
  if (typeof target !== 'string') continue;
150
150
  const clean = target.trim();
@@ -668,8 +668,8 @@ export function createV2DreamScheduler(session) {
668
668
  keepAlive: !!session?.config?.serverMode,
669
669
  logger: session.config?.debug ? console : undefined,
670
670
  });
671
- // Auto-start the timer.
672
- v2.start();
671
+ let enabled = session?.config?.dream?.enabled !== false;
672
+ if (enabled) v2.start();
673
673
 
674
674
  // task-710: wire `noteUserMessage` to a real per-session message
675
675
  // counter. When the count crosses DREAM_NUDGE_AFTER_MESSAGES (default
@@ -685,6 +685,7 @@ export function createV2DreamScheduler(session) {
685
685
  // immediately, defeating the 50-message guarantee.
686
686
  let messagesSinceLastNudgeFire = 0;
687
687
  function nudgeOnUserMessage() {
688
+ if (!enabled) return;
688
689
  messagesSinceLastNudgeFire += 1;
689
690
  if (messagesSinceLastNudgeFire < DREAM_NUDGE_AFTER_MESSAGES) return;
690
691
  if (v2.isRunning()) {
@@ -709,8 +710,20 @@ export function createV2DreamScheduler(session) {
709
710
  * below threshold are skipped — same shape as the interval timer
710
711
  * tick. Used by `bootCatchUpStaleDream`.
711
712
  */
712
- catchUpNudge() { return v2.nudge(); },
713
+ catchUpNudge() {
714
+ return enabled ? v2.nudge() : Promise.resolve({ skipped: true, skippedReason: 'disabled', trigger: 'auto' });
715
+ },
716
+ setEnabled(nextEnabled) {
717
+ enabled = nextEnabled !== false;
718
+ if (enabled) v2.start();
719
+ else {
720
+ messagesSinceLastNudgeFire = 0;
721
+ v2.stop();
722
+ }
723
+ return enabled;
724
+ },
713
725
  shutdown() { v2.stop(); },
726
+ get enabled() { return enabled; },
714
727
  get isRunning() { return v2.isRunning(); },
715
728
  // Preserve direct access for tests.
716
729
  _v2: v2,
@@ -727,12 +740,16 @@ export function createV2DreamScheduler(session) {
727
740
  * Pure side-effect, fire-and-forget. Failure logs at debug only — never
728
741
  * blocks session load.
729
742
  *
730
- * @param {{ yeaftDir: string, memoryIndex: import('../memory/index-db.js').SegmentIndex|null, dreamScheduler: { triggerDreamForScopes: (s:string[]) => Promise<any> }, config?: { debug?: boolean } }} args
743
+ * @param {{ yeaftDir: string, memoryIndex: import('../memory/index-db.js').SegmentIndex|null, dreamScheduler: { triggerDreamForScopes: (s:string[]) => Promise<any> }, config?: { debug?: boolean, dream?: { enabled?: boolean } } }} args
731
744
  * @returns {Promise<{ triggered: string[] }>}
732
745
  */
733
746
  export async function bootInitEmptyGroups(args) {
734
747
  const out = { triggered: [] };
735
748
  if (!args || !args.memoryIndex || !args.dreamScheduler) return out;
749
+ // This is automatic boot catch-up, not an explicit user trigger. The
750
+ // scheduler's scoped trigger intentionally remains manual-capable while
751
+ // disabled, so enforce the Agent toggle at this automatic entry point.
752
+ if (args.config?.dream?.enabled === false) return out;
736
753
  const sessionsRoot = join(args.yeaftDir, 'sessions');
737
754
  let ids;
738
755
  try { ids = listSessionMetas(sessionsRoot).map(g => g.id); }
@@ -1,3 +1,5 @@
1
+ import { inspect } from 'util';
2
+
1
3
  import { isValidTopic } from '../memory/store.js';
2
4
  /**
3
5
  * dream/triage.js.
@@ -6,9 +8,10 @@ import { isValidTopic } from '../memory/store.js';
6
8
  * The decision is two-staged on purpose:
7
9
  *
8
10
  * 1. **Hard rules** (this module, no LLM): everything we can determine
9
- * from message metadata. Always include the active session, every VP
10
- * that spoke as an assistant in the diff, and `user` (so painted-over
11
- * user-profile signals can't be missed). (Feature scope was dropped
11
+ * from message metadata. Always include the active session and every VP
12
+ * that spoke as an assistant in the diff. User-profile scopes are not a
13
+ * structural property, so they are added only when soft classification
14
+ * confirms a durable profile signal. (Feature scope was dropped
12
15
  * 2026-05-13 along with the rest of the Feature system.)
13
16
  *
14
17
  * 2. **Soft classification** (LLM, two passes):
@@ -18,14 +21,15 @@ import { isValidTopic } from '../memory/store.js';
18
21
  * it to an exact existing path or propose
19
22
  * a new ≤2-level path.
20
23
  *
21
- * VP / group are deliberately NOT asked of the LLM — Hard Rules
24
+ * VP / Session are deliberately NOT asked of the LLM — Hard Rules
22
25
  * already cover them, and giving the LLM a chance to drop a
23
26
  * structurally-required scope would weaken the contract.
24
27
  *
25
- * `user_profile_signals === true` does not need Pass-2 either: the
26
- * Hard Rule already added `user` and Apply itself decides whether
27
- * to actually rewrite anything (an UPDATE with no relevant content
28
- * reads as a no-op rewrite of the existing memory).
28
+ * `user_profile_signals === true` does not need Pass-2: it directly
29
+ * adds the global `user` scope and, for a real collaborative Session,
30
+ * the Session-local `sessions/<id>/user` scope. Keeping those scopes
31
+ * out when the classifier says false avoids two Apply rewrites and two
32
+ * extraction requests for ordinary task-focused conversation.
29
33
  *
30
34
  * The LLM is injected as a callable: `llm({ pass, prompt, system })` →
31
35
  * Promise<string>. Tests pass a stub; runner injects the real adapter.
@@ -63,8 +67,8 @@ export function applyHardRules({ sessionId, chatId, messages }) {
63
67
  const out = new Map();
64
68
  const add = (scope) => { if (!out.has(scope)) out.set(scope, { kind: 'update', scope }); };
65
69
 
66
- // global user is always in.
67
- add('user');
70
+ // User-profile scopes are content-dependent and are added by soft
71
+ // classification only when the diff contains a durable profile signal.
68
72
 
69
73
  // chat path takes precedence: chat sessions have no collaborative session context.
70
74
  if (chatId) {
@@ -84,7 +88,6 @@ export function applyHardRules({ sessionId, chatId, messages }) {
84
88
  // active collaborative session, except the virtual _no-session bucket.
85
89
  if (sessionId && sessionId !== '_no-session') {
86
90
  add(`sessions/${sessionId}`);
87
- add(`sessions/${sessionId}/user`);
88
91
  }
89
92
 
90
93
  for (const m of (messages || [])) {
@@ -159,18 +162,21 @@ export async function classifySoft({ root, sessionId, messages, topicSummaries,
159
162
  const pass1Prompt = buildPass1Prompt({ sessionId, messages, topicSummaries, language, maxPromptChars });
160
163
  const pass1Raw = await llm({ pass: 'triage-pass1', prompt: pass1Prompt, system: triageSystem(language) });
161
164
  const pass1 = parseJsonSafe(pass1Raw);
165
+ if (!isValidPass1(pass1)) {
166
+ throw triageContractError('Pass-1', pass1Raw);
167
+ }
162
168
  const out = [];
163
169
 
164
- // user_profile_signals: covered by hard rules; we only emit explicit
165
- // user action here when Pass-1 says yes (idempotent if hard rules
166
- // already added it).
167
- if (pass1 && pass1.user_profile_signals === true) {
170
+ // User-profile scopes are expensive shared-memory rewrites, not structural
171
+ // Session scopes. Route to them only when Pass-1 finds a durable signal.
172
+ if (pass1.user_profile_signals) {
168
173
  out.push({ kind: 'update', scope: 'user' });
174
+ if (sessionId && sessionId !== '_no-session') {
175
+ out.push({ kind: 'update', scope: `sessions/${sessionId}/user` });
176
+ }
169
177
  }
170
178
 
171
- const topicDescriptions = (pass1 && Array.isArray(pass1.topics)) ? pass1.topics : [];
172
- for (const description of topicDescriptions) {
173
- if (typeof description !== 'string' || !description.trim()) continue;
179
+ for (const description of pass1.topics) {
174
180
  const pass2Prompt = buildPass2Prompt({
175
181
  description: description.trim(),
176
182
  existingTopics: topicSummaries || [],
@@ -179,20 +185,18 @@ export async function classifySoft({ root, sessionId, messages, topicSummaries,
179
185
  });
180
186
  const pass2Raw = await llm({ pass: 'triage-pass2', prompt: pass2Prompt, system: triageSystem(language) });
181
187
  const pass2 = parseJsonSafe(pass2Raw);
182
- if (!pass2 || !pass2.decision) continue;
188
+ if (!isValidPass2(pass2, { sessionId, topicSummaries })) {
189
+ throw triageContractError('Pass-2', pass2Raw);
190
+ }
183
191
  if (pass2.decision === 'none') continue;
184
- const path = String(pass2.path || '').trim();
185
- if (!path) continue;
186
- const segs = path.split('/').filter(Boolean);
187
- if (!sessionId || sessionId === '_no-session') continue;
188
- if (!isValidTopic({ kind: 'session-topic', sessionId, path: segs })) continue;
192
+ const path = pass2.path;
189
193
  const redirected = root
190
- ? resolveTopicRedirect(root, sessionId, segs.join('/'))
191
- : segs.join('/');
194
+ ? resolveTopicRedirect(root, sessionId, path)
195
+ : path;
192
196
  const scope = `sessions/${sessionId}/topic/${redirected}`;
193
197
  if (pass2.decision === 'match') {
194
198
  out.push({ kind: 'update', scope });
195
- } else if (pass2.decision === 'new') {
199
+ } else {
196
200
  out.push({ kind: 'create', scope });
197
201
  }
198
202
  }
@@ -268,6 +272,58 @@ function oneLine(s) {
268
272
  return String(s || '').replace(/\s+/g, ' ').trim().slice(0, 200);
269
273
  }
270
274
 
275
+ function isPlainObject(value) {
276
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
277
+ }
278
+
279
+ function hasExactKeys(value, expected) {
280
+ const keys = Object.keys(value).sort();
281
+ return keys.length === expected.length && keys.every((key, index) => key === expected[index]);
282
+ }
283
+
284
+ function isValidPass1(value) {
285
+ if (!isPlainObject(value)
286
+ || !hasExactKeys(value, ['topics', 'trivial_only', 'user_profile_signals'])
287
+ || typeof value.user_profile_signals !== 'boolean'
288
+ || typeof value.trivial_only !== 'boolean'
289
+ || !Array.isArray(value.topics)
290
+ || !value.topics.every(topic => typeof topic === 'string' && topic.trim().length > 0)) {
291
+ return false;
292
+ }
293
+ return !value.trivial_only || (!value.user_profile_signals && value.topics.length === 0);
294
+ }
295
+
296
+ function isValidPass2(value, { sessionId, topicSummaries }) {
297
+ if (!isPlainObject(value) || !['match', 'new', 'none'].includes(value.decision)) return false;
298
+ if (value.decision === 'none') {
299
+ return hasExactKeys(value, ['decision']);
300
+ }
301
+ if (!hasExactKeys(value, ['decision', 'path'])
302
+ || !sessionId
303
+ || sessionId === '_no-session'
304
+ || typeof value.path !== 'string'
305
+ || value.path !== value.path.trim()) {
306
+ return false;
307
+ }
308
+ const path = value.path;
309
+ const segments = path.split('/');
310
+ if (!isValidTopic({ kind: 'session-topic', sessionId, path: segments })) return false;
311
+ const pathExists = (topicSummaries || []).some(topic => topic?.path === path);
312
+ return value.decision === 'match' ? pathExists : !pathExists;
313
+ }
314
+
315
+ function triageContractError(pass, raw) {
316
+ const err = new Error(`triage: ${pass} returned malformed JSON`);
317
+ err.rawSnippet = rawResponseSnippet(raw);
318
+ return err;
319
+ }
320
+
321
+ function rawResponseSnippet(raw) {
322
+ if (typeof raw === 'string') return raw.slice(0, 1000);
323
+ if (raw == null) return String(raw);
324
+ return inspect(raw, { depth: 2, maxArrayLength: 10, breakLength: 120 }).slice(0, 1000);
325
+ }
326
+
271
327
  /** Lenient JSON parse: tolerate fenced ```json blocks. Returns null on failure. */
272
328
  export function parseJsonSafe(raw) {
273
329
  if (typeof raw !== 'string') return null;
package/yeaft/session.js CHANGED
@@ -136,6 +136,7 @@ export async function loadSession(options = {}) {
136
136
  extraTools = [],
137
137
  configOverrides = {},
138
138
  serverMode = false,
139
+ dreamEnabled,
139
140
  managedCliReady = null,
140
141
  } = options;
141
142
 
@@ -172,6 +173,7 @@ export async function loadSession(options = {}) {
172
173
  // can decide whether to keep its interval timer alive (server) or
173
174
  // unref it (CLI / tests). Non-persisted — set per-session by caller.
174
175
  if (serverMode) config.serverMode = true;
176
+ if (typeof dreamEnabled === 'boolean') config.dream.enabled = dreamEnabled;
175
177
 
176
178
  // Propagate the (clamped) cold-start replay window to the conversation
177
179
  // store. The default is 20 turns; a user wanting more recall after a