@yeaft/webchat-agent 0.1.647 → 0.1.649

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.647",
3
+ "version": "0.1.649",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -17,8 +17,34 @@ const FILES = {
17
17
  triagePass2: 'triage-pass2.md',
18
18
  update: 'update.md',
19
19
  create: 'create.md',
20
+ // H2.e — per-scope segment extraction prompts (one per scope family)
21
+ extractUser: 'extract-user.md',
22
+ extractVp: 'extract-vp.md',
23
+ extractGroup: 'extract-group.md',
24
+ extractFeature: 'extract-feature.md',
25
+ extractTopic: 'extract-topic.md',
26
+ // H2.e — per-scope summary compression
27
+ summarizeScope: 'summarize-scope.md',
20
28
  };
21
29
 
30
+ /**
31
+ * Map a scope string (e.g. "user", "vp/alice", "topic/auth/jwt") to the
32
+ * extraction template name. Unknown scopes fall back to `extractTopic`
33
+ * (the most generic template) so we never throw at extraction time.
34
+ *
35
+ * @param {string} scope
36
+ * @returns {keyof typeof FILES}
37
+ */
38
+ export function extractTemplateForScope(scope) {
39
+ if (!scope || typeof scope !== 'string') return 'extractTopic';
40
+ if (scope === 'user') return 'extractUser';
41
+ if (scope.startsWith('vp/')) return 'extractVp';
42
+ if (scope.startsWith('group/')) return 'extractGroup';
43
+ if (scope.startsWith('feature/')) return 'extractFeature';
44
+ if (scope.startsWith('topic/')) return 'extractTopic';
45
+ return 'extractTopic';
46
+ }
47
+
22
48
  /** @type {Record<string, string>} */
23
49
  const cache = {};
24
50
 
@@ -0,0 +1,303 @@
1
+ /**
2
+ * memory/adjust.js — DESIGN-H2-AMS §7. Post-turn LLM AMS adjustment.
3
+ *
4
+ * Pre-flow uses FTS keyword recall — fast but coarse. Some semantically
5
+ * relevant segments will be missed; some FTS hits won't be relevant to
6
+ * THIS turn. adjustMemory is the LLM-grade correction step:
7
+ *
8
+ * - Sees the full visible memory (all reachable scopes, privacy-filtered).
9
+ * - Outputs `add` (segments to pull into AMS.onDemand that pre-flow missed)
10
+ * and `evict` (segments currently in AMS that this turn didn't need).
11
+ * - Does NOT modify segment bodies. Does NOT create new segments. Only
12
+ * manipulates AMS membership.
13
+ *
14
+ * Triggered conditionally — typical session shape is "hot turn skips,
15
+ * adjust runs every 5–10 turns or on first turn":
16
+ *
17
+ * shouldRunAdjust =
18
+ * (newMemoryWritten && onDemand.size >= 5)
19
+ * || (turnTokenUsage > totalBudget * 0.9)
20
+ * || (!session.adjustRanThisSession) // first-turn guarantee
21
+ *
22
+ * The trigger lives at the call site (engine post-turn hook); this
23
+ * module just exposes the policy + the LLM round-trip.
24
+ */
25
+
26
+ import { approxTokens } from './budget.js';
27
+
28
+ /**
29
+ * @typedef {object} AdjustTriggerInput
30
+ * @property {boolean} newMemoryWritten
31
+ * @property {number} onDemandSize
32
+ * @property {number} turnTokenUsage
33
+ * @property {number} totalBudget
34
+ * @property {boolean} adjustRanThisSession
35
+ */
36
+
37
+ /**
38
+ * Pure decision function: should adjustMemory run this turn?
39
+ *
40
+ * @param {AdjustTriggerInput} input
41
+ * @returns {{ run: boolean, reason: string }}
42
+ */
43
+ export function shouldRunAdjust(input) {
44
+ if (!input) return { run: false, reason: 'no input' };
45
+ if (!input.adjustRanThisSession) {
46
+ return { run: true, reason: 'first-turn-guarantee' };
47
+ }
48
+ if (input.turnTokenUsage > input.totalBudget * 0.9) {
49
+ return { run: true, reason: 'budget-pressure' };
50
+ }
51
+ if (input.newMemoryWritten && input.onDemandSize >= 5) {
52
+ return { run: true, reason: 'new-memory+onDemand-saturated' };
53
+ }
54
+ return { run: false, reason: 'no-trigger' };
55
+ }
56
+
57
+ /**
58
+ * Build the candidate visible-segments list for the LLM, applying
59
+ * privacy and a per-scope summarisation cap so prompts stay bounded.
60
+ *
61
+ * If a scope holds more than `bodyCap` segments, we replace each
62
+ * segment body with its first sentence + tags (cheap summary). This
63
+ * keeps the adjust prompt < ~10k tokens even when the user has
64
+ * thousands of segments.
65
+ *
66
+ * @param {{
67
+ * index: import('./index-db.js').SegmentIndex,
68
+ * scopes: string[],
69
+ * ownVpId: string|null,
70
+ * currentAmsIds: Set<string>,
71
+ * bodyCap?: number,
72
+ * }} args
73
+ * @returns {Array<{
74
+ * id: string, scope: string, kind: string, tags: string[],
75
+ * body: string, inAMS: boolean, summarised: boolean,
76
+ * }>}
77
+ */
78
+ export function buildVisibleSegments(args) {
79
+ const bodyCap = Number.isFinite(args.bodyCap) && args.bodyCap > 0
80
+ ? args.bodyCap : 200;
81
+ const visibleScopes = args.scopes.filter(s => isOwnOrNonVp(s, args.ownVpId));
82
+ const out = [];
83
+ for (const scope of visibleScopes) {
84
+ const segs = args.index.listByScope(scope);
85
+ const summarise = segs.length > bodyCap;
86
+ for (const s of segs) {
87
+ out.push({
88
+ id: s.id,
89
+ scope: s.scope,
90
+ kind: s.kind,
91
+ tags: s.tags || [],
92
+ body: summarise ? firstSentence(s.body) : s.body,
93
+ inAMS: args.currentAmsIds.has(s.id),
94
+ summarised: summarise,
95
+ });
96
+ }
97
+ }
98
+ return out;
99
+ }
100
+
101
+ function firstSentence(body) {
102
+ if (!body) return '';
103
+ const m = /^([^.!?。!?\n]+[.!?。!?]?)/.exec(body.trim());
104
+ return m ? m[1].trim() : body.slice(0, 200);
105
+ }
106
+
107
+ function isOwnOrNonVp(scope, ownVpId) {
108
+ if (!scope.startsWith('vp/')) return true;
109
+ if (!ownVpId) return true;
110
+ const other = scope.slice(3).split('/')[0];
111
+ return other === ownVpId;
112
+ }
113
+
114
+ /**
115
+ * Build the prompt the LLM sees. Bilingual-friendly — the engine's
116
+ * regular system prompt provides language; this is just the user-turn
117
+ * payload.
118
+ *
119
+ * @param {object} args
120
+ * @returns {string}
121
+ */
122
+ export function buildAdjustPrompt(args) {
123
+ const {
124
+ userMsg, assistantReply, residentScopes, recentIds, onDemandIds,
125
+ visibleSegments,
126
+ } = args;
127
+
128
+ const lines = [];
129
+ lines.push('# AMS Adjustment Task');
130
+ lines.push('');
131
+ lines.push('You are managing the Active Memory Set (AMS) for the current session.');
132
+ lines.push('Decide which memory segments should be ADDED to AMS.onDemand and which');
133
+ lines.push('should be EVICTED, based on what this turn actually needed.');
134
+ lines.push('');
135
+ lines.push('## Current turn');
136
+ lines.push('### user');
137
+ lines.push(truncate(userMsg, 4000));
138
+ lines.push('### assistant');
139
+ lines.push(truncate(assistantReply, 4000));
140
+ lines.push('');
141
+ lines.push('## Current AMS state');
142
+ lines.push(`resident scopes: ${residentScopes.join(', ') || '(none)'}`);
143
+ lines.push(`recent ids: ${recentIds.slice(0, 50).join(', ') || '(none)'}`);
144
+ lines.push(`onDemand ids: ${onDemandIds.join(', ') || '(none)'}`);
145
+ lines.push('');
146
+ lines.push('## Visible memory segments');
147
+ lines.push('Each row: [inAMS] id | scope | kind | tags | body');
148
+ for (const seg of visibleSegments) {
149
+ lines.push(
150
+ `[${seg.inAMS ? 'X' : ' '}] ${seg.id} | ${seg.scope} | ${seg.kind} | ` +
151
+ `${(seg.tags || []).join(',')} | ${truncate(seg.body, 240)}`,
152
+ );
153
+ }
154
+ lines.push('');
155
+ lines.push('## Output format');
156
+ lines.push('Reply with a single JSON object on its own line:');
157
+ lines.push('```json');
158
+ lines.push('{ "add": ["seg_..."], "evict": ["seg_..."], "reason": "<one line>" }');
159
+ lines.push('```');
160
+ lines.push('Rules: use only ids from the visible list; never repeat an id in both');
161
+ lines.push('arrays; keep evict ⊆ current onDemand; keep add ∩ current onDemand = ∅.');
162
+ return lines.join('\n');
163
+ }
164
+
165
+ function truncate(text, max) {
166
+ if (!text) return '';
167
+ if (text.length <= max) return text;
168
+ return `${text.slice(0, max)}…`;
169
+ }
170
+
171
+ /**
172
+ * Parse the LLM's reply. Tolerant: extracts the first JSON object,
173
+ * coerces missing arrays to []. Returns null on hard parse failure.
174
+ *
175
+ * @param {string} replyText
176
+ * @returns {{ add: string[], evict: string[], reason: string } | null}
177
+ */
178
+ export function parseAdjustReply(replyText) {
179
+ if (!replyText) return null;
180
+ // Strip markdown fences
181
+ const cleaned = replyText.replace(/^```(?:json)?\s*|\s*```$/gm, '').trim();
182
+ // Find first { ... } JSON object
183
+ const start = cleaned.indexOf('{');
184
+ const end = cleaned.lastIndexOf('}');
185
+ if (start < 0 || end <= start) return null;
186
+ const json = cleaned.slice(start, end + 1);
187
+ let obj;
188
+ try { obj = JSON.parse(json); } catch { return null; }
189
+ if (!obj || typeof obj !== 'object') return null;
190
+ const add = Array.isArray(obj.add) ? obj.add.filter(s => typeof s === 'string') : [];
191
+ const evict = Array.isArray(obj.evict) ? obj.evict.filter(s => typeof s === 'string') : [];
192
+ const reason = typeof obj.reason === 'string' ? obj.reason : '';
193
+ return { add, evict, reason };
194
+ }
195
+
196
+ /**
197
+ * Apply an adjustment to AMS membership, with safety guards:
198
+ * - Drop add ids that aren't in the visible segment set.
199
+ * - Drop evict ids that aren't currently in onDemand.
200
+ * - Reject pathological replies (huge add / huge evict).
201
+ *
202
+ * @param {object} args
203
+ * @param {import('./ams.js').ActiveMemorySet} args.ams
204
+ * @param {import('./index-db.js').SegmentIndex} args.index
205
+ * @param {{ add: string[], evict: string[] }} args.decision
206
+ * @param {Set<string>} args.visibleIds
207
+ * @param {number} [args.maxAdd]
208
+ * @param {number} [args.maxEvict]
209
+ * @returns {{ added: number, evicted: number, skipped: number }}
210
+ */
211
+ export function applyAdjustment(args) {
212
+ const maxAdd = args.maxAdd ?? 32;
213
+ const maxEvict = args.maxEvict ?? 32;
214
+ const currentOnDemand = new Set(args.ams.onDemandIds());
215
+ const addIds = (args.decision.add || [])
216
+ .filter(id => args.visibleIds.has(id) && !currentOnDemand.has(id))
217
+ .slice(0, maxAdd);
218
+ const evictIds = (args.decision.evict || [])
219
+ .filter(id => currentOnDemand.has(id))
220
+ .slice(0, maxEvict);
221
+ const skipped =
222
+ (args.decision.add?.length || 0) - addIds.length +
223
+ (args.decision.evict?.length || 0) - evictIds.length;
224
+
225
+ // Resolve add segments via the index
226
+ const addSegs = [];
227
+ for (const id of addIds) {
228
+ const s = args.index.get(id);
229
+ if (s) addSegs.push(s);
230
+ }
231
+ if (addSegs.length > 0) args.ams.addOnDemand(addSegs);
232
+ if (evictIds.length > 0) args.ams.removeOnDemand(evictIds);
233
+
234
+ return {
235
+ added: addSegs.length,
236
+ evicted: evictIds.length,
237
+ skipped: Math.max(0, skipped),
238
+ };
239
+ }
240
+
241
+ /**
242
+ * Full round-trip: decide whether to run, build prompt, call LLM,
243
+ * parse, apply. Returns telemetry counts.
244
+ *
245
+ * The caller supplies the LLM via `runLLM(prompt) → text` so this
246
+ * module stays adapter-agnostic.
247
+ *
248
+ * @param {object} args
249
+ * @param {AdjustTriggerInput} args.trigger
250
+ * @param {import('./ams.js').ActiveMemorySet} args.ams
251
+ * @param {import('./index-db.js').SegmentIndex} args.index
252
+ * @param {string[]} args.scopes
253
+ * @param {string|null} args.ownVpId
254
+ * @param {string} args.userMsg
255
+ * @param {string} args.assistantReply
256
+ * @param {(prompt: string) => Promise<string>} args.runLLM
257
+ * @returns {Promise<{
258
+ * ran: boolean, reason: string,
259
+ * added: number, evicted: number, skipped: number,
260
+ * promptTokens: number,
261
+ * }>}
262
+ */
263
+ export async function runAdjust(args) {
264
+ const decision = shouldRunAdjust(args.trigger);
265
+ if (!decision.run) {
266
+ return { ran: false, reason: decision.reason, added: 0, evicted: 0, skipped: 0, promptTokens: 0 };
267
+ }
268
+ const currentAmsIds = new Set([
269
+ ...args.ams.onDemandIds(),
270
+ ...args.ams.recentIds(),
271
+ ]);
272
+ const visibleSegments = buildVisibleSegments({
273
+ index: args.index, scopes: args.scopes, ownVpId: args.ownVpId,
274
+ currentAmsIds,
275
+ });
276
+ const prompt = buildAdjustPrompt({
277
+ userMsg: args.userMsg,
278
+ assistantReply: args.assistantReply,
279
+ residentScopes: args.ams.residentScopes(),
280
+ recentIds: args.ams.recentIds(),
281
+ onDemandIds: args.ams.onDemandIds(),
282
+ visibleSegments,
283
+ });
284
+ const reply = await args.runLLM(prompt);
285
+ const parsed = parseAdjustReply(reply);
286
+ if (!parsed) {
287
+ return {
288
+ ran: true, reason: decision.reason + '+parse-fail',
289
+ added: 0, evicted: 0, skipped: 0,
290
+ promptTokens: approxTokens(prompt),
291
+ };
292
+ }
293
+ const visibleIds = new Set(visibleSegments.map(s => s.id));
294
+ const apply = applyAdjustment({
295
+ ams: args.ams, index: args.index,
296
+ decision: parsed, visibleIds,
297
+ });
298
+ return {
299
+ ran: true, reason: decision.reason,
300
+ ...apply,
301
+ promptTokens: approxTokens(prompt),
302
+ };
303
+ }