@yeaft/webchat-agent 0.1.645 → 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 +1 -1
- package/unify/memory/ams.js +197 -0
- package/unify/memory/budget.js +111 -0
- package/unify/memory/keywords.js +57 -0
- package/unify/memory/preflow.js +173 -0
- package/unify/memory/summary-store.js +63 -0
package/package.json
CHANGED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* memory/ams.js — DESIGN-H2-AMS §5. Active Memory Set.
|
|
3
|
+
*
|
|
4
|
+
* Per-group session state. Three layers:
|
|
5
|
+
*
|
|
6
|
+
* resident summaries of all relevant scopes — always-on, high precision
|
|
7
|
+
* recent LRU of segments touched in last N turns — warm cache
|
|
8
|
+
* onDemand segments the pre-flow FTS pulled in this turn — hot recall
|
|
9
|
+
*
|
|
10
|
+
* AMS is in-memory; it's rebuilt at session start. The disk source of
|
|
11
|
+
* truth is `<scope>/memory.md` + `<scope>/summary.md`. AMS itself
|
|
12
|
+
* doesn't write to disk — that's Dream's job.
|
|
13
|
+
*
|
|
14
|
+
* Privacy (DESIGN-v2 §2.2): `vp/<other>` scopes are ALWAYS filtered out
|
|
15
|
+
* for any worker that isn't `<other>`. The owning code passes its own
|
|
16
|
+
* vpId at construction.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { approxTokens, packWithinBudget } from './budget.js';
|
|
20
|
+
|
|
21
|
+
const RECENT_DEFAULT_CAPACITY = 64;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @typedef {object} AmsLayers
|
|
25
|
+
* @property {Map<string, string>} resident
|
|
26
|
+
* @property {Array<{ id: string, seg: import('./segment.js').Segment, ts: number }>} recent
|
|
27
|
+
* @property {Map<string, import('./segment.js').Segment>} onDemand
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @typedef {object} AmsSnapshot
|
|
32
|
+
* @property {Array<{ scope: string, summary: string }>} resident
|
|
33
|
+
* @property {import('./segment.js').Segment[]} recent
|
|
34
|
+
* @property {import('./segment.js').Segment[]} onDemand
|
|
35
|
+
* @property {{ resident: number, recent: number, onDemand: number, total: number }} usage
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
export class ActiveMemorySet {
|
|
39
|
+
/**
|
|
40
|
+
* @param {{
|
|
41
|
+
* ownVpId?: string | null,
|
|
42
|
+
* budget: import('./budget.js').BudgetSplit,
|
|
43
|
+
* recentCapacity?: number,
|
|
44
|
+
* }} opts
|
|
45
|
+
*/
|
|
46
|
+
constructor(opts) {
|
|
47
|
+
if (!opts || !opts.budget) throw new Error('ActiveMemorySet: budget required');
|
|
48
|
+
this.ownVpId = opts.ownVpId || null;
|
|
49
|
+
this.budget = opts.budget;
|
|
50
|
+
this.recentCapacity = opts.recentCapacity || RECENT_DEFAULT_CAPACITY;
|
|
51
|
+
/** @type {Map<string, string>} */
|
|
52
|
+
this._resident = new Map(); // scope → summaryText
|
|
53
|
+
/** @type {Map<string, { seg: import('./segment.js').Segment, ts: number }>} */
|
|
54
|
+
this._recent = new Map(); // segId → entry (insertion-order is LRU order)
|
|
55
|
+
/** @type {Map<string, import('./segment.js').Segment>} */
|
|
56
|
+
this._onDemand = new Map(); // segId → segment
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ────────────────────────── resident ──────────────────────────
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Replace the resident layer with a fresh set of scope→summary
|
|
63
|
+
* pairs. Foreign VP scopes are silently dropped.
|
|
64
|
+
*
|
|
65
|
+
* @param {Array<{ scope: string, summary: string }>} entries
|
|
66
|
+
*/
|
|
67
|
+
setResident(entries) {
|
|
68
|
+
this._resident.clear();
|
|
69
|
+
for (const e of entries) {
|
|
70
|
+
if (this._isForeignVp(e.scope)) continue;
|
|
71
|
+
if (!e.summary) continue;
|
|
72
|
+
this._resident.set(e.scope, e.summary);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ────────────────────────── recent ──────────────────────────
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Touch a segment as "used this turn". LRU semantics: most-recent at
|
|
80
|
+
* the end. Trims to capacity automatically.
|
|
81
|
+
*
|
|
82
|
+
* @param {import('./segment.js').Segment} seg
|
|
83
|
+
*/
|
|
84
|
+
touchRecent(seg) {
|
|
85
|
+
if (!seg || !seg.id) return;
|
|
86
|
+
if (this._isForeignVp(seg.scope)) return;
|
|
87
|
+
if (this._recent.has(seg.id)) this._recent.delete(seg.id);
|
|
88
|
+
this._recent.set(seg.id, { seg, ts: Date.now() });
|
|
89
|
+
while (this._recent.size > this.recentCapacity) {
|
|
90
|
+
const firstKey = this._recent.keys().next().value;
|
|
91
|
+
this._recent.delete(firstKey);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ────────────────────────── onDemand ──────────────────────────
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Replace the onDemand layer with this turn's FTS hits.
|
|
99
|
+
*
|
|
100
|
+
* @param {import('./segment.js').Segment[]} segments
|
|
101
|
+
*/
|
|
102
|
+
setOnDemand(segments) {
|
|
103
|
+
this._onDemand.clear();
|
|
104
|
+
for (const seg of segments) {
|
|
105
|
+
if (this._isForeignVp(seg.scope)) continue;
|
|
106
|
+
this._onDemand.set(seg.id, seg);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Add segments to onDemand without clearing (used by adjustMemory).
|
|
112
|
+
*
|
|
113
|
+
* @param {import('./segment.js').Segment[]} segments
|
|
114
|
+
*/
|
|
115
|
+
addOnDemand(segments) {
|
|
116
|
+
for (const seg of segments) {
|
|
117
|
+
if (this._isForeignVp(seg.scope)) continue;
|
|
118
|
+
this._onDemand.set(seg.id, seg);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Remove segment ids from onDemand (used by adjustMemory eviction).
|
|
124
|
+
*
|
|
125
|
+
* @param {string[]} ids
|
|
126
|
+
*/
|
|
127
|
+
removeOnDemand(ids) {
|
|
128
|
+
for (const id of ids) this._onDemand.delete(id);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ────────────────────────── snapshot ──────────────────────────
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Produce a budget-aware snapshot that can be injected into the
|
|
135
|
+
* system prompt. Each layer is greedily packed within its budget;
|
|
136
|
+
* overflow is dropped from this turn but not from disk.
|
|
137
|
+
*
|
|
138
|
+
* @returns {AmsSnapshot}
|
|
139
|
+
*/
|
|
140
|
+
snapshot() {
|
|
141
|
+
// Resident: pack scopes by priority order (caller provides via insert
|
|
142
|
+
// order — current group's own vp first, then user, etc.).
|
|
143
|
+
const resEntries = [...this._resident.entries()].map(([scope, summary]) => ({
|
|
144
|
+
scope, summary,
|
|
145
|
+
}));
|
|
146
|
+
const { picked: resPicked, cost: resCost } = packWithinBudget(
|
|
147
|
+
resEntries, this.budget.resident,
|
|
148
|
+
e => approxTokens(e.summary),
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
// Recent: insertion order is oldest-first; we want newest first.
|
|
152
|
+
const recentArr = [...this._recent.values()]
|
|
153
|
+
.reverse()
|
|
154
|
+
.map(e => e.seg);
|
|
155
|
+
const { picked: recPicked, cost: recCost } = packWithinBudget(
|
|
156
|
+
recentArr, this.budget.recent,
|
|
157
|
+
seg => approxTokens(seg.body),
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
// OnDemand: insertion order from caller (already FTS-ranked).
|
|
161
|
+
const odArr = [...this._onDemand.values()];
|
|
162
|
+
const { picked: odPicked, cost: odCost } = packWithinBudget(
|
|
163
|
+
odArr, this.budget.onDemand,
|
|
164
|
+
seg => approxTokens(seg.body),
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
resident: resPicked,
|
|
169
|
+
recent: recPicked,
|
|
170
|
+
onDemand: odPicked,
|
|
171
|
+
usage: {
|
|
172
|
+
resident: resCost,
|
|
173
|
+
recent: recCost,
|
|
174
|
+
onDemand: odCost,
|
|
175
|
+
total: resCost + recCost + odCost,
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Read-only inspectors (for tests / observability / adjustMemory input).
|
|
182
|
+
*/
|
|
183
|
+
residentScopes() { return [...this._resident.keys()]; }
|
|
184
|
+
recentIds() { return [...this._recent.keys()]; }
|
|
185
|
+
onDemandIds() { return [...this._onDemand.keys()]; }
|
|
186
|
+
onDemandSegments() { return [...this._onDemand.values()]; }
|
|
187
|
+
size() { return this._resident.size + this._recent.size + this._onDemand.size; }
|
|
188
|
+
|
|
189
|
+
// ────────────────────────── privacy ──────────────────────────
|
|
190
|
+
|
|
191
|
+
_isForeignVp(scope) {
|
|
192
|
+
if (!scope || !scope.startsWith('vp/')) return false;
|
|
193
|
+
if (!this.ownVpId) return false; // no own id → no filtering
|
|
194
|
+
const other = scope.slice(3).split('/')[0];
|
|
195
|
+
return other !== this.ownVpId;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* memory/budget.js — DESIGN-H2-AMS §5.2.
|
|
3
|
+
*
|
|
4
|
+
* Memory budget = `min(50_000, modelMaxContext * 0.10)`.
|
|
5
|
+
*
|
|
6
|
+
* Then split across the three AMS layers (resident / recent / onDemand)
|
|
7
|
+
* with a configurable ratio. The defaults are tuned for ~200k context
|
|
8
|
+
* models (Claude / GPT-5):
|
|
9
|
+
*
|
|
10
|
+
* resident 40% → 20k (all relevant scope summaries)
|
|
11
|
+
* recent 25% → 12.5k (LRU of recently-used segments)
|
|
12
|
+
* onDemand 35% → 17.5k (this turn's FTS recall)
|
|
13
|
+
*
|
|
14
|
+
* Token counting here is approximate (chars / 4) — accurate enough for
|
|
15
|
+
* budget enforcement. The engine has a real tokenizer for prompt
|
|
16
|
+
* assembly; budget here is a guard rail, not the source of truth.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export const ABSOLUTE_CAP = 50_000;
|
|
20
|
+
export const MODEL_FRACTION = 0.10;
|
|
21
|
+
|
|
22
|
+
export const DEFAULT_RATIO = {
|
|
23
|
+
resident: 0.40,
|
|
24
|
+
recent: 0.25,
|
|
25
|
+
onDemand: 0.35,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @typedef {object} BudgetSplit
|
|
30
|
+
* @property {number} total
|
|
31
|
+
* @property {number} resident
|
|
32
|
+
* @property {number} recent
|
|
33
|
+
* @property {number} onDemand
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @param {number} modelMaxContext tokens of the model's full context window
|
|
38
|
+
* @param {Partial<typeof DEFAULT_RATIO>} [ratio]
|
|
39
|
+
* @returns {BudgetSplit}
|
|
40
|
+
*/
|
|
41
|
+
export function computeBudget(modelMaxContext, ratio = {}) {
|
|
42
|
+
const ctx = Number.isFinite(modelMaxContext) && modelMaxContext > 0
|
|
43
|
+
? modelMaxContext : 200_000;
|
|
44
|
+
const total = Math.min(ABSOLUTE_CAP, Math.floor(ctx * MODEL_FRACTION));
|
|
45
|
+
|
|
46
|
+
const r = { ...DEFAULT_RATIO, ...ratio };
|
|
47
|
+
// Normalise so ratios sum to 1 (defensive).
|
|
48
|
+
const sum = r.resident + r.recent + r.onDemand;
|
|
49
|
+
const norm = sum > 0 ? sum : 1;
|
|
50
|
+
return {
|
|
51
|
+
total,
|
|
52
|
+
resident: Math.floor(total * (r.resident / norm)),
|
|
53
|
+
recent: Math.floor(total * (r.recent / norm)),
|
|
54
|
+
onDemand: Math.floor(total * (r.onDemand / norm)),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Approximate token count of a string. Avg English ≈ 4 chars / token,
|
|
60
|
+
* Chinese ≈ 1 char / token. We use a conservative blended estimate.
|
|
61
|
+
*
|
|
62
|
+
* @param {string} text
|
|
63
|
+
* @returns {number}
|
|
64
|
+
*/
|
|
65
|
+
export function approxTokens(text) {
|
|
66
|
+
if (!text) return 0;
|
|
67
|
+
// Count CJK chars as ~1 token each, the rest as char/4.
|
|
68
|
+
let cjk = 0;
|
|
69
|
+
let other = 0;
|
|
70
|
+
for (const ch of text) {
|
|
71
|
+
const c = ch.codePointAt(0) || 0;
|
|
72
|
+
if (
|
|
73
|
+
(c >= 0x4e00 && c <= 0x9fff) ||
|
|
74
|
+
(c >= 0x3040 && c <= 0x309f) ||
|
|
75
|
+
(c >= 0x30a0 && c <= 0x30ff) ||
|
|
76
|
+
(c >= 0xac00 && c <= 0xd7af)
|
|
77
|
+
) {
|
|
78
|
+
cjk += 1;
|
|
79
|
+
} else {
|
|
80
|
+
other += 1;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return Math.ceil(cjk + other / 4);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Greedy pack: pick items in order until adding the next would exceed
|
|
88
|
+
* the budget. Returns the picked list and the total cost. Does NOT
|
|
89
|
+
* sort — caller decides ordering.
|
|
90
|
+
*
|
|
91
|
+
* @template T
|
|
92
|
+
* @param {T[]} items
|
|
93
|
+
* @param {number} budget
|
|
94
|
+
* @param {(item: T) => number} costFn
|
|
95
|
+
* @returns {{ picked: T[], cost: number, dropped: T[] }}
|
|
96
|
+
*/
|
|
97
|
+
export function packWithinBudget(items, budget, costFn) {
|
|
98
|
+
const picked = [];
|
|
99
|
+
const dropped = [];
|
|
100
|
+
let cost = 0;
|
|
101
|
+
for (const it of items) {
|
|
102
|
+
const c = costFn(it);
|
|
103
|
+
if (cost + c <= budget) {
|
|
104
|
+
picked.push(it);
|
|
105
|
+
cost += c;
|
|
106
|
+
} else {
|
|
107
|
+
dropped.push(it);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return { picked, cost, dropped };
|
|
111
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* memory/summary-store.js — DESIGN-H2-AMS §3.
|
|
3
|
+
*
|
|
4
|
+
* `summary.md` is a bounded, per-scope prose digest derived from all
|
|
5
|
+
* segments in that scope. Resident AMS layer = concatenation of all
|
|
6
|
+
* relevant scope summaries. Regenerated by Dream after segments change.
|
|
7
|
+
*
|
|
8
|
+
* Layout:
|
|
9
|
+
* ~/.yeaft/memory/<scope>/summary.md
|
|
10
|
+
*
|
|
11
|
+
* Format: plain prose, optionally prefixed with a one-line metadata
|
|
12
|
+
* header `<!-- updatedAt: ISO -->`. No required schema beyond non-empty
|
|
13
|
+
* text.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
readFileSync, writeFileSync, existsSync, mkdirSync, renameSync,
|
|
18
|
+
} from 'node:fs';
|
|
19
|
+
import { join, dirname } from 'node:path';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param {string} memoryRoot
|
|
23
|
+
* @param {string} scope
|
|
24
|
+
* @returns {string}
|
|
25
|
+
*/
|
|
26
|
+
export function summaryPath(memoryRoot, scope) {
|
|
27
|
+
return join(memoryRoot, scope, 'summary.md');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Read summary text for a scope (empty string if missing).
|
|
32
|
+
*
|
|
33
|
+
* @param {string} memoryRoot
|
|
34
|
+
* @param {string} scope
|
|
35
|
+
* @returns {string}
|
|
36
|
+
*/
|
|
37
|
+
export function readSummary(memoryRoot, scope) {
|
|
38
|
+
const p = summaryPath(memoryRoot, scope);
|
|
39
|
+
if (!existsSync(p)) return '';
|
|
40
|
+
return stripHeader(readFileSync(p, 'utf8')).trim();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Atomic write summary text. Empty string = wipe content but keep file.
|
|
45
|
+
*
|
|
46
|
+
* @param {string} memoryRoot
|
|
47
|
+
* @param {string} scope
|
|
48
|
+
* @param {string} text
|
|
49
|
+
*/
|
|
50
|
+
export function writeSummary(memoryRoot, scope, text) {
|
|
51
|
+
const p = summaryPath(memoryRoot, scope);
|
|
52
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
53
|
+
const header = `<!-- updatedAt: ${new Date().toISOString()} -->\n`;
|
|
54
|
+
const body = (text || '').trim();
|
|
55
|
+
const final = body ? `${header}${body}\n` : header;
|
|
56
|
+
const tmp = `${p}.tmp`;
|
|
57
|
+
writeFileSync(tmp, final, 'utf8');
|
|
58
|
+
renameSync(tmp, p);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function stripHeader(text) {
|
|
62
|
+
return text.replace(/^<!--[^>]*-->\s*/m, '');
|
|
63
|
+
}
|