@yeaft/webchat-agent 0.1.646 → 0.1.648
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/memory/adjust.js +303 -0
- package/unify/memory/keywords.js +57 -0
- package/unify/memory/preflow.js +173 -0
package/package.json
CHANGED
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* keywords.js — pure-rule keyword extraction shared by memory recall paths.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from the legacy R5 recall.js so recall-v2.js (and any future
|
|
5
|
+
* recall path) can use it without dragging in the rest of the R5 module.
|
|
6
|
+
* Pure CPU, no LLM, <1ms.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Common stop words filtered out before frequency counting. */
|
|
10
|
+
const STOP_WORDS = new Set([
|
|
11
|
+
'the', 'a', 'an', 'is', 'are', 'was', 'were', 'be', 'been', 'being',
|
|
12
|
+
'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could',
|
|
13
|
+
'should', 'may', 'might', 'can', 'shall', 'to', 'of', 'in', 'for',
|
|
14
|
+
'on', 'with', 'at', 'by', 'from', 'as', 'into', 'through', 'during',
|
|
15
|
+
'before', 'after', 'above', 'below', 'between', 'out', 'off', 'over',
|
|
16
|
+
'under', 'again', 'further', 'then', 'once', 'here', 'there', 'when',
|
|
17
|
+
'where', 'why', 'how', 'all', 'both', 'each', 'few', 'more', 'most',
|
|
18
|
+
'other', 'some', 'such', 'no', 'nor', 'not', 'only', 'own', 'same',
|
|
19
|
+
'so', 'than', 'too', 'very', 'just', 'because', 'but', 'and', 'or',
|
|
20
|
+
'if', 'while', 'about', 'up', 'it', 'its', 'my', 'me', 'i', 'you',
|
|
21
|
+
'your', 'we', 'our', 'they', 'them', 'their', 'this', 'that', 'what',
|
|
22
|
+
'which', 'who', 'whom', 'these', 'those',
|
|
23
|
+
// Chinese stop words
|
|
24
|
+
'的', '了', '在', '是', '我', '有', '和', '就',
|
|
25
|
+
'不', '人', '都', '一', '一个', '上', '也',
|
|
26
|
+
'很', '到', '说', '要', '去', '你', '会',
|
|
27
|
+
'着', '没有', '看', '好', '自己', '这',
|
|
28
|
+
'他', '她', '吗', '呢', '吧', '把', '被',
|
|
29
|
+
'那', '它', '让', '给', '可以', '什么',
|
|
30
|
+
'怎么', '帮', '帮我', '请', '能', '想',
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Extract keywords from a prompt (pure rules, no LLM).
|
|
35
|
+
*
|
|
36
|
+
* @param {string} prompt
|
|
37
|
+
* @returns {string[]} keywords sorted by frequency descending then alpha.
|
|
38
|
+
*/
|
|
39
|
+
export function extractKeywords(prompt) {
|
|
40
|
+
if (!prompt || !prompt.trim()) return [];
|
|
41
|
+
|
|
42
|
+
// Tokenize: split on whitespace + punctuation, keep CJK chars.
|
|
43
|
+
const tokens = prompt
|
|
44
|
+
.toLowerCase()
|
|
45
|
+
.replace(/[^\w\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff]+/g, ' ')
|
|
46
|
+
.split(/\s+/)
|
|
47
|
+
.filter(t => t.length > 1 && !STOP_WORDS.has(t));
|
|
48
|
+
|
|
49
|
+
const freq = new Map();
|
|
50
|
+
for (const t of tokens) {
|
|
51
|
+
freq.set(t, (freq.get(t) || 0) + 1);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return [...freq.entries()]
|
|
55
|
+
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
56
|
+
.map(([word]) => word);
|
|
57
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* memory/preflow.js — DESIGN-H2-AMS §6. Pre-turn memory recall.
|
|
3
|
+
*
|
|
4
|
+
* Pure-CPU pipeline (no LLM). Runs on every turn:
|
|
5
|
+
*
|
|
6
|
+
* userMsg
|
|
7
|
+
* → extractKeywords (rule-based tokeniser)
|
|
8
|
+
* → SQLite FTS5 MATCH (scope-filtered, bm25 ranked)
|
|
9
|
+
* → rerank by (scope match + tag overlap + recency)
|
|
10
|
+
* → onDemand layer (budget-clamped)
|
|
11
|
+
*
|
|
12
|
+
* Latency target: < 10ms p95 on a 10k-segment index.
|
|
13
|
+
*
|
|
14
|
+
* Honours `vp/<other>` privacy: caller passes `ownVpId` and the scope
|
|
15
|
+
* filter excludes foreign VP scopes.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { extractKeywords } from './keywords.js';
|
|
19
|
+
import { approxTokens } from './budget.js';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @typedef {object} PreflowOptions
|
|
23
|
+
* @property {string} userMsg
|
|
24
|
+
* @property {string[]} relevantScopes e.g. ['user', 'group/g1', 'vp/alice']
|
|
25
|
+
* @property {string|null} [ownVpId]
|
|
26
|
+
* @property {string[]} [currentTags] tags from the current group/feature context
|
|
27
|
+
* @property {number} [topK] max FTS rows to fetch (default 50)
|
|
28
|
+
* @property {number} [budgetTokens] onDemand budget (caller-supplied)
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @typedef {object} PreflowResult
|
|
33
|
+
* @property {string[]} keywords
|
|
34
|
+
* @property {string} ftsQuery
|
|
35
|
+
* @property {import('./index-db.js').SearchHit[]} hits
|
|
36
|
+
* @property {import('./segment.js').Segment[]} picked
|
|
37
|
+
* @property {number} pickedTokens
|
|
38
|
+
* @property {number} droppedCount
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Run the pre-flow against a segment index.
|
|
43
|
+
*
|
|
44
|
+
* @param {import('./index-db.js').SegmentIndex} index
|
|
45
|
+
* @param {PreflowOptions} opts
|
|
46
|
+
* @returns {PreflowResult}
|
|
47
|
+
*/
|
|
48
|
+
export function runPreflow(index, opts) {
|
|
49
|
+
const userMsg = (opts.userMsg || '').trim();
|
|
50
|
+
const relevantScopes = Array.isArray(opts.relevantScopes) ? opts.relevantScopes : [];
|
|
51
|
+
const ownVpId = opts.ownVpId || null;
|
|
52
|
+
const currentTags = Array.isArray(opts.currentTags) ? opts.currentTags : [];
|
|
53
|
+
const topK = Number.isFinite(opts.topK) && opts.topK > 0 ? opts.topK : 50;
|
|
54
|
+
const budgetTokens = Number.isFinite(opts.budgetTokens) && opts.budgetTokens > 0
|
|
55
|
+
? opts.budgetTokens : Infinity;
|
|
56
|
+
|
|
57
|
+
const keywords = extractKeywords(userMsg);
|
|
58
|
+
if (keywords.length === 0) {
|
|
59
|
+
return {
|
|
60
|
+
keywords: [], ftsQuery: '', hits: [],
|
|
61
|
+
picked: [], pickedTokens: 0, droppedCount: 0,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const ftsQuery = buildFtsQuery(keywords);
|
|
66
|
+
const scopeFilter = filterScopes(relevantScopes, ownVpId);
|
|
67
|
+
if (scopeFilter.length === 0) {
|
|
68
|
+
return {
|
|
69
|
+
keywords, ftsQuery, hits: [],
|
|
70
|
+
picked: [], pickedTokens: 0, droppedCount: 0,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const hits = index.search({ query: ftsQuery, scopeFilter, limit: topK });
|
|
75
|
+
const reranked = rerank(hits, { currentTags });
|
|
76
|
+
|
|
77
|
+
const picked = [];
|
|
78
|
+
let cost = 0;
|
|
79
|
+
let dropped = 0;
|
|
80
|
+
for (const h of reranked) {
|
|
81
|
+
const tk = approxTokens(h.body);
|
|
82
|
+
if (cost + tk <= budgetTokens) {
|
|
83
|
+
picked.push(toSegment(h));
|
|
84
|
+
cost += tk;
|
|
85
|
+
} else {
|
|
86
|
+
dropped += 1;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
keywords, ftsQuery, hits: reranked,
|
|
92
|
+
picked, pickedTokens: cost, droppedCount: dropped,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Compose an FTS5 MATCH query from keywords. Each keyword is OR'd with
|
|
98
|
+
* a prefix wildcard so morphological variants match. We escape any
|
|
99
|
+
* FTS5-special characters by quoting tokens.
|
|
100
|
+
*
|
|
101
|
+
* @param {string[]} keywords
|
|
102
|
+
* @returns {string}
|
|
103
|
+
*/
|
|
104
|
+
export function buildFtsQuery(keywords) {
|
|
105
|
+
const cleaned = keywords
|
|
106
|
+
.map(k => k.replace(/"/g, ''))
|
|
107
|
+
.filter(k => k.length > 1)
|
|
108
|
+
.slice(0, 8); // top-8 keywords — avoid query bloat
|
|
109
|
+
if (cleaned.length === 0) return '';
|
|
110
|
+
return cleaned.map(k => `"${k}"*`).join(' OR ');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Strip foreign VP scopes from the filter list (privacy).
|
|
115
|
+
*
|
|
116
|
+
* @param {string[]} scopes
|
|
117
|
+
* @param {string|null} ownVpId
|
|
118
|
+
* @returns {string[]}
|
|
119
|
+
*/
|
|
120
|
+
export function filterScopes(scopes, ownVpId) {
|
|
121
|
+
return scopes.filter(s => {
|
|
122
|
+
if (!s.startsWith('vp/')) return true;
|
|
123
|
+
if (!ownVpId) return true;
|
|
124
|
+
const other = s.slice(3).split('/')[0];
|
|
125
|
+
return other === ownVpId;
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Rerank FTS hits with two soft signals on top of bm25:
|
|
131
|
+
* - tag overlap with the current group/feature context (subtract penalty)
|
|
132
|
+
* - recency: recent items get a small bonus
|
|
133
|
+
*
|
|
134
|
+
* SQLite FTS5 bm25 returns NEGATIVE numbers (more negative = better
|
|
135
|
+
* match). We treat lower score as better. To make overlap & recency
|
|
136
|
+
* push hits ahead, we SUBTRACT bonuses from the bm25 base (making the
|
|
137
|
+
* score more negative).
|
|
138
|
+
*
|
|
139
|
+
* @param {import('./index-db.js').SearchHit[]} hits
|
|
140
|
+
* @param {{ currentTags: string[] }} ctx
|
|
141
|
+
* @returns {import('./index-db.js').SearchHit[]}
|
|
142
|
+
*/
|
|
143
|
+
export function rerank(hits, ctx) {
|
|
144
|
+
const tagSet = new Set((ctx.currentTags || []).map(t => String(t).toLowerCase()));
|
|
145
|
+
const now = Date.now();
|
|
146
|
+
return [...hits]
|
|
147
|
+
.map(h => {
|
|
148
|
+
const overlap = (h.tags || []).reduce(
|
|
149
|
+
(n, t) => n + (tagSet.has(String(t).toLowerCase()) ? 1 : 0), 0,
|
|
150
|
+
);
|
|
151
|
+
const tagBonus = Math.min(2, overlap * 0.5); // up to 2 points
|
|
152
|
+
const ageDays = Math.max(0, (now - Date.parse(h.updatedAt || h.createdAt || '')) / 86400000);
|
|
153
|
+
const recencyBonus = Math.min(0.5, 0.2 / Math.max(0.5, ageDays + 1));
|
|
154
|
+
const base = h.rank ?? 0;
|
|
155
|
+
const score = base - tagBonus - recencyBonus;
|
|
156
|
+
return { ...h, _score: score };
|
|
157
|
+
})
|
|
158
|
+
.sort((a, b) => a._score - b._score)
|
|
159
|
+
.map(({ _score, ...rest }) => rest);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function toSegment(h) {
|
|
163
|
+
return {
|
|
164
|
+
id: h.id,
|
|
165
|
+
scope: h.scope,
|
|
166
|
+
kind: h.kind,
|
|
167
|
+
tags: h.tags,
|
|
168
|
+
sourceMessages: h.sourceMessages,
|
|
169
|
+
body: h.body,
|
|
170
|
+
createdAt: h.createdAt,
|
|
171
|
+
updatedAt: h.updatedAt,
|
|
172
|
+
};
|
|
173
|
+
}
|