@yeaft/webchat-agent 0.1.628 → 0.1.630
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 +1 -1
- package/unify/dream-v2/apply.js +271 -0
- package/unify/dream-v2/limits.js +46 -0
- package/unify/dream-v2/merge.js +87 -0
- package/unify/dream-v2/runner.js +267 -0
- package/unify/dream-v2/schedule.js +73 -0
- package/unify/dream-v2/segment.js +191 -0
- package/unify/dream-v2/snapshot.js +80 -0
- package/unify/dream-v2/state.js +177 -0
- package/unify/dream-v2/triage.js +287 -0
- package/unify/memory/migrate-r6-to-v2.js +462 -0
- package/unify/memory/store-v2.js +402 -0
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dream-v2/triage.js — DESIGN-v2 §14.
|
|
3
|
+
*
|
|
4
|
+
* Decide, for one group's diff, which scopes should be touched by Apply.
|
|
5
|
+
* The decision is two-staged on purpose:
|
|
6
|
+
*
|
|
7
|
+
* 1. **Hard rules** (this module, no LLM): everything we can determine
|
|
8
|
+
* from message metadata. Always include the active group, every VP
|
|
9
|
+
* that spoke as an assistant in the diff, every feature referenced
|
|
10
|
+
* via `featureId`, and `user` (so painted-over user-profile signals
|
|
11
|
+
* can't be missed).
|
|
12
|
+
*
|
|
13
|
+
* 2. **Soft classification** (LLM, two passes):
|
|
14
|
+
* Pass-1: high-recall — does the diff carry user-profile signal?
|
|
15
|
+
* what topics (category-level) does it touch?
|
|
16
|
+
* Pass-2: high-precision — for each topic Pass-1 surfaced, bind
|
|
17
|
+
* it to an exact existing path or propose
|
|
18
|
+
* a new ≤2-level path.
|
|
19
|
+
*
|
|
20
|
+
* VP / group / feature are deliberately NOT asked of the LLM —
|
|
21
|
+
* Hard Rules already cover them, and giving the LLM a chance to
|
|
22
|
+
* drop a structurally-required scope would weaken the contract.
|
|
23
|
+
*
|
|
24
|
+
* `user_profile_signals === true` does not need Pass-2 either: the
|
|
25
|
+
* Hard Rule already added `user` and Apply itself decides whether
|
|
26
|
+
* to actually rewrite anything (an UPDATE with no relevant content
|
|
27
|
+
* reads as a no-op rewrite of the existing memory).
|
|
28
|
+
*
|
|
29
|
+
* The LLM is injected as a callable: `llm({ pass, prompt, system })` →
|
|
30
|
+
* Promise<string>. Tests pass a stub; runner injects the real adapter.
|
|
31
|
+
*
|
|
32
|
+
* The triage actions are unioned across segments when one group's diff
|
|
33
|
+
* is split in segment.js — segment-level triage runs N times and the
|
|
34
|
+
* caller dedupes. (Implemented as a thin wrapper `triageGroupSegments`
|
|
35
|
+
* below.)
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { isValidTopic } from '../memory/store-v2.js';
|
|
39
|
+
import { truncateMessage } from './segment.js';
|
|
40
|
+
|
|
41
|
+
const SYSTEM = `You are the Triage stage of a dream pipeline that decides which scopes a recent group conversation should affect. Reply with strict JSON only — no prose, no markdown fences.`;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Hard rules: deterministically derive must-include scopes from the
|
|
45
|
+
* structure of the diff.
|
|
46
|
+
*
|
|
47
|
+
* Inputs:
|
|
48
|
+
* - groupId: the active group ('_no-group' is allowed and skips the
|
|
49
|
+
* `group/<id>` entry — by convention the virtual group has no scope
|
|
50
|
+
* of its own).
|
|
51
|
+
* - messages: the diff (already overlap-prefixed if applicable).
|
|
52
|
+
*
|
|
53
|
+
* @param {{ groupId: string, messages: Array<object> }} args
|
|
54
|
+
* @returns {Array<{ kind: 'update', scope: string }>}
|
|
55
|
+
*/
|
|
56
|
+
export function applyHardRules({ groupId, messages }) {
|
|
57
|
+
const out = new Map();
|
|
58
|
+
const add = (scope) => { if (!out.has(scope)) out.set(scope, { kind: 'update', scope }); };
|
|
59
|
+
|
|
60
|
+
// user is always in.
|
|
61
|
+
add('user');
|
|
62
|
+
|
|
63
|
+
// active group, except the virtual _no-group bucket.
|
|
64
|
+
if (groupId && groupId !== '_no-group') add(`group/${groupId}`);
|
|
65
|
+
|
|
66
|
+
for (const m of (messages || [])) {
|
|
67
|
+
if (!m || typeof m !== 'object') continue;
|
|
68
|
+
// Active VP: any assistant message's vpId.
|
|
69
|
+
if (m.role === 'assistant') {
|
|
70
|
+
const vp = m.vpId || (m.author && /^vp:(.+)$/.exec(m.author)?.[1]);
|
|
71
|
+
if (vp && /^[A-Za-z0-9_\-.一-鿿]+$/.test(vp)) add(`vp/${vp}`);
|
|
72
|
+
}
|
|
73
|
+
// Active feature: explicit metadata.
|
|
74
|
+
if (m.featureId && /^[A-Za-z0-9_\-.一-鿿]+$/.test(m.featureId)) {
|
|
75
|
+
add(`feature/${m.featureId}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return Array.from(out.values());
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ─── soft classification ──────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Build the prompt used for Pass-1.
|
|
86
|
+
*
|
|
87
|
+
* @param {{ groupId: string, messages: Array<object>, topicSummaries: Array<{ path: string, summary: string }> }} ctx
|
|
88
|
+
*/
|
|
89
|
+
export function buildPass1Prompt(ctx) {
|
|
90
|
+
const lines = [
|
|
91
|
+
'You are deciding whether a recent group conversation carries:',
|
|
92
|
+
' - signals that should update the USER profile, and/or',
|
|
93
|
+
' - signals that should update one or more TOPIC scopes.',
|
|
94
|
+
'',
|
|
95
|
+
'Do NOT mention vp/, group/, or feature/ scopes — those are handled by hard rules.',
|
|
96
|
+
'',
|
|
97
|
+
`Group: ${ctx.groupId}`,
|
|
98
|
+
'',
|
|
99
|
+
'Existing topic scopes (path — summary):',
|
|
100
|
+
];
|
|
101
|
+
if (!ctx.topicSummaries || ctx.topicSummaries.length === 0) {
|
|
102
|
+
lines.push(' (none)');
|
|
103
|
+
} else {
|
|
104
|
+
for (const t of ctx.topicSummaries) {
|
|
105
|
+
lines.push(` - ${t.path} — ${oneLine(t.summary)}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
lines.push('', 'Conversation:');
|
|
109
|
+
for (const m of (ctx.messages || [])) {
|
|
110
|
+
const head = `[${m.role || 'message'}${m.kind === 'overlap' ? ' (already processed)' : ''}]`;
|
|
111
|
+
lines.push(head);
|
|
112
|
+
lines.push(truncateMessage(m.body || ''));
|
|
113
|
+
lines.push('');
|
|
114
|
+
}
|
|
115
|
+
lines.push('Respond with strict JSON of the shape:');
|
|
116
|
+
lines.push('{');
|
|
117
|
+
lines.push(' "user_profile_signals": boolean,');
|
|
118
|
+
lines.push(' "topics": [ "<short category description>", ... ],');
|
|
119
|
+
lines.push(' "trivial_only": boolean');
|
|
120
|
+
lines.push('}');
|
|
121
|
+
return lines.join('\n');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Build the Pass-2 prompt for a single topic description.
|
|
126
|
+
*
|
|
127
|
+
* @param {{ description: string, existingTopics: Array<{ path: string, summary: string }> }} ctx
|
|
128
|
+
*/
|
|
129
|
+
export function buildPass2Prompt(ctx) {
|
|
130
|
+
const lines = [
|
|
131
|
+
'Bind a free-form topic description to an exact path under topic/.',
|
|
132
|
+
'Rules:',
|
|
133
|
+
' - At most TWO path segments. Reject any third level.',
|
|
134
|
+
' - Segments may contain letters, digits, dashes, underscores, dots, CJK.',
|
|
135
|
+
' - Prefer matching an existing path if the description fits.',
|
|
136
|
+
'',
|
|
137
|
+
`Description: ${ctx.description}`,
|
|
138
|
+
'',
|
|
139
|
+
'Existing topics:',
|
|
140
|
+
];
|
|
141
|
+
if (!ctx.existingTopics || ctx.existingTopics.length === 0) {
|
|
142
|
+
lines.push(' (none)');
|
|
143
|
+
} else {
|
|
144
|
+
for (const t of ctx.existingTopics) {
|
|
145
|
+
lines.push(` - ${t.path} — ${oneLine(t.summary)}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
lines.push('');
|
|
149
|
+
lines.push('Reply with strict JSON, exactly one of:');
|
|
150
|
+
lines.push(' { "decision": "match", "path": "<existing path>" }');
|
|
151
|
+
lines.push(' { "decision": "new", "path": "<new ≤2-segment path>" }');
|
|
152
|
+
lines.push(' { "decision": "none" }');
|
|
153
|
+
return lines.join('\n');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Run soft classification for one segment of one group's diff.
|
|
158
|
+
*
|
|
159
|
+
* @param {{
|
|
160
|
+
* groupId: string,
|
|
161
|
+
* messages: Array<object>,
|
|
162
|
+
* topicSummaries: Array<{ path: string, summary: string }>,
|
|
163
|
+
* llm: (req: { pass: string, prompt: string, system: string }) => Promise<string>,
|
|
164
|
+
* }} args
|
|
165
|
+
* @returns {Promise<Array<{ kind: 'update'|'create', scope: string }>>}
|
|
166
|
+
*/
|
|
167
|
+
export async function classifySoft({ groupId, messages, topicSummaries, llm }) {
|
|
168
|
+
if (!llm) throw new Error('triage.classifySoft: llm callable required');
|
|
169
|
+
const pass1Prompt = buildPass1Prompt({ groupId, messages, topicSummaries });
|
|
170
|
+
const pass1Raw = await llm({ pass: 'triage-pass1', prompt: pass1Prompt, system: SYSTEM });
|
|
171
|
+
const pass1 = parseJsonSafe(pass1Raw);
|
|
172
|
+
const out = [];
|
|
173
|
+
|
|
174
|
+
// user_profile_signals: covered by hard rules; we only emit explicit
|
|
175
|
+
// user action here when Pass-1 says yes (idempotent if hard rules
|
|
176
|
+
// already added it).
|
|
177
|
+
if (pass1 && pass1.user_profile_signals === true) {
|
|
178
|
+
out.push({ kind: 'update', scope: 'user' });
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const topicDescriptions = (pass1 && Array.isArray(pass1.topics)) ? pass1.topics : [];
|
|
182
|
+
for (const description of topicDescriptions) {
|
|
183
|
+
if (typeof description !== 'string' || !description.trim()) continue;
|
|
184
|
+
const pass2Prompt = buildPass2Prompt({
|
|
185
|
+
description: description.trim(),
|
|
186
|
+
existingTopics: topicSummaries || [],
|
|
187
|
+
});
|
|
188
|
+
const pass2Raw = await llm({ pass: 'triage-pass2', prompt: pass2Prompt, system: SYSTEM });
|
|
189
|
+
const pass2 = parseJsonSafe(pass2Raw);
|
|
190
|
+
if (!pass2 || !pass2.decision) continue;
|
|
191
|
+
if (pass2.decision === 'none') continue;
|
|
192
|
+
const path = String(pass2.path || '').trim();
|
|
193
|
+
if (!path) continue;
|
|
194
|
+
const segs = path.split('/').filter(Boolean);
|
|
195
|
+
if (!isValidTopic({ kind: 'topic', path: segs })) continue;
|
|
196
|
+
const scope = `topic/${segs.join('/')}`;
|
|
197
|
+
if (pass2.decision === 'match') {
|
|
198
|
+
out.push({ kind: 'update', scope });
|
|
199
|
+
} else if (pass2.decision === 'new') {
|
|
200
|
+
out.push({ kind: 'create', scope });
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return out;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Combine hard-rule and soft-classification results for one segment.
|
|
208
|
+
* Dedupes by scope — `update` wins if any source said update.
|
|
209
|
+
*
|
|
210
|
+
* @param {{
|
|
211
|
+
* groupId: string,
|
|
212
|
+
* messages: Array<object>,
|
|
213
|
+
* topicSummaries: Array<{ path: string, summary: string }>,
|
|
214
|
+
* llm: (req: { pass: string, prompt: string, system: string }) => Promise<string>,
|
|
215
|
+
* }} args
|
|
216
|
+
* @returns {Promise<Array<{ kind: 'update'|'create', scope: string }>>}
|
|
217
|
+
*/
|
|
218
|
+
export async function triageOneSegment(args) {
|
|
219
|
+
const hard = applyHardRules({ groupId: args.groupId, messages: args.messages });
|
|
220
|
+
const soft = await classifySoft(args);
|
|
221
|
+
return dedupeActions([...hard, ...soft]);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Triage a group's diff that has already been split into N segments.
|
|
226
|
+
* Runs each segment serially, accumulates and dedupes actions.
|
|
227
|
+
*
|
|
228
|
+
* @param {{
|
|
229
|
+
* groupId: string,
|
|
230
|
+
* segments: Array<{ messages: Array<object> }>,
|
|
231
|
+
* topicSummaries: Array<{ path: string, summary: string }>,
|
|
232
|
+
* llm: (req: { pass: string, prompt: string, system: string }) => Promise<string>,
|
|
233
|
+
* onProgress?: (event: object) => void,
|
|
234
|
+
* }} args
|
|
235
|
+
* @returns {Promise<Array<{ kind: 'update'|'create', scope: string }>>}
|
|
236
|
+
*/
|
|
237
|
+
export async function triageGroupSegments({ groupId, segments, topicSummaries, llm, onProgress }) {
|
|
238
|
+
let acc = [];
|
|
239
|
+
let i = 0;
|
|
240
|
+
for (const seg of (segments || [])) {
|
|
241
|
+
i += 1;
|
|
242
|
+
if (onProgress) onProgress({ phase: 'triage', groupId, segment: i, total: segments.length });
|
|
243
|
+
const segActions = await triageOneSegment({
|
|
244
|
+
groupId,
|
|
245
|
+
messages: seg.messages,
|
|
246
|
+
topicSummaries,
|
|
247
|
+
llm,
|
|
248
|
+
});
|
|
249
|
+
acc = dedupeActions([...acc, ...segActions]);
|
|
250
|
+
}
|
|
251
|
+
return acc;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ─── helpers ──────────────────────────────────────────────────
|
|
255
|
+
|
|
256
|
+
function dedupeActions(list) {
|
|
257
|
+
const map = new Map();
|
|
258
|
+
for (const a of list) {
|
|
259
|
+
if (!a || !a.scope) continue;
|
|
260
|
+
const cur = map.get(a.scope);
|
|
261
|
+
if (!cur) { map.set(a.scope, { ...a }); continue; }
|
|
262
|
+
if (a.kind === 'update') cur.kind = 'update';
|
|
263
|
+
}
|
|
264
|
+
return Array.from(map.values());
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function oneLine(s) {
|
|
268
|
+
return String(s || '').replace(/\s+/g, ' ').trim().slice(0, 200);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Lenient JSON parse: tolerate fenced ```json blocks. Returns null on failure. */
|
|
272
|
+
export function parseJsonSafe(raw) {
|
|
273
|
+
if (typeof raw !== 'string') return null;
|
|
274
|
+
let s = raw.trim();
|
|
275
|
+
// Strip markdown fences if present.
|
|
276
|
+
const fenced = /^```(?:json)?\s*([\s\S]*?)\s*```$/.exec(s);
|
|
277
|
+
if (fenced) s = fenced[1].trim();
|
|
278
|
+
try { return JSON.parse(s); }
|
|
279
|
+
catch { /* try to recover the first {...} block */ }
|
|
280
|
+
const start = s.indexOf('{');
|
|
281
|
+
const end = s.lastIndexOf('}');
|
|
282
|
+
if (start >= 0 && end > start) {
|
|
283
|
+
try { return JSON.parse(s.slice(start, end + 1)); }
|
|
284
|
+
catch { return null; }
|
|
285
|
+
}
|
|
286
|
+
return null;
|
|
287
|
+
}
|