@yeaft/webchat-agent 0.1.646 → 0.1.647

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.646",
3
+ "version": "0.1.647",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -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
+ }