@yeaft/webchat-agent 1.0.406 → 1.0.407

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.406"}
1
+ {"version":"1.0.407"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "1.0.406",
3
+ "version": "1.0.407",
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",
@@ -145,6 +145,7 @@ export function buildPass2Prompt(ctx) {
145
145
  * Run soft classification for one segment of one group's diff.
146
146
  *
147
147
  * @param {{
148
+ * root?: string,
148
149
  * sessionId: string,
149
150
  * messages: Array<object>,
150
151
  * topicSummaries: Array<{ path: string, summary: string }>,
@@ -152,7 +153,7 @@ export function buildPass2Prompt(ctx) {
152
153
  * }} args
153
154
  * @returns {Promise<Array<{ kind: 'update'|'create', scope: string }>>}
154
155
  */
155
- export async function classifySoft({ sessionId, messages, topicSummaries, llm, language }) {
156
+ export async function classifySoft({ root, sessionId, messages, topicSummaries, llm, language }) {
156
157
  if (!llm) throw new Error('triage.classifySoft: llm callable required');
157
158
  const pass1Prompt = buildPass1Prompt({ sessionId, messages, topicSummaries, language });
158
159
  const pass1Raw = await llm({ pass: 'triage-pass1', prompt: pass1Prompt, system: triageSystem(language) });
@@ -183,8 +184,8 @@ export async function classifySoft({ sessionId, messages, topicSummaries, llm, l
183
184
  const segs = path.split('/').filter(Boolean);
184
185
  if (!sessionId || sessionId === '_no-session') continue;
185
186
  if (!isValidTopic({ kind: 'session-topic', sessionId, path: segs })) continue;
186
- const redirected = args.root
187
- ? resolveTopicRedirect(args.root, sessionId, segs.join('/'))
187
+ const redirected = root
188
+ ? resolveTopicRedirect(root, sessionId, segs.join('/'))
188
189
  : segs.join('/');
189
190
  const scope = `sessions/${sessionId}/topic/${redirected}`;
190
191
  if (pass2.decision === 'match') {
package/yeaft/engine.js CHANGED
@@ -1486,7 +1486,7 @@ export class Engine {
1486
1486
  * without injection.
1487
1487
  *
1488
1488
  * @param {string} prompt
1489
- * @param {{ sessionId?: string, vpId?: string, extraScopes?: string[] }} [ctx]
1489
+ * @param {{ sessionId?: string, vpId?: string, extraScopes?: string[], strictScopes?: string[] }} [ctx]
1490
1490
  * @returns {Promise<{ profile: string, entries: object[], formatted: string }|null>}
1491
1491
  */
1492
1492
  async #recallMemory(prompt, ctx = {}) {
@@ -1499,6 +1499,7 @@ export class Engine {
1499
1499
  chatId: ctx.chatId || this.#chatId,
1500
1500
  vpId: ctx.vpId,
1501
1501
  extraScopes: ctx.extraScopes,
1502
+ strictScopes: ctx.strictScopes,
1502
1503
  pickLimit: resolveMemoryRecallLimit(this.#config),
1503
1504
  uniqueScopes: true,
1504
1505
  canonicalOnly: true,
@@ -2300,11 +2301,8 @@ export class Engine {
2300
2301
  const projectScopesForMemory = Array.isArray(projectSessionIds)
2301
2302
  ? projectSessionIds.flatMap(id => [
2302
2303
  `sessions/${id}`,
2303
- `sessions/${id}/user`,
2304
2304
  `session/${id}`,
2305
- `session/${id}/user`,
2306
2305
  `group/${id}`,
2307
- `group/${id}/user`,
2308
2306
  ])
2309
2307
  : [];
2310
2308
  const recallResult = await this.#recallMemory(prompt, {
@@ -2313,6 +2311,10 @@ export class Engine {
2313
2311
  ? vpPersona.vpId
2314
2312
  : (typeof senderVpId === 'string' ? senderVpId : undefined),
2315
2313
  extraScopes: [...topicScopesForMemory, ...projectScopesForMemory],
2314
+ // Global user memory and Project siblings are much broader than the
2315
+ // active Session. A single generic OR-FTS hit must not pull an entire
2316
+ // historical content.md into the provider prompt.
2317
+ strictScopes: ['user', ...projectScopesForMemory],
2316
2318
  });
2317
2319
  recallEntryCount = recallResult && Array.isArray(recallResult.entries)
2318
2320
  ? recallResult.entries.length
@@ -18,6 +18,7 @@ import { approxTokens } from './budget.js';
18
18
  import {
19
19
  cleanMemoryPromptText,
20
20
  filterMemoryPromptTextForPrompt,
21
+ filterRelatedSessionPromptText,
21
22
  isDuplicateMemoryText,
22
23
  rememberMemoryText,
23
24
  } from './prompt-cleanup.js';
@@ -172,11 +173,11 @@ export class ActiveMemorySet {
172
173
  const { picked: resPicked, cost: resCost } = pickMemoryItems({
173
174
  items: [...this._resident.entries()].map(([scope, entry]) => ({
174
175
  scope,
175
- // Related-Session summaries are explicitly historical context. Keep the
176
- // bounded prose intact and label it as experience instead of dropping
177
- // the whole paragraph because it mentions an old PR/tag/task state.
176
+ // Related-Session content is broad historical context. Even after FTS
177
+ // selects its scope, keep only chunks supported by the current user turn;
178
+ // otherwise one weak term can drag an entire old PR/session into prompt.
178
179
  summary: entry.category === 'experience'
179
- ? cleanMemoryPromptText(entry.summary)
180
+ ? filterRelatedSessionPromptText(entry.summary, userMsg)
180
181
  : filterMemoryPromptTextForPrompt(entry.summary, userMsg),
181
182
  ...(entry.category ? { category: entry.category } : {}),
182
183
  })),
@@ -36,7 +36,6 @@ const STOP_WORDS = new Set([
36
36
  '当前', '状态', '任务', '工作', '工作项', '待办', '下一步', '完成', '合并',
37
37
  '评审', '发布', '标签', '记忆', '主题', '阻塞', '正在', '上下文', '最新',
38
38
  ]);
39
-
40
39
  /**
41
40
  * Extract keywords from a prompt (pure rules, no LLM).
42
41
  *
@@ -18,8 +18,10 @@
18
18
  import { extractKeywords } from './keywords.js';
19
19
  import { approxTokens } from './budget.js';
20
20
  import { isVpForeign } from './store.js';
21
+ import { isTransientMemoryText, promptRelevanceTokens } from './prompt-cleanup.js';
21
22
 
22
23
  export const DEFAULT_PICK_LIMIT = 8;
24
+ const STRICT_SCOPE_MIN_QUERY_TERMS = 2;
23
25
 
24
26
  /**
25
27
  * @typedef {object} PreflowOptions
@@ -32,6 +34,7 @@ export const DEFAULT_PICK_LIMIT = 8;
32
34
  * @property {number} [pickLimit] max picked segments (default 8)
33
35
  * @property {boolean} [uniqueScopes] pick at most one best hit per scope
34
36
  * @property {boolean} [canonicalOnly] search canonical content records only
37
+ * @property {string[]} [strictScopes] scopes that require multiple distinct query-term matches
35
38
  */
36
39
 
37
40
  /**
@@ -42,6 +45,7 @@ export const DEFAULT_PICK_LIMIT = 8;
42
45
  * @property {import('./segment.js').Segment[]} picked
43
46
  * @property {number} pickedTokens
44
47
  * @property {number} droppedCount
48
+ * @property {number} droppedByRelevance
45
49
  */
46
50
 
47
51
  /**
@@ -61,12 +65,13 @@ export function runPreflow(index, opts) {
61
65
  ? opts.budgetTokens : Infinity;
62
66
  const pickLimit = Number.isFinite(opts.pickLimit) && opts.pickLimit > 0
63
67
  ? Math.floor(opts.pickLimit) : DEFAULT_PICK_LIMIT;
68
+ const strictScopes = new Set(Array.isArray(opts.strictScopes) ? opts.strictScopes : []);
64
69
 
65
70
  const keywords = extractKeywords(userMsg);
66
71
  if (keywords.length === 0) {
67
72
  return {
68
73
  keywords: [], ftsQuery: '', hits: [],
69
- picked: [], pickedTokens: 0, droppedCount: 0,
74
+ picked: [], pickedTokens: 0, droppedCount: 0, droppedByRelevance: 0,
70
75
  };
71
76
  }
72
77
 
@@ -75,7 +80,7 @@ export function runPreflow(index, opts) {
75
80
  if (scopeFilter.length === 0) {
76
81
  return {
77
82
  keywords, ftsQuery, hits: [],
78
- picked: [], pickedTokens: 0, droppedCount: 0,
83
+ picked: [], pickedTokens: 0, droppedCount: 0, droppedByRelevance: 0,
79
84
  };
80
85
  }
81
86
 
@@ -91,7 +96,13 @@ export function runPreflow(index, opts) {
91
96
  const pickedScopes = new Set();
92
97
  let cost = 0;
93
98
  let dropped = 0;
99
+ let droppedByRelevance = 0;
94
100
  for (const h of reranked) {
101
+ if (strictScopes.has(h.scope) && !passesStrictScopeGate(h, userMsg)) {
102
+ dropped += 1;
103
+ droppedByRelevance += 1;
104
+ continue;
105
+ }
95
106
  if (opts.uniqueScopes && pickedScopes.has(h.scope)) {
96
107
  dropped += 1;
97
108
  continue;
@@ -108,10 +119,77 @@ export function runPreflow(index, opts) {
108
119
 
109
120
  return {
110
121
  keywords, ftsQuery, hits: reranked,
111
- picked, pickedTokens: cost, droppedCount: dropped,
122
+ picked, pickedTokens: cost, droppedCount: dropped, droppedByRelevance,
112
123
  };
113
124
  }
114
125
 
126
+ function passesStrictScopeGate(hit, userMsg) {
127
+ const queryTerms = promptRelevanceTokens(userMsg);
128
+ if (matchedStrictQueryTermCount(hit, queryTerms) >= STRICT_SCOPE_MIN_QUERY_TERMS) return true;
129
+
130
+ // Broad scopes still need a narrow path for exact entity lookups. The
131
+ // canonical record's tags are derived from its entire body, so an exact tag is
132
+ // not strong evidence. Require the whole one-term query to equal an authored
133
+ // Markdown heading instead; generic sentences and body-only hits stay gated.
134
+ return isSingleDiscriminativeQuery(userMsg, queryTerms) && hasExactCanonicalHeading(hit, userMsg);
135
+ }
136
+
137
+ function isSingleDiscriminativeQuery(userMsg, queryTerms) {
138
+ if (queryTerms.size !== 1 || isTransientMemoryText(userMsg)) return false;
139
+ const words = String(userMsg || '')
140
+ .normalize('NFKC')
141
+ .toLowerCase()
142
+ .match(/[\p{L}\p{N}_]+/gu) || [];
143
+ return words.length === 1;
144
+ }
145
+
146
+ function matchedStrictQueryTermCount(hit, queryTerms) {
147
+ const haystack = promptRelevanceTokens(
148
+ `${hit?.body || ''}\n${Array.isArray(hit?.tags) ? hit.tags.join(' ') : ''}`,
149
+ );
150
+ let matched = 0;
151
+ for (const term of queryTerms) {
152
+ if (haystack.has(term)) matched += 1;
153
+ }
154
+ return matched;
155
+ }
156
+
157
+ function hasExactCanonicalHeading(hit, userMsg) {
158
+ const query = normalizeStrongEvidenceText(userMsg);
159
+ if (!query) return false;
160
+
161
+ let fence = null;
162
+ for (const line of String(hit?.body || '').split(/\r?\n/)) {
163
+ if (fence) {
164
+ const closing = /^ {0,3}(`{3,}|~{3,})[ \t]*$/.exec(line);
165
+ if (closing && closing[1][0] === fence.marker && closing[1].length >= fence.length) {
166
+ fence = null;
167
+ }
168
+ continue;
169
+ }
170
+
171
+ const opening = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line);
172
+ if (opening && !(opening[1][0] === '`' && opening[2].includes('`'))) {
173
+ fence = { marker: opening[1][0], length: opening[1].length };
174
+ continue;
175
+ }
176
+ if (/^(?: {4}| {0,3}\t)/.test(line)) continue;
177
+
178
+ const match = /^ {0,3}#{1,6}[ \t]+(.+?)(?:[ \t]+#+)?[ \t]*$/.exec(line);
179
+ if (match && normalizeStrongEvidenceText(match[1]) === query) return true;
180
+ }
181
+ return false;
182
+ }
183
+
184
+ function normalizeStrongEvidenceText(text) {
185
+ return String(text || '')
186
+ .normalize('NFKC')
187
+ .toLowerCase()
188
+ .replace(/[`*_~[\]()<>{}.,,。::;;!!??"'“”‘’]/g, ' ')
189
+ .replace(/\s+/g, ' ')
190
+ .trim();
191
+ }
192
+
115
193
  /**
116
194
  * Compose an FTS5 MATCH query from keywords. Each keyword is OR'd with
117
195
  * a prefix wildcard so morphological variants match. We escape any
@@ -20,9 +20,15 @@ const COMMON_INTENT_TOKENS = new Set([
20
20
  'merge', 'merged', 'review', 'reviewed', 'release', 'tag', 'tags',
21
21
  'pr', 'pull', 'request', 'issue', 'fix', 'feat', 'test', 'tests',
22
22
  'dream', 'memory', 'session', 'topic', 'status', 'blocker', 'blocked',
23
- '当前', '状态', '任务', '工作', '工作项', '待办', '下一步', '完成', '已完成',
24
- '合并', '评审', '发布', '标签', '记忆', '主题', '阻塞', '正在',
23
+ 'yeaft',
25
24
  ]);
25
+ const COMMON_CJK_INTENT_PHRASES = [
26
+ '当前', '状态', '任务', '工作项', '工作', '待办', '下一步', '完成', '已完成',
27
+ '合并', '评审', '发布', '标签', '记忆', '主题', '阻塞', '正在',
28
+ '内容', '用户', '要求', '需求', '设计', '决策', '搜索', '发现', '需要', '应该',
29
+ '添加', '无关', '不相干', '完全', '明细', '看看', '还有', '这个',
30
+ '比如', '每个', '如果',
31
+ ].sort((a, b) => b.length - a.length);
26
32
 
27
33
  /**
28
34
  * Remove Dream scheduler metadata blocks from memory text.
@@ -126,6 +132,43 @@ export function filterMemoryPromptTextForPrompt(text, userText) {
126
132
  return joinMemoryPromptChunks(kept).trim();
127
133
  }
128
134
 
135
+ /**
136
+ * Project sibling content is a broad historical source, not resident state for
137
+ * the active Session. Keep only chunks with concrete lexical support from the
138
+ * current user turn; if none match, the sibling contributes no prompt block.
139
+ *
140
+ * @param {string} text
141
+ * @param {string} userText
142
+ * @returns {string}
143
+ */
144
+ export function filterRelatedSessionPromptText(text, userText) {
145
+ const cleaned = cleanMemoryPromptText(text);
146
+ if (!cleaned || !userText) return '';
147
+ const kept = [];
148
+ let governingHeading = '';
149
+ let emittedHeading = '';
150
+ for (const chunk of splitMemoryPromptChunks(cleaned)) {
151
+ if (/^#{1,6}\s+\S/.test(chunk)) {
152
+ governingHeading = chunk;
153
+ continue;
154
+ }
155
+ const headingRelevant = governingHeading && isMemoryPromptRelevant(governingHeading, userText);
156
+ const chunkRelevant = isMemoryPromptRelevant(chunk, userText);
157
+ const headingCarriesSpecificTerm = headingRelevant && hasSpecificPromptTerms(governingHeading);
158
+ if (!chunkRelevant && (
159
+ !headingCarriesSpecificTerm
160
+ || !hasConcretePromptTerms(chunk)
161
+ || hasOperationalMemoryMarker(chunk)
162
+ )) continue;
163
+ if (governingHeading && emittedHeading !== governingHeading) {
164
+ kept.push(governingHeading);
165
+ emittedHeading = governingHeading;
166
+ }
167
+ kept.push(chunk);
168
+ }
169
+ return joinMemoryPromptChunks(kept).trim();
170
+ }
171
+
129
172
  function splitMemoryPromptChunks(text) {
130
173
  const chunks = [];
131
174
  for (const block of String(text || '').split(/\n{2,}/)) {
@@ -177,13 +220,41 @@ export function promptRelevanceTokens(text) {
177
220
  if (!COMMON_INTENT_TOKENS.has(token)) out.add(token);
178
221
  }
179
222
  for (const match of cleaned.matchAll(CJK_RUN_RE)) {
180
- for (const token of cjkBigrams(match[0])) {
181
- if (!COMMON_INTENT_TOKENS.has(token)) out.add(token);
223
+ for (const run of removeCommonCjkIntentPhrases(match[0])) {
224
+ for (const token of cjkBigrams(run)) out.add(token);
182
225
  }
183
226
  }
184
227
  return out;
185
228
  }
186
229
 
230
+ function hasConcretePromptTerms(text) {
231
+ return promptRelevanceTokens(text).size > 0;
232
+ }
233
+
234
+ function hasOperationalMemoryMarker(text) {
235
+ return /\b(work\s*item|todo|next\s+step|blocker|blocked|pr\s*#?\d+|pull\s+request|review|merge\s+commit|release\s+tag|tag\s+v\d|v\d+\.\d+\.\d+)\b|(?:工作项|当前(?:状态|任务|工作)|正在|待办|下一步|阻塞|评审|合并|发布|已推|已合并|已完成)/i.test(String(text || ''));
236
+ }
237
+
238
+ function hasSpecificPromptTerms(text) {
239
+ const cleaned = cleanMemoryPromptText(text).toLowerCase();
240
+ for (const match of cleaned.matchAll(ASCII_WORD_RE)) {
241
+ const token = match[0];
242
+ if (!COMMON_INTENT_TOKENS.has(token) && token !== 'yeaft') return true;
243
+ }
244
+ for (const match of cleaned.matchAll(CJK_RUN_RE)) {
245
+ const run = match[0];
246
+ if (run.length === 2 && !COMMON_CJK_INTENT_PHRASES.includes(run)) return true;
247
+ if (removeCommonCjkIntentPhrases(run).some(part => part.length >= 2)) return true;
248
+ }
249
+ return false;
250
+ }
251
+
252
+ function removeCommonCjkIntentPhrases(text) {
253
+ let cleaned = text;
254
+ for (const phrase of COMMON_CJK_INTENT_PHRASES) cleaned = cleaned.split(phrase).join(' ');
255
+ return cleaned.match(CJK_RUN_RE) || [];
256
+ }
257
+
187
258
  function cjkBigrams(text) {
188
259
  const out = [];
189
260
  for (let i = 0; i < text.length - 1; i += 1) out.push(text.slice(i, i + 2));
@@ -72,8 +72,8 @@ export function writeScope(memoryRoot, scope, segments) {
72
72
  export function readCanonicalContentRecord(memoryRoot, scope) {
73
73
  const path = join(memoryRoot, scope, 'content.md');
74
74
  if (!existsSync(path)) return null;
75
- const body = readFileSync(path, 'utf8').trim();
76
- if (!body) return null;
75
+ const body = readFileSync(path, 'utf8');
76
+ if (!body.trim()) return null;
77
77
  const stat = statSync(path);
78
78
  const timestamp = stat.mtime.toISOString();
79
79
  const digest = createHash('sha256').update(scope).digest('hex').slice(0, 12);
@@ -262,6 +262,7 @@ export function formatPickedForInjection(picked) {
262
262
  * @property {number} [pickLimit] Max picked segments (default 8)
263
263
  * @property {boolean} [uniqueScopes] Pick only the best hit per scope
264
264
  * @property {boolean} [canonicalOnly] Search canonical content records only
265
+ * @property {string[]} [strictScopes] Additional scopes that require multiple distinct query-term matches
265
266
  * @property {boolean} [fallbackOnEmpty] Include bounded recent scoped segments when FTS has no hits
266
267
  * @property {number} [fallbackPerScope] Max fallback segments per scope
267
268
  */
@@ -359,6 +360,7 @@ export function runMemoryPreflow(index, opts) {
359
360
  pickLimit: opts.pickLimit,
360
361
  uniqueScopes: opts.uniqueScopes === true,
361
362
  canonicalOnly: opts.canonicalOnly === true,
363
+ strictScopes: opts.strictScopes,
362
364
  });
363
365
 
364
366
  let fallbackUsed = false;
@@ -396,6 +398,7 @@ export function runMemoryPreflow(index, opts) {
396
398
  ftsQuery: result.ftsQuery,
397
399
  pickedTokens: result.pickedTokens,
398
400
  droppedCount: result.droppedCount,
401
+ droppedByRelevance: result.droppedByRelevance || 0,
399
402
  hitCount: (result.hits || []).length,
400
403
  fallbackUsed,
401
404
  },