@yeaft/webchat-agent 0.1.645 → 0.1.646
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/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,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
|
+
}
|