@yeaft/webchat-agent 1.0.457 → 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.
@@ -1 +1 @@
1
- {"version":"1.0.457"}
1
+ {"version":"1.0.458"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "1.0.457",
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",
@@ -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();
@@ -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;